diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix
index a063bc61..3dd47569 100644
--- a/modules/home-manager/sops.nix
+++ b/modules/home-manager/sops.nix
@@ -3,7 +3,14 @@
let
cfg = config.sops;
sops-install-secrets = (pkgs.callPackage ../.. {}).sops-install-secrets;
- secretType = lib.types.submodule ({ config, name, ... }: {
+ secretType = lib.types.submodule ({ config, options, name, ... }: {
+ config = lib.mkMerge[{
+ sopsFile = lib.mkOptionDefault cfg.defaultSopsFile;
+ sopsFiles = lib.mkIf (lib.length cfg.defaultSopsFiles > 0) (lib.mkOptionDefault cfg.defaultSopsFiles);
+ }
+ {
+ sopsFiles = lib.mkIf (config.sopsFile != null) ( lib.mkOverride options.sopsFile.highestPrio (lib.mkBefore [config.sopsFile]));
+ }];
options = {
name = lib.mkOption {
type = lib.types.str;
@@ -53,12 +60,19 @@ let
sopsFile = lib.mkOption {
type = lib.types.path;
- default = cfg.defaultSopsFile;
defaultText = "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
};
+
+ sopsFiles = lib.mkOption {
+ type = lib.types.nonEmptyListOf lib.types.path;
+ defaultText = "\${config.sops.defaultSopsFiles}";
+ description = ''
+ Sops files the secret is loaded from.
+ '';
+ };
};
});
@@ -110,12 +124,21 @@ in {
};
defaultSopsFile = lib.mkOption {
- type = lib.types.path;
+ type = lib.types.nullOr lib.types.path;
+ default = null;
description = ''
Default sops file used for all secrets.
'';
};
+ defaultSopsFiles = lib.mkOption {
+ type = lib.types.listOf lib.types.path;
+ default = [];
+ description = ''
+ Default sops files used for all secrets.
+ '';
+ };
+
defaultSopsFormat = lib.mkOption {
type = lib.types.str;
default = "yaml";
@@ -222,15 +245,20 @@ in {
assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []);
message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set";
}] ++ lib.optionals cfg.validateSopsFiles (
- lib.concatLists (lib.mapAttrsToList (name: secret: [{
- assertion = builtins.pathExists secret.sopsFile;
- message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
- } {
- assertion =
- builtins.isPath secret.sopsFile ||
- (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
- message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
- }]) cfg.secrets)
+ lib.concatLists (lib.mapAttrsToList
+ (name: secret:
+ lib.concatMap
+ (sopsFile: [{
+ assertion = builtins.pathExists sopsFile;
+ message = "Cannot find path '${sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFiles";
+ } {
+ assertion =
+ builtins.isPath sopsFile ||
+ (builtins.isString sopsFile && lib.hasPrefix builtins.storeDir sopsFile);
+ message = "'${sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
+ }])
+ secret.sopsFiles)
+ cfg.secrets)
);
systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux {
diff --git a/modules/sops/default.nix b/modules/sops/default.nix
index a3f1c965..a1882bab 100644
--- a/modules/sops/default.nix
+++ b/modules/sops/default.nix
@@ -7,13 +7,18 @@ let
users = config.users.users;
sops-install-secrets = cfg.package;
sops-install-secrets-check = cfg.validationPackage;
- regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets;
- secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets;
- secretType = types.submodule ({ config, ... }: {
- config = {
- sopsFile = lib.mkOptionDefault cfg.defaultSopsFile;
- sopsFileHash = mkOptionDefault (optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}");
- };
+ secrets = mapAttrs (_: secret: removeAttrs secret ["sopsFile"]) cfg.secrets;
+ regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) secrets;
+ secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) secrets;
+ secretType = types.submodule ({ config, options, ... }: {
+ config = mkMerge [{
+ sopsFile = mkOptionDefault cfg.defaultSopsFile;
+ sopsFiles = mkIf (length cfg.defaultSopsFiles > 0) (mkOptionDefault cfg.defaultSopsFiles);
+ sopsFilesHash = mkOptionDefault (optionals cfg.validateSopsFiles (forEach config.sopsFiles (builtins.hashFile "sha256")));
+ }
+ {
+ sopsFiles = mkIf (config.sopsFile != null) (mkOverride options.sopsFile.highestPrio (mkBefore [config.sopsFile]));
+ }];
options = {
name = mkOption {
type = types.str;
@@ -71,17 +76,24 @@ let
'';
};
sopsFile = mkOption {
- type = types.path;
+ type = types.nullOr types.path;
defaultText = "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
};
- sopsFileHash = mkOption {
- type = types.str;
+ sopsFiles = mkOption {
+ type = types.nonEmptyListOf types.path;
+ defaultText = "\${config.sops.defaultSopsFiles}";
+ description = ''
+ Sops files the secret is loaded from.
+ '';
+ };
+ sopsFilesHash = mkOption {
+ type = types.nonEmptyListOf types.str;
readOnly = true;
description = ''
- Hash of the sops file, useful in .
+ Hash of the sops files, useful in .
'';
};
restartUnits = mkOption {
@@ -167,12 +179,21 @@ in {
};
defaultSopsFile = mkOption {
- type = types.path;
+ type = types.nullOr types.path;
+ default = null;
description = ''
Default sops file used for all secrets.
'';
};
+ defaultSopsFiles = mkOption {
+ type = types.listOf types.path;
+ default = [];
+ description = ''
+ Default sops files used for all secrets.
+ '';
+ };
+
defaultSopsFormat = mkOption {
type = types.str;
default = "yaml";
@@ -331,17 +352,23 @@ in {
assertion = (filterAttrs (_: v: v.owner != "root" || v.group != "root") secretsForUsers) == {};
message = "neededForUsers cannot be used for secrets that are not root-owned";
}] ++ optionals cfg.validateSopsFiles (
- concatLists (mapAttrsToList (name: secret: [{
- assertion = builtins.pathExists secret.sopsFile;
- message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${strings.escapeNixIdentifier name}.sopsFile";
- } {
- assertion =
- builtins.isPath secret.sopsFile ||
- (builtins.isString secret.sopsFile && hasPrefix builtins.storeDir secret.sopsFile);
- message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
- }]) cfg.secrets)
+ concatLists (mapAttrsToList
+ (name: secret:
+ concatMap
+ (sopsFile: [{
+ assertion = builtins.pathExists sopsFile;
+ message = "Cannot find path '${sopsFile}' set in sops.secrets.${strings.escapeNixIdentifier name}.sopsFiles";
+ } {
+ assertion =
+ builtins.isPath sopsFile ||
+ (builtins.isString sopsFile && hasPrefix builtins.storeDir sopsFile);
+ message = "'${sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
+ }])
+ secret.sopsFiles)
+ cfg.secrets)
);
+
sops.environment.SOPS_GPG_EXEC = mkIf (cfg.gnupg.home != null) (mkDefault "${pkgs.gnupg}/bin/gpg");
system.activationScripts = {
diff --git a/pkgs/sops-install-secrets/main.go b/pkgs/sops-install-secrets/main.go
index 911fcb49..f8cdbbe9 100644
--- a/pkgs/sops-install-secrets/main.go
+++ b/pkgs/sops-install-secrets/main.go
@@ -29,7 +29,7 @@ type secret struct {
Path string `json:"path"`
Owner string `json:"owner"`
Group string `json:"group"`
- SopsFile string `json:"sopsFile"`
+ SopsFiles []string `json:"sopsFiles"`
Format FormatType `json:"format"`
Mode string `json:"mode"`
RestartUnits []string `json:"restartUnits"`
@@ -258,40 +258,47 @@ func recurseSecretKey(keys map[string]interface{}, wantedKey string) (string, er
}
func decryptSecret(s *secret, sourceFiles map[string]plainData) error {
- sourceFile := sourceFiles[s.SopsFile]
- if sourceFile.data == nil || sourceFile.binary == nil {
- plain, err := decrypt.File(s.SopsFile, string(s.Format))
- if err != nil {
- return fmt.Errorf("Failed to decrypt '%s': %w", s.SopsFile, err)
- }
+ for i := len(s.SopsFiles) - 1; i >= 0; i-- {
+ sourceFile := sourceFiles[s.SopsFiles[i]]
+ if sourceFile.data == nil || sourceFile.binary == nil {
+ plain, err := decrypt.File(s.SopsFiles[i], string(s.Format))
+ if err != nil {
+ return fmt.Errorf("Failed to decrypt '%s': %w", s.SopsFiles[i], err)
+ }
+ switch s.Format {
+ case Binary, Dotenv, Ini:
+ sourceFile.binary = plain
+ case Yaml:
+ if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil {
+ return fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFiles[i], err)
+ }
+ case Json:
+ if err := json.Unmarshal(plain, &sourceFile.data); err != nil {
+ return fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFiles[i], err)
+ }
+ default:
+ return fmt.Errorf("Secret of type %s in %s is not supported", s.Format, s.SopsFiles[i])
+ }
+ }
switch s.Format {
case Binary, Dotenv, Ini:
- sourceFile.binary = plain
- case Yaml:
- if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil {
- return fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFile, err)
- }
- case Json:
- if err := json.Unmarshal(plain, &sourceFile.data); err != nil {
- return fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err)
+ s.value = sourceFile.binary
+ case Yaml, Json:
+ strVal, err := recurseSecretKey(sourceFile.data, s.Key)
+ if err != nil {
+ continue
}
- default:
- return fmt.Errorf("Secret of type %s in %s is not supported", s.Format, s.SopsFile)
- }
- }
- switch s.Format {
- case Binary, Dotenv, Ini:
- s.value = sourceFile.binary
- case Yaml, Json:
- strVal, err := recurseSecretKey(sourceFile.data, s.Key)
- if err != nil {
- return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err)
+ s.value = []byte(strVal)
}
- s.value = []byte(strVal)
+
+ sourceFiles[s.SopsFiles[i]] = sourceFile
+ // Secret found
+ return nil
}
- sourceFiles[s.SopsFile] = sourceFile
- return nil
+
+ // Secret not found in any of the SopsFiles
+ return fmt.Errorf("secret %s in %v is not valid", s.Name, s.SopsFiles)
}
func decryptSecrets(secrets []secret) error {
@@ -395,14 +402,14 @@ func lookupKeysGroup() (int, error) {
return 0, fmt.Errorf("Can't find group 'keys' nor 'nogroup' (%w).", err2)
}
-func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
+func (app *appContext) loadSopsFile(s *secret, sopsFile *string) (*secretFile, error) {
if app.checkMode == Manifest {
return &secretFile{firstSecret: s}, nil
}
- cipherText, err := os.ReadFile(s.SopsFile)
+ cipherText, err := os.ReadFile(*sopsFile)
if err != nil {
- return nil, fmt.Errorf("Failed reading %s: %w", s.SopsFile, err)
+ return nil, fmt.Errorf("Failed reading %s: %w", SopsFile, err)
}
var keys map[string]interface{}
@@ -410,17 +417,17 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
switch s.Format {
case Binary:
if err := json.Unmarshal(cipherText, &keys); err != nil {
- return nil, fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err)
+ return nil, fmt.Errorf("Cannot parse json of '%s': %w", *sopsFile, err)
}
return &secretFile{cipherText: cipherText, firstSecret: s}, nil
case Yaml:
if err := yaml.Unmarshal(cipherText, &keys); err != nil {
- return nil, fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFile, err)
+ return nil, fmt.Errorf("Cannot parse yaml of '%s': %w", *sopsFile, err)
}
case Dotenv:
env, err := godotenv.Unmarshal(string(cipherText))
if err != nil {
- return nil, fmt.Errorf("Cannot parse dotenv of '%s': %w", s.SopsFile, err)
+ return nil, fmt.Errorf("Cannot parse dotenv of '%s': %w", *sopsFile, err)
}
keys = map[string]interface{}{}
for k, v := range env {
@@ -428,7 +435,7 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
}
case Json:
if err := json.Unmarshal(cipherText, &keys); err != nil {
- return nil, fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err)
+ return nil, fmt.Errorf("Cannot parse json of '%s': %w", *sopsFile, err)
}
}
@@ -441,14 +448,14 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
func (app *appContext) validateSopsFile(s *secret, file *secretFile) error {
if file.firstSecret.Format != s.Format {
- return fmt.Errorf("secret %s defined the format of %s as %s, but it was specified as %s in %s before",
- s.Name, s.SopsFile, s.Format,
+ return fmt.Errorf("secret %s defined the format of %v as %s, but it was specified as %s in %s before",
+ s.Name, s.SopsFiles, s.Format,
file.firstSecret.Format, file.firstSecret.Name)
}
if app.checkMode != Manifest && (!(s.Format == Binary || s.Format == Dotenv || s.Format == Ini)) {
_, err := recurseSecretKey(file.keys, s.Key)
if err != nil {
- return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err)
+ return fmt.Errorf("secret %s in %s is not valid: %v", s.Name, s.SopsFiles, err)
}
}
return nil
@@ -495,17 +502,31 @@ func (app *appContext) validateSecret(secret *secret) error {
return fmt.Errorf("Unsupported format %s for secret %s", secret.Format, secret.Name)
}
- file, ok := app.secretFiles[secret.SopsFile]
- if !ok {
- maybeFile, err := app.loadSopsFile(secret)
- if err != nil {
- return err
+ files := []secretFile{}
+ for _, sopsFile := range secret.SopsFiles {
+ file, ok := app.secretFiles[sopsFile]
+ if !ok {
+ maybeFile, err := app.loadSopsFile(secret, &sopsFile)
+ if err != nil {
+ return err
+ }
+ app.secretFiles[sopsFile] = *maybeFile
+ file = *maybeFile
}
- app.secretFiles[secret.SopsFile] = *maybeFile
- file = *maybeFile
+ files = append(files, file)
}
- return app.validateSopsFile(secret, &file)
+ for i := len(files) - 1; i >= 0; i-- {
+ err := app.validateSopsFile(secret, &files[i])
+ if err == nil {
+ // Found valid sopsFile
+ break
+ } else if i == 0 {
+ // No valid sopsFile found in sopsFiles
+ return fmt.Errorf("Failed to find valid secret %s in %v", secret.Name, secret.SopsFiles)
+ }
+ }
+ return nil
}
func (app *appContext) validateManifest() error {
diff --git a/pkgs/sops-install-secrets/main_test.go b/pkgs/sops-install-secrets/main_test.go
index 7c4ef927..f61cba01 100644
--- a/pkgs/sops-install-secrets/main_test.go
+++ b/pkgs/sops-install-secrets/main_test.go
@@ -104,7 +104,7 @@ func testGPG(t *testing.T) {
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
- SopsFile: path.Join(assets, "secrets.yaml"),
+ SopsFiles: []string{path.Join(assets, "secrets.yaml")},
Path: path.Join(testdir.path, "test-target"),
Mode: "0400",
RestartUnits: []string{"affected-service"},
@@ -118,14 +118,14 @@ func testGPG(t *testing.T) {
jsonSecret.Owner = "root"
jsonSecret.Format = "json"
jsonSecret.Group = "root"
- jsonSecret.SopsFile = path.Join(assets, "secrets.json")
+ jsonSecret.SopsFiles = []string{path.Join(assets, "secrets.json")}
jsonSecret.Path = path.Join(testdir.secretsPath, "test2")
jsonSecret.Mode = "0700"
binarySecret = yamlSecret
binarySecret.Name = "test3"
binarySecret.Format = "binary"
- binarySecret.SopsFile = path.Join(assets, "secrets.bin")
+ binarySecret.SopsFiles = []string{path.Join(assets, "secrets.bin")}
binarySecret.Path = path.Join(testdir.secretsPath, "test3")
dotenvSecret = yamlSecret
@@ -133,7 +133,7 @@ func testGPG(t *testing.T) {
dotenvSecret.Owner = "root"
dotenvSecret.Group = "root"
dotenvSecret.Format = "dotenv"
- dotenvSecret.SopsFile = path.Join(assets, "secrets.env")
+ dotenvSecret.SopsFiles = []string{path.Join(assets, "secrets.env")}
dotenvSecret.Path = path.Join(testdir.secretsPath, "test4")
iniSecret = yamlSecret
@@ -141,7 +141,7 @@ func testGPG(t *testing.T) {
iniSecret.Owner = "root"
iniSecret.Group = "root"
iniSecret.Format = "ini"
- iniSecret.SopsFile = path.Join(assets, "secrets.ini")
+ iniSecret.SopsFiles = []string{path.Join(assets, "secrets.ini")}
iniSecret.Path = path.Join(testdir.secretsPath, "test5")
manifest := manifest{
@@ -219,7 +219,7 @@ func testSSHKey(t *testing.T) {
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
- SopsFile: path.Join(assets, "secrets.yaml"),
+ SopsFiles: []string{path.Join(assets, "secrets.yaml")},
Path: target,
Mode: "0400",
RestartUnits: []string{"affected-service"},
@@ -252,7 +252,7 @@ func TestAge(t *testing.T) {
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
- SopsFile: path.Join(assets, "secrets.yaml"),
+ SopsFiles: []string{path.Join(assets, "secrets.yaml")},
Path: target,
Mode: "0400",
RestartUnits: []string{"affected-service"},
@@ -285,7 +285,7 @@ func TestAgeWithSSH(t *testing.T) {
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
- SopsFile: path.Join(assets, "secrets.yaml"),
+ SopsFiles: []string{path.Join(assets, "secrets.yaml")},
Path: target,
Mode: "0400",
RestartUnits: []string{"affected-service"},
@@ -319,7 +319,7 @@ func TestValidateManifest(t *testing.T) {
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
- SopsFile: path.Join(assets, "secrets.yaml"),
+ SopsFiles: []string{path.Join(assets, "secrets.yaml")},
Path: path.Join(testdir.path, "test-target"),
Mode: "0400",
RestartUnits: []string{},
diff --git a/pkgs/sops-install-secrets/nixos-test.nix b/pkgs/sops-install-secrets/nixos-test.nix
index 487074ef..8f29dd60 100644
--- a/pkgs/sops-install-secrets/nixos-test.nix
+++ b/pkgs/sops-install-secrets/nixos-test.nix
@@ -377,4 +377,75 @@
inherit pkgs;
inherit (pkgs) system;
};
+
+ sops-files-shadowing = makeTest {
+ name = "sops-files-shadowing";
+ nodes.machine = {lib,...}:
+ let
+ inherit (lib.lists) reverseList;
+ inherit (lib.modules) mkDefault;
+
+ sopsFile = ./test-assets/secrets.yaml;
+ systemSopsFile = ./test-assets/secrets-system.yaml;
+ userSopsFile = ./test-assets/secrets-user.yaml;
+
+ sopsFiles = [ sopsFile ];
+ systemSopsFiles = sopsFiles ++ [ systemSopsFile ];
+ userSopsFiles = systemSopsFiles ++ [ userSopsFile ];
+
+ mkSecretConfig = key: sopsFiles: { inherit key sopsFiles; };
+ in {
+ imports = [ ../../modules/sops ];
+ sops = {
+ age.keyFile = ./test-assets/age-keys.txt;
+ defaultSopsFile = sopsFile;
+
+ secrets.test_key = {};
+ secrets.test_key_system = mkSecretConfig "test_key" systemSopsFiles;
+ secrets.test_key_user = mkSecretConfig "test_key" userSopsFiles;
+
+ secrets.test_key2_system = mkSecretConfig "test_key2" systemSopsFiles;
+ secrets.test_key2_user = mkSecretConfig "test_key2" userSopsFiles;
+
+ secrets.test_key3_user = mkSecretConfig "test_key3" userSopsFiles;
+
+ secrets.test_key3_user_reverse = mkSecretConfig "test_key3" (reverseList userSopsFiles);
+ secrets.test_key2_user_reverse = mkSecretConfig "test_key2" (reverseList userSopsFiles);
+ secrets.test_key_user_reverse = mkSecretConfig "test_key" (reverseList userSopsFiles);
+
+ secrets.priority_file = {
+ key = "test_key";
+ sopsFile = systemSopsFile;
+ sopsFiles = mkDefault userSopsFiles;
+ };
+ secrets.priority_same = {
+ inherit sopsFile;
+ key = "nested/test/file";
+ sopsFiles = [ systemSopsFile userSopsFile ];
+ };
+ };
+ };
+
+ testScript = ''
+ start_all()
+ machine.succeed("cat /run/secrets/test_key | grep -qw test_value")
+ machine.succeed("cat /run/secrets/test_key_system | grep -qw test_value_system")
+ machine.succeed("cat /run/secrets/test_key_user | grep -qw test_value_user")
+
+ machine.succeed("cat /run/secrets/test_key2_system | grep -qw test_value2_system")
+ machine.succeed("cat /run/secrets/test_key2_user | grep -qw test_value2_user")
+
+ machine.succeed("cat /run/secrets/test_key3_user | grep -qw test_value3_user")
+
+ machine.succeed("cat /run/secrets/test_key3_user_reverse | grep -qw test_value3_user")
+ machine.succeed("cat /run/secrets/test_key2_user_reverse | grep -qw test_value2_system")
+ machine.succeed("cat /run/secrets/test_key_user_reverse | grep -qw test_value")
+
+ machine.succeed("cat /run/secrets/priority_file | grep -qw test_value_system")
+ machine.succeed("cat /run/secrets/priority_same | grep -qw 'another value'")
+ '';
+ } {
+ inherit pkgs;
+ inherit (pkgs) system;
+ };
}
diff --git a/pkgs/sops-install-secrets/test-assets/secrets-system.yaml b/pkgs/sops-install-secrets/test-assets/secrets-system.yaml
new file mode 100644
index 00000000..09329b39
--- /dev/null
+++ b/pkgs/sops-install-secrets/test-assets/secrets-system.yaml
@@ -0,0 +1,64 @@
+test_key: ENC[AES256_GCM,data:nKT/4vbkpyYUS18rJ4na1pk=,iv:1VwaqxGdrUlquA6pr1yQV4wnq1FPlEhilK9FGPFs8SM=,tag:HUxe8+MUpyQUXuIwR3dxIQ==,type:str]
+test_key2: ENC[AES256_GCM,data:IZ3XrdhsMKSAeRBxm1kiHSd+,iv:XGEBYa++pwrp3zQNGFDp7mSpQzZDEYC1oLEJOCnT5Bs=,tag:VuEURKlDst5aKTqmnPksog==,type:str]
+sops:
+ kms: []
+ gcp_kms: []
+ azure_kv: []
+ hc_vault: []
+ age:
+ - recipient: age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw
+ enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQaGJ0ZlFUMXNNSkRYeitS
+ TVkvU1RacHJIYzdMT1AxaldVRnNIMVVxb1hZCnFOclVlMEdJUDJWMDhaejRkU3hq
+ Y0tObnZYYnhidTB2Z2p0amhUaTZGeHMKLS0tIERDU3pKb1FwZk44bXBualhnS0Z5
+ eWlUdXhCZGM2dzcxNEY0MTBwN3prTDgK9Sgzw8IuSnBBLS9cNlh6UnzTraxgrQe6
+ qo+34EQln2Kty7Ot+8TnYo1X+8xRn3VTsQw8+iVdcr28DI0ltMcFtQ==
+ -----END AGE ENCRYPTED FILE-----
+ - recipient: age1a8pk4akrdamj7nvqy3zywgtny8dxz7t5xzu7u8v9mhrayp9freqsqatyrs
+ enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWVkFidnFPQ0toY0RPeFZN
+ eCsxSUxlU05LUXJYVWVBb3NTblhKVExsNzN3CjkyWlJFaXE3N1RiaEJ1RjdtZncv
+ cERvdi9kQ3FYY2l3NmF4SUVzYlJxYUEKLS0tIFNSUzhkV0tDYWFjVUprczVCTS9Y
+ c3poQXZhSzZvVTY2YmdEVVVaUHVxTVkK7Y/YTczA/T5EmLJNjGkL6bh0eI0xH7aH
+ sjOdnuQG2vioHBYnsqWmmn0bUvY7y3q0h6y+gMDfmzYIgh6B2spUrA==
+ -----END AGE ENCRYPTED FILE-----
+ lastmodified: "2023-11-12T16:37:17Z"
+ mac: ENC[AES256_GCM,data:igpv/Z/VWq53sh3A+LqUl8jPOZumm3/0yaj7Tco9nAqzwJ7v/QNgUiCZlFe2tUZXXE/0kH+C990aRU8QqKR0uJJ86Jz0jyRTdHYEqPtWUabAg9kofAsU1tr+qTRJ1dfuYJ+BukF0tLNwFce00th+RRJzCjBncSHnP4go7rELyr0=,iv:reqRxtuXt/wOhDVHoYcjOpmhpTqFzMpqh8C0ZVKTwUI=,tag:F2irgrIq61xXhs4v5Y4img==,type:str]
+ pgp:
+ - created_at: "2023-11-12T16:37:09Z"
+ enc: |
+ -----BEGIN PGP MESSAGE-----
+
+ hQEMA/m6nevQP1fAAQf9HVmJtTe34ameXLpIKgHvUmMLFzarqCTpFx1h4WZo+0Nk
+ 3eHw572Mm0npG5/uRqbff6fdb433dNlJXLF/O3ZFLk30+6cKsWbcDXAlrCC6fug7
+ UJh3SJ+Vrp+fsPQXF+2JIkz7ktRZAJ1ktKOKh8P6UjJcrVVG0QH/2gx2wjx+0TBZ
+ Jem+zF+2TSvj/VQPcdfi4eBisyDGUopiSgLlvENNK6h3pStVWqQTMmMVEZw6SGvW
+ OwqgydTzzgsLsrZOr4RMNd0KOwhFtHZ758OMePoxdmhDMS/n06fia5TlajTidMOT
+ H5vosuHh3EuEyl+dKX/N2HwYToADCV+MIdMBN3n8JNJeAVJUp1Kxyh0MlMgUm/vk
+ g83FuYZ8u3F8MaCzk/+XlJUst0iraxPyO0DQpTuYRmtZ1seVAEidWsDmoDzqxq29
+ S4GigtxlDcAFhzSMuteLjhWPXO5CHOc+h9tPFqAUCQ==
+ =bj6u
+ -----END PGP MESSAGE-----
+ fp: 7FB89715AADA920D65D25E63F9BA9DEBD03F57C0
+ - created_at: "2023-11-12T16:37:09Z"
+ enc: |
+ -----BEGIN PGP MESSAGE-----
+
+ hQGMA3ulPRkZxd/UAQwAqqwRqq/9n06pkZQ1TXS/4Y9s5QdoMOOYrlMwGXNIkieR
+ 6u6qAmJhwsEBWp03gfggFnsfAnKH7zXGdNhWumLkWMvX6DldHkv+4jG/mWOqaNUw
+ wnkJXNpNoff79DIpZlYZkeTLyFU+02wvbSN56WJCXUCzu+07snT8mCFVRRu6+JJH
+ v3AD+7K/AWsL3NeL8/eijKuse2nMyWfkSQO77lNS65rI+HAEPkPr+AcYmc0qsvk1
+ nt+f/UwuLzdsx0wiJ/qoO23vpUGekA6f4Pl5sJX2vfIoroet6h/SlMEBFfgN+9uE
+ kHJvd31p5OWhSxGe1s+gYNpJqkRJlevcZhMw7GRY5wrlxSz+KjUysudSklAdBiDA
+ C2XvU9GrxKAvWLNZpmPLJgJSeEFdT3GaG0uZdkqXL3ERN+5i4xonLrmipJabLP2r
+ X2y4lHwTPqnJAAtzkajNbkJECl9HjXimQKIue3Adxdks48b87yn9r4jEFl5Q3jzi
+ 7PJRGSXxXh7IYug9oK8j0lgBR73bXlKg6NoRb60Qf+fNr1C+2/u9/oZHtMVkL9DC
+ GQgYMcNl/iaXwtMKrXRpXdybJiUaEVDUj1IniOKslkA+SXqYPT/GEgzsZg7N1iNj
+ T8HB88mqIowz
+ =+4j5
+ -----END PGP MESSAGE-----
+ fp: 2504791468B153B8A3963CC97BA53D1919C5DFD4
+ unencrypted_suffix: _unencrypted
+ version: 3.7.3
diff --git a/pkgs/sops-install-secrets/test-assets/secrets-user.yaml b/pkgs/sops-install-secrets/test-assets/secrets-user.yaml
new file mode 100644
index 00000000..2a24e773
--- /dev/null
+++ b/pkgs/sops-install-secrets/test-assets/secrets-user.yaml
@@ -0,0 +1,65 @@
+test_key: ENC[AES256_GCM,data:JZ2xgV5SWgDZavBCIcH+,iv:kl6h4EJbivo1wVHqzM8W0vHyf4U+qEYoqH6JXIgYdTw=,tag:z0roNXcgF+dGvv4MMAO2Rw==,type:str]
+test_key2: ENC[AES256_GCM,data:E43CtluUaO4EzvWrrbwIWA==,iv:AI3togB1kiYo3VEjEwNyWCWb7XC1nooN3vDj/K9wuNc=,tag:7OYaY/Htw95akdr1klYFWg==,type:str]
+test_key3: ENC[AES256_GCM,data:vWP0CpCR6Mh2mcTJBsQmBA==,iv:9iZHc5m89AmfWLKGqw6RHA+M51wclGqcZzVur7ZDk5k=,tag:yfdfW7VS3eptl/YRqn61mg==,type:str]
+sops:
+ kms: []
+ gcp_kms: []
+ azure_kv: []
+ hc_vault: []
+ age:
+ - recipient: age1yt3tfqlfrwdwx0z0ynwplcr6qxcxfaqycuprpmy89nr83ltx74tqdpszlw
+ enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBINGZkWG9TNkk3cTdvc2hh
+ NFNpbG11TnZjZHRZdFczSTZSRElGcHhkeFZ3CldjMXpjMFdOd1JzemFONmkya2pv
+ ZGlGN25DZVFpSG9waTkydjJXSUJFRjAKLS0tIFZWblk2cmtRWEdUYXo4REROTVpW
+ V0M4MjBaYTBrTCtSVXRtNGh0bzNaREUKsbZ9EK24APYCCC63qbI4YsJmkNFH/j88
+ ROwRAXFqm0SZcwqUU6TbK9ulyyfE9dsWZ8a6Zb3iDFlFVBuEd5Yb7w==
+ -----END AGE ENCRYPTED FILE-----
+ - recipient: age1a8pk4akrdamj7nvqy3zywgtny8dxz7t5xzu7u8v9mhrayp9freqsqatyrs
+ enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaaXVQeVlFU0trMGROZEdp
+ Y2xJNzJzWGNFcEtVS1R4ODBIWWk5dkFEeDBVCktrWkFlUkI3eksyVWRmNTcxRWpU
+ YllzU1NwNHZHWk9oU2FabFFSRnJuY0UKLS0tIDBaUjVaak5qUGNlRHVpSi9HTEFW
+ OFdHYSsyNGcvSG91dGxFdk1NYmVRSkkKEEqXuDN7gFKwUDY6O9EMbhEzGIY/BfGU
+ SM435jTAcR76tq10HbgYgBQ2ef2vvUmvkVzHGQV9LsTxMT+11oFSHw==
+ -----END AGE ENCRYPTED FILE-----
+ lastmodified: "2023-11-12T16:44:49Z"
+ mac: ENC[AES256_GCM,data:auu+8Cs8qRX5OEfCSO0m5U9rEdgKYBWninSVu0H1+VDtGOxjOPhVjAJgIa9wHGbrhp7LoDFymgiGGzbG3H4B/gZAFEhoyYn8VfJOHouT12M6kijtBUAhSqL5csbTMfiZmganvKPYN6PXg7hX2MyjJlFAGLc/Ixte61fKGoExqgA=,iv:P1uCpCO+nqS+dUXhxCcYd/2q0PHVUfbGvYm5PHVrP9s=,tag:yxlNfyTSnM0Wbj95Z/8Ffw==,type:str]
+ pgp:
+ - created_at: "2023-11-12T16:41:00Z"
+ enc: |
+ -----BEGIN PGP MESSAGE-----
+
+ hQEMA/m6nevQP1fAAQf/XVOr/ZOZo/b5lDJ7EIeRgwejwtJLGToF6xXM0jcZvlsB
+ 1Vk2tuzOpAGpO8JpaTzZj0n4Da8+XXpEqLqeGFBsjPni+W0ErlYR/exIVFBZyCVM
+ gVFnjoAsiR7z+Y+ovYx1VBVpfav3GXXNkPOWNOQlXAhoS9Rxv04AU8XBBy5Hk5kH
+ is+eM9U+iVxlZfEGW3nAylSieMQdEjWG3MM8KgWr4SLSSLY+sAhiM6QwSDF0wkkn
+ mLcFJ4CwbWiZIa0995lbhIusTko+DJfdYB6b6e7yxftMySKskTKp0fRh6eagpLhC
+ oXca0MSIhdWUbgcB4MnWdXl6fGYL7YnTgwOg50ETdNJeAf6p+u/uBElu0Ym94ZRf
+ /DHrY/rRMx6xNtUCwxAI4ekmi/gXky9/lteZtkW87nXGaYweeQECfbDFWtNczbpn
+ mfQzF/LrdlaIMEyMGpLow7AgakEuIPXyH5f9hgz9IA==
+ =NPJP
+ -----END PGP MESSAGE-----
+ fp: 7FB89715AADA920D65D25E63F9BA9DEBD03F57C0
+ - created_at: "2023-11-12T16:41:00Z"
+ enc: |
+ -----BEGIN PGP MESSAGE-----
+
+ hQGMA3ulPRkZxd/UAQv/UWnL12QFkO1EDguYrLyzq50qAc9wtWJvYP8SS4CP/k1t
+ ii0cSteyxbiPnYAFFRsiW3KDfkVIOl24+qKihmOcsq+uL90VxweGHJbZwwdNLU5v
+ YD+bbvfAN1iO2eY0ipTL1gNSu0zl4s8bZaTaYBIBhwTMXNWLfz7CHMU2yJ1g5sWK
+ oZjGYkleq76zUmhDpzKONivYQiN+UayZ5XqEavdVc5omUlM26hO6jgDUxYua7Wjf
+ m5cDyPHLzO04d91z0hLxYl5dfK93R2/1dicFmh0yin/nyxbqGBKUAoYdpYAhuI9M
+ JnKgeEx1mNZnCJdXSJouAA9FakTIkkbOPr45ik087VQ8AdY/c7Ao8SGB2W2kOPqt
+ +G2r5GBeO/0XH7FUIAcvekOHrjPV16aQpZCouJsFOUMtXi5lnViWhyVPPxERAzIL
+ sCN2AHI/aFXycPEXSnwoCMvW+3KOihLDxBUPK45Pjc7HfiOPDeWCva6BhG5gFQkT
+ sJ2B0yeaJoNlQO2bG+AJ0lgBW4s+Q0rtoSRj52cCnVj4zlgjrArPje/aU8pj0w5k
+ aSl1OoqsD1a5A7vkldmVgec9rWhByRDEnHke00OhmOcPDm6hv0fROwa5qls9RhR0
+ Gs6FXl5y3Cz7
+ =yMyb
+ -----END PGP MESSAGE-----
+ fp: 2504791468B153B8A3963CC97BA53D1919C5DFD4
+ unencrypted_suffix: _unencrypted
+ version: 3.7.3