Merge pull request 'age plugin support' (#3322) from feat/age-plugin-support into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3322
This commit is contained in:
Michael Hoang
2025-04-29 06:12:44 +00:00
18 changed files with 780 additions and 375 deletions

View File

@@ -1,18 +1,19 @@
Clan enables encryption of secrets (such as passwords & keys) ensuring security and ease-of-use among users. Clan enables encryption of secrets (such as passwords & keys) ensuring security and ease-of-use among users.
By default Clan utilizes the [sops](https://github.com/getsops/sops) format and integrates with [sops-nix](https://github.com/Mic92/sops-nix) on NixOS machines. By default, Clan uses the [sops](https://github.com/getsops/sops) format
and integrates with [sops-nix](https://github.com/Mic92/sops-nix) on NixOS machines.
Clan can also be configured to be used with other secret store [backends](https://docs.clan.lol/reference/clan-core/vars/#clan.core.vars.settings.secretStore). Clan can also be configured to be used with other secret store [backends](https://docs.clan.lol/reference/clan-core/vars/#clan.core.vars.settings.secretStore).
This guide will walk you through: This guide will walk you through:
- **Creating a Keypair for Your User**: Learn how to generate a keypair for $USER to securely control all secrets. - **Creating a Keypair for Your User**: Learn how to generate a keypair for `$USER` to securely control all secrets.
- **Creating Your First Secret**: Step-by-step instructions on creating your initial secret. - **Creating Your First Secret**: Step-by-step instructions on creating your initial secret.
- **Assigning Machine Access to the Secret**: Understand how to grant a machine access to the newly created secret. - **Assigning Machine Access to the Secret**: Understand how to grant a machine access to the newly created secret.
## Create Your Admin Keypair ## Create Your Admin Keypair
To get started, you'll need to create **Your admin keypair**. To get started, you'll need to create **your admin keypair**.
!!! info !!! info
Don't worry — if you've already made one before, this step won't change or overwrite it. Don't worry — if you've already made one before, this step won't change or overwrite it.
@@ -34,9 +35,78 @@ Also add your age public key to the repository with 'clan secrets users add YOUR
Make sure to keep a safe backup of the private key you've just created. Make sure to keep a safe backup of the private key you've just created.
If it's lost, you won't be able to get to your secrets anymore because they all need the admin key to be unlocked. If it's lost, you won't be able to get to your secrets anymore because they all need the admin key to be unlocked.
If you already have an [age] secret key and want to use that instead, you can simply edit `~/.config/sops/age/keys.txt`:
```title="~/.config/sops/age/keys.txt"
AGE-SECRET-KEY-13GWMK0KNNKXPTJ8KQ9LPSQZU7G3KU8LZDW474NX3D956GGVFAZRQTAE3F4
```
Alternatively, you can provide your [age] secret key as an environment variable `SOPS_AGE_KEY`, or in a different file
using `SOPS_AGE_KEY_FILE`.
For more information see the [SOPS] guide on [encrypting with age].
!!! note !!! note
It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`. It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`.
### Using Age Plugins
If you wish to use a key generated using an [age plugin] as your admin key, extra care is needed.
You must **precede your secret key with a comment that contains its corresponding recipient**.
This is usually output as part of the generation process
and is only required because there is no unified mechanism for recovering a recipient from a plugin secret key.
Here is an example:
```title="~/.config/sops/age/keys.txt"
# public key: age1zdy49ek6z60q9r34vf5mmzkx6u43pr9haqdh5lqdg7fh5tpwlfwqea356l
AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV...
```
!!! note
The comment that precedes the plugin secret key need only contain the recipient.
Any other text is ignored.
In the example above, you can specify `# recipient: age1zdy...`, `# public: age1zdy....` or even
just `# age1zdy....`
You will need to add an entry into your `flake.nix` to ensure that the necessary `age` plugins
are loaded when using Clan:
```nix title="flake.nix"
{
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "clan-core/nixpkgs";
outputs =
{ self, clan-core, ... }:
let
clan = clan-core.clanLib.buildClan {
inherit self;
meta.name = "myclan";
# Add Yubikey and FIDO2 HMAC plugins
# Note: the plugins listed here must be available in nixpkgs.
secrets.age.plugins = [
"age-plugin-yubikey"
"age-plugin-fido2-hmac"
];
machines = {
# elided for brevity
};
};
in
{
inherit (clan) nixosConfigurations clanInternals;
# elided for brevity
};
}
```
### Add Your Public Key(s) ### Add Your Public Key(s)
```console ```console
@@ -70,7 +140,7 @@ If you followed the quickstart tutorial all necessary secrets are initialized at
You can list keys for your user with `clan secrets users get $USER`: You can list keys for your user with `clan secrets users get $USER`:
```console ```console
bin/clan secrets users get alice clan secrets users get alice
[ [
{ {
@@ -83,17 +153,22 @@ You can list keys for your user with `clan secrets users get $USER`:
"type": "age", "type": "age",
"username": "alice" "username": "alice"
} }
] ]
``` ```
To add a new key to your user: To add a new key to your user:
```console ```console
clan secrets users add-key $USER --age-key <your_public_key> clan secrets users add-key $USER --age-key <your_public_key>
``` ```
To remove a key from your user: To remove a key from your user:
```console ```console
clan secrets users remove-key $USER --age-key <your_public_key> clan secrets users remove-key $USER --age-key <your_public_key>
``` ```
[age]: https://github.com/FiloSottile/age
[age plugin]: https://github.com/FiloSottile/awesome-age?tab=readme-ov-file#plugins
[sops]: https://github.com/getsops/sops
[encrypting with age]: https://github.com/getsops/sops?tab=readme-ov-file#encrypting-using-age

View File

@@ -108,6 +108,14 @@ in
default = { }; default = { };
}; };
secrets = lib.mkOption {
type = types.submodule { imports = [ ./secrets/interface.nix ]; };
description = ''
Secrets related options such as AGE plugins required to encrypt/decrypt secrets using the CLI.
'';
default = { };
};
pkgsForSystem = lib.mkOption { pkgsForSystem = lib.mkOption {
type = types.functionTo (types.nullOr types.attrs); type = types.functionTo (types.nullOr types.attrs);
default = _system: null; default = _system: null;
@@ -165,6 +173,7 @@ in
clanModules = lib.mkOption { type = lib.types.raw; }; clanModules = lib.mkOption { type = lib.types.raw; };
source = lib.mkOption { type = lib.types.raw; }; source = lib.mkOption { type = lib.types.raw; };
meta = lib.mkOption { type = lib.types.raw; }; meta = lib.mkOption { type = lib.types.raw; };
secrets = lib.mkOption { type = lib.types.raw; };
clanLib = lib.mkOption { type = lib.types.raw; }; clanLib = lib.mkOption { type = lib.types.raw; };
all-machines-json = lib.mkOption { type = lib.types.raw; }; all-machines-json = lib.mkOption { type = lib.types.raw; };
machines = lib.mkOption { type = lib.types.raw; }; machines = lib.mkOption { type = lib.types.raw; };

View File

@@ -219,6 +219,7 @@ in
templates = config.templates; templates = config.templates;
inventory = config.inventory; inventory = config.inventory;
meta = config.inventory.meta; meta = config.inventory.meta;
secrets = config.secrets;
source = "${clan-core}"; source = "${clan-core}";

View File

@@ -0,0 +1,18 @@
{
lib,
...
}:
let
inherit (lib) types;
in
{
options = {
age.plugins = lib.mkOption {
type = types.listOf (types.strMatching "age-plugin-.*");
default = [ ];
description = ''
A list of age plugins which must be available in the shell when encrypting and decrypting secrets.
'';
};
};
}

View File

@@ -1,5 +1,11 @@
[ [
"age", "age",
"age-plugin-fido2-hmac",
"age-plugin-ledger",
"age-plugin-se",
"age-plugin-sss",
"age-plugin-tpm",
"age-plugin-yubikey",
"avahi", "avahi",
"bash", "bash",
"bubblewrap", "bubblewrap",

View File

@@ -240,6 +240,7 @@ def add_group_argument(parser: argparse.ArgumentParser) -> None:
def add_secret(flake_dir: Path, group: str, name: str) -> None: def add_secret(flake_dir: Path, group: str, name: str) -> None:
secrets.allow_member( secrets.allow_member(
flake_dir,
secrets.groups_folder(sops_secrets_folder(flake_dir) / name), secrets.groups_folder(sops_secrets_folder(flake_dir) / name),
sops_groups_folder(flake_dir), sops_groups_folder(flake_dir),
group, group,
@@ -267,7 +268,7 @@ def add_secret_command(args: argparse.Namespace) -> None:
def remove_secret(flake_dir: Path, group: str, name: str) -> None: def remove_secret(flake_dir: Path, group: str, name: str) -> None:
updated_paths = secrets.disallow_member( updated_paths = secrets.disallow_member(
secrets.groups_folder(sops_secrets_folder(flake_dir) / name), group flake_dir, secrets.groups_folder(sops_secrets_folder(flake_dir) / name), group
) )
commit_files( commit_files(
updated_paths, updated_paths,

View File

@@ -74,6 +74,7 @@ def list_sops_machines(flake_dir: Path) -> list[str]:
def add_secret(flake_dir: Path, machine: str, secret_path: Path) -> None: def add_secret(flake_dir: Path, machine: str, secret_path: Path) -> None:
paths = secrets.allow_member( paths = secrets.allow_member(
flake_dir,
secrets.machines_folder(secret_path), secrets.machines_folder(secret_path),
sops_machines_folder(flake_dir), sops_machines_folder(flake_dir),
machine, machine,
@@ -87,7 +88,9 @@ def add_secret(flake_dir: Path, machine: str, secret_path: Path) -> None:
def remove_secret(flake_dir: Path, machine: str, secret: str) -> None: def remove_secret(flake_dir: Path, machine: str, secret: str) -> None:
updated_paths = secrets.disallow_member( updated_paths = secrets.disallow_member(
secrets.machines_folder(sops_secrets_folder(flake_dir) / secret), machine flake_dir,
secrets.machines_folder(sops_secrets_folder(flake_dir) / secret),
machine,
) )
commit_files( commit_files(
updated_paths, updated_paths,

View File

@@ -86,6 +86,7 @@ def update_secrets(
changed_files.extend(cleanup_dangling_symlinks(path / "machines")) changed_files.extend(cleanup_dangling_symlinks(path / "machines"))
changed_files.extend( changed_files.extend(
update_keys( update_keys(
flake_dir,
path, path,
collect_keys_for_path(path), collect_keys_for_path(path),
) )
@@ -155,16 +156,14 @@ def encrypt_secret(
if add_users is None: if add_users is None:
add_users = [] add_users = []
keys = sops.ensure_admin_public_key(flake_dir) admin_keys = sops.ensure_admin_public_keys(flake_dir)
if not keys: if not admin_keys:
# todo double check the correct command to run # todo double check the correct command to run
msg = "No keys found. Please run 'clan secrets add-key' to add a key." msg = "No keys found. Please run 'clan secrets add-key' to add a key."
raise ClanError(msg) raise ClanError(msg)
username = next(iter(keys)).username username = next(iter(admin_keys)).username
recipient_keys = set()
# encrypt_secret can be called before the secret has been created # encrypt_secret can be called before the secret has been created
# so don't try to call sops.update_keys on a non-existent file: # so don't try to call sops.update_keys on a non-existent file:
@@ -174,6 +173,7 @@ def encrypt_secret(
for user in add_users: for user in add_users:
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
flake_dir,
users_folder(secret_path), users_folder(secret_path),
sops_users_folder(flake_dir), sops_users_folder(flake_dir),
user, user,
@@ -184,6 +184,7 @@ def encrypt_secret(
for machine in add_machines: for machine in add_machines:
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
flake_dir,
machines_folder(secret_path), machines_folder(secret_path),
sops_machines_folder(flake_dir), sops_machines_folder(flake_dir),
machine, machine,
@@ -194,6 +195,7 @@ def encrypt_secret(
for group in add_groups: for group in add_groups:
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
flake_dir,
groups_folder(secret_path), groups_folder(secret_path),
sops_groups_folder(flake_dir), sops_groups_folder(flake_dir),
group, group,
@@ -203,11 +205,12 @@ def encrypt_secret(
recipient_keys = collect_keys_for_path(secret_path) recipient_keys = collect_keys_for_path(secret_path)
if not keys.intersection(recipient_keys): if admin_keys not in recipient_keys:
recipient_keys.update(keys) recipient_keys.update(admin_keys)
files_to_commit.extend( files_to_commit.extend(
allow_member( allow_member(
flake_dir,
users_folder(secret_path), users_folder(secret_path),
sops_users_folder(flake_dir), sops_users_folder(flake_dir),
username, username,
@@ -216,7 +219,7 @@ def encrypt_secret(
) )
secret_path = secret_path / "secret" secret_path = secret_path / "secret"
encrypt_file(secret_path, value, sorted(recipient_keys)) encrypt_file(flake_dir, secret_path, value, sorted(recipient_keys))
files_to_commit.append(secret_path) files_to_commit.append(secret_path)
if git_commit: if git_commit:
commit_files( commit_files(
@@ -276,7 +279,11 @@ def list_directory(directory: Path) -> str:
def allow_member( def allow_member(
group_folder: Path, source_folder: Path, name: str, do_update_keys: bool = True flake_dir: str | Path,
group_folder: Path,
source_folder: Path,
name: str,
do_update_keys: bool = True,
) -> list[Path]: ) -> list[Path]:
source = source_folder / name source = source_folder / name
if not source.exists(): if not source.exists():
@@ -299,6 +306,7 @@ def allow_member(
if do_update_keys: if do_update_keys:
changed.extend( changed.extend(
update_keys( update_keys(
flake_dir,
group_folder.parent, group_folder.parent,
collect_keys_for_path(group_folder.parent), collect_keys_for_path(group_folder.parent),
) )
@@ -306,7 +314,7 @@ def allow_member(
return changed return changed
def disallow_member(group_folder: Path, name: str) -> list[Path]: def disallow_member(flake_dir: str | Path, group_folder: Path, name: str) -> list[Path]:
target = group_folder / name target = group_folder / name
if not target.exists(): if not target.exists():
msg = f"{name} does not exist in group in {group_folder}: " msg = f"{name} does not exist in group in {group_folder}: "
@@ -326,7 +334,9 @@ def disallow_member(group_folder: Path, name: str) -> list[Path]:
if next(group_folder.parent.iterdir(), None) is None: if next(group_folder.parent.iterdir(), None) is None:
group_folder.parent.rmdir() group_folder.parent.rmdir()
return update_keys(target.parent.parent, collect_keys_for_path(group_folder.parent)) return update_keys(
flake_dir, target.parent.parent, collect_keys_for_path(group_folder.parent)
)
def has_secret(secret_path: Path) -> bool: def has_secret(secret_path: Path) -> bool:
@@ -366,7 +376,7 @@ def decrypt_secret(flake_dir: Path, secret_path: Path) -> str:
if not path.exists(): if not path.exists():
msg = f"Secret '{secret_path!s}' does not exist" msg = f"Secret '{secret_path!s}' does not exist"
raise ClanError(msg) raise ClanError(msg)
return decrypt_file(path) return decrypt_file(flake_dir, path)
def get_command(args: argparse.Namespace) -> None: def get_command(args: argparse.Namespace) -> None:

View File

@@ -4,6 +4,7 @@ import io
import json import json
import logging import logging
import os import os
import re
import shutil import shutil
import subprocess import subprocess
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
@@ -17,9 +18,11 @@ from clan_lib.api import API
from clan_cli.cmd import Log, RunOpts, run from clan_cli.cmd import Log, RunOpts, run
from clan_cli.dirs import user_config_dir from clan_cli.dirs import user_config_dir
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.nix import nix_shell from clan_cli.nix import nix_eval, nix_shell
from .folders import sops_machines_folder, sops_users_folder from .folders import sops_users_folder
AGE_RECIPIENT_REGEX = re.compile(r"^.*((age1|ssh-(rsa|ed25519) ).*?)(\s|$)")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -55,14 +58,20 @@ class KeyType(enum.Enum):
def maybe_read_from_path(key_path: Path) -> None: def maybe_read_from_path(key_path: Path) -> None:
try: try:
# as in parse.go in age: # as in parse.go in age:
lines = Path(key_path).read_text().strip().splitlines() content = Path(key_path).read_text().strip()
for private_key in filter(lambda ln: not ln.startswith("#"), lines):
public_key = get_public_age_key(private_key) try:
log.info( for public_key in get_public_age_keys(content):
f"Found age public key from a private key " log.info(
f"in {key_path}: {public_key}" f"Found age public key from a private key "
) f"in {key_path}: {public_key}"
keyring.append(public_key) )
keyring.append(public_key)
except ClanError as e:
error_msg = f"Failed to read age keys from {key_path}"
raise ClanError(error_msg) from e
except FileNotFoundError: except FileNotFoundError:
return return
except Exception as ex: except Exception as ex:
@@ -72,13 +81,19 @@ class KeyType(enum.Enum):
# SOPS_AGE_KEY is fed into age.ParseIdentities by Sops, and # SOPS_AGE_KEY is fed into age.ParseIdentities by Sops, and
# reads identities line by line. See age/keysource.go in # reads identities line by line. See age/keysource.go in
# Sops, and age/parse.go in Age. # Sops, and age/parse.go in Age.
for private_key in keys.strip().splitlines(): content = keys.strip()
public_key = get_public_age_key(private_key)
log.info( try:
f"Found age public key from a private key " for public_key in get_public_age_keys(content):
f"in the environment (SOPS_AGE_KEY): {public_key}" log.info(
) f"Found age public key from a private key "
keyring.append(public_key) f"in the environment (SOPS_AGE_KEY): {public_key}"
)
keyring.append(public_key)
except ClanError as e:
error_msg = "Failed to read age keys from SOPS_AGE_KEY"
raise ClanError(error_msg) from e
# Sops will try every location, see age/keysource.go # Sops will try every location, see age/keysource.go
elif key_path := os.environ.get("SOPS_AGE_KEY_FILE"): elif key_path := os.environ.get("SOPS_AGE_KEY_FILE"):
@@ -176,7 +191,41 @@ class Operation(enum.StrEnum):
UPDATE_KEYS = "updatekeys" UPDATE_KEYS = "updatekeys"
def load_age_plugins(flake_dir: str | Path) -> list[str]:
if not flake_dir:
msg = "Missing flake directory"
raise ClanError(msg)
cmd = nix_eval(
[
f"{flake_dir}#clanInternals.secrets.age.plugins",
"--json",
]
)
try:
result = run(cmd)
except Exception as e:
msg = f"Failed to load age plugins {flake_dir}"
raise ClanError(msg) from e
json_str = result.stdout.strip()
try:
plugins = json.loads(json_str)
except json.JSONDecodeError as e:
msg = f"Failed to decode '{json_str}': {e}"
raise ClanError(msg) from e
if isinstance(plugins, list):
return plugins
msg = f"Expected a list of age plugins but {type(plugins)!r} was provided"
raise ClanError(msg)
def sops_run( def sops_run(
flake_dir: str | Path,
call: Operation, call: Operation,
secret_path: Path, secret_path: Path,
public_keys: Iterable[SopsKey], public_keys: Iterable[SopsKey],
@@ -234,7 +283,9 @@ def sops_run(
raise ClanError(msg) raise ClanError(msg)
sops_cmd.append(str(secret_path)) sops_cmd.append(str(secret_path))
cmd = nix_shell(["sops", "gnupg"], sops_cmd) age_plugins = load_age_plugins(flake_dir)
cmd = nix_shell(["sops", "gnupg", *age_plugins], sops_cmd)
opts = ( opts = (
dataclasses.replace(run_opts, env=environ) dataclasses.replace(run_opts, env=environ)
if run_opts if run_opts
@@ -249,7 +300,44 @@ def sops_run(
return p.returncode, p.stdout return p.returncode, p.stdout
def get_public_age_key(privkey: str) -> str: def get_public_age_keys(contents: str) -> set[str]:
# we use a set as it's possible we may detect the same key twice, once in a `# comment` and once by recovering it
# from AGE-SECRET-KEY
keys: set[str] = set()
recipient: str | None = None
for line_number, line in enumerate(contents.splitlines()):
match = AGE_RECIPIENT_REGEX.match(line)
if match:
recipient = match[1]
keys.add(recipient)
if line.startswith("#"):
continue
if line.startswith("AGE-PLUGIN-"):
if not recipient:
msg = f"Did you forget to precede line {line_number} with it's corresponding `# recipient: age1xxxxxxxx` entry?"
raise ClanError(msg)
# reset recipient
recipient = None
if line.startswith("AGE-SECRET-KEY-"):
try:
keys.add(get_public_age_key_from_private_key(line))
except Exception as e:
msg = "Failed to get public key for age private key. Is the key malformed?"
raise ClanError(msg) from e
# reset recipient
recipient = None
return keys
def get_public_age_key_from_private_key(privkey: str) -> str:
cmd = nix_shell(["age"], ["age-keygen", "-y"]) cmd = nix_shell(["age"], ["age-keygen", "-y"])
error_msg = "Failed to get public key for age private key. Is the key malformed?" error_msg = "Failed to get public key for age private key. Is the key malformed?"
@@ -298,23 +386,7 @@ def get_user_name(flake_dir: Path, user: str) -> str:
print(f"{flake_dir / user} already exists") print(f"{flake_dir / user} already exists")
def maybe_get_user_or_machine(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None: def maybe_get_user(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None:
folders = [sops_users_folder(flake_dir), sops_machines_folder(flake_dir)]
for folder in folders:
if folder.exists():
for user in folder.iterdir():
if not (user / "key.json").exists():
continue
keys = read_keys(user)
if key in keys:
return {SopsKey(key.pubkey, user.name, key.key_type)}
return None
def get_user(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None:
folder = sops_users_folder(flake_dir) folder = sops_users_folder(flake_dir)
if folder.exists(): if folder.exists():
@@ -324,20 +396,11 @@ def get_user(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None:
keys = read_keys(user) keys = read_keys(user)
if key in keys: if key in keys:
return {SopsKey(key.pubkey, user.name, key.key_type)} return {SopsKey(key.pubkey, user.name, key.key_type) for key in keys}
return None return None
@API.register
def ensure_user_or_machine(flake_dir: Path, key: SopsKey) -> set[SopsKey]:
maybe_keys = maybe_get_user_or_machine(flake_dir, key)
if maybe_keys:
return maybe_keys
msg = f"A sops key is not yet added to the repository. Please add it with 'clan secrets users add youruser {key.pubkey}' (replace youruser with your user name)"
raise ClanError(msg)
def default_admin_private_key_path() -> Path: def default_admin_private_key_path() -> Path:
raw_path = os.environ.get("SOPS_AGE_KEY_FILE") raw_path = os.environ.get("SOPS_AGE_KEY_FILE")
if raw_path: if raw_path:
@@ -346,8 +409,9 @@ def default_admin_private_key_path() -> Path:
@API.register @API.register
def maybe_get_admin_public_key() -> None | SopsKey: def maybe_get_admin_public_key() -> SopsKey | None:
keyring = SopsKey.collect_public_keys() keyring = SopsKey.collect_public_keys()
if len(keyring) == 0: if len(keyring) == 0:
return None return None
@@ -366,19 +430,31 @@ def maybe_get_admin_public_key() -> None | SopsKey:
return keyring[0] return keyring[0]
def ensure_admin_public_key(flake_dir: Path) -> set[SopsKey]: def ensure_admin_public_keys(flake_dir: Path) -> set[SopsKey]:
key = maybe_get_admin_public_key() key = maybe_get_admin_public_key()
if key:
return ensure_user_or_machine(flake_dir, key) if not key:
msg = "No sops key found. Please generate one with 'clan secrets key generate'." msg = "No SOPS key found. Please generate one with `clan secrets key generate`."
raise ClanError(msg) raise ClanError(msg)
user_keys = maybe_get_user(flake_dir, key)
if not user_keys:
# todo improve error message
msg = f"We could not figure out which Clan secrets user you are with the SOPS key we found: {key.pubkey}"
raise ClanError(msg)
return user_keys
def update_keys(secret_path: Path, keys: Iterable[SopsKey]) -> list[Path]: def update_keys(
flake_dir: str | Path, secret_path: Path, keys: Iterable[SopsKey]
) -> list[Path]:
secret_path = secret_path / "secret" secret_path = secret_path / "secret"
error_msg = f"Could not update keys for {secret_path}" error_msg = f"Could not update keys for {secret_path}"
rc, _ = sops_run( rc, _ = sops_run(
flake_dir,
Operation.UPDATE_KEYS, Operation.UPDATE_KEYS,
secret_path, secret_path,
keys, keys,
@@ -389,6 +465,7 @@ def update_keys(secret_path: Path, keys: Iterable[SopsKey]) -> list[Path]:
def encrypt_file( def encrypt_file(
flake_dir: str | Path,
secret_path: Path, secret_path: Path,
content: str | IO[bytes] | bytes | None, content: str | IO[bytes] | bytes | None,
pubkeys: list[SopsKey], pubkeys: list[SopsKey],
@@ -399,6 +476,7 @@ def encrypt_file(
if not content: if not content:
# This will spawn an editor to edit the file. # This will spawn an editor to edit the file.
rc, _ = sops_run( rc, _ = sops_run(
flake_dir,
Operation.EDIT, Operation.EDIT,
secret_path, secret_path,
pubkeys, pubkeys,
@@ -437,6 +515,7 @@ def encrypt_file(
msg = f"Invalid content type: {type(content)}" msg = f"Invalid content type: {type(content)}"
raise ClanError(msg) raise ClanError(msg)
sops_run( sops_run(
flake_dir,
Operation.ENCRYPT, Operation.ENCRYPT,
Path(source.name), Path(source.name),
pubkeys, pubkeys,
@@ -451,11 +530,12 @@ def encrypt_file(
Path(source.name).unlink() Path(source.name).unlink()
def decrypt_file(secret_path: Path) -> str: def decrypt_file(flake_dir: str | Path, secret_path: Path) -> str:
# decryption uses private keys from the environment or default paths: # decryption uses private keys from the environment or default paths:
no_public_keys_needed: list[SopsKey] = [] no_public_keys_needed: list[SopsKey] = []
_, stdout = sops_run( _, stdout = sops_run(
flake_dir,
Operation.DECRYPT, Operation.DECRYPT,
secret_path, secret_path,
no_public_keys_needed, no_public_keys_needed,

View File

@@ -5,7 +5,7 @@ from pathlib import Path
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from .sops import get_public_age_key from .sops import get_public_age_keys
VALID_SECRET_NAME = re.compile(r"^[a-zA-Z0-9._-]+$") VALID_SECRET_NAME = re.compile(r"^[a-zA-Z0-9._-]+$")
VALID_USER_NAME = re.compile(r"^[a-z_]([a-z0-9_-]{0,31})?$") VALID_USER_NAME = re.compile(r"^[a-z_]([a-z0-9_-]{0,31})?$")
@@ -21,15 +21,20 @@ def secret_name_type(arg_value: str) -> str:
def public_or_private_age_key_type(arg_value: str) -> str: def public_or_private_age_key_type(arg_value: str) -> str:
if Path(arg_value).is_file(): if Path(arg_value).is_file():
arg_value = Path(arg_value).read_text().strip() arg_value = Path(arg_value).read_text().strip()
for line in arg_value.splitlines():
if line.startswith("#"): public_keys = get_public_age_keys(arg_value)
continue
if line.startswith("age1"): match len(public_keys):
return line.strip() case 0:
if line.startswith("AGE-SECRET-KEY-"): msg = f"Please provide an age public key starting with age1 or an age private key starting with AGE-SECRET-KEY- or AGE-PLUGIN-, got: '{arg_value}'"
return get_public_age_key(line) raise ClanError(msg)
msg = f"Please provide an age public key starting with age1 or an age private key AGE-SECRET-KEY-, got: '{arg_value}'"
raise ClanError(msg) case 1:
return next(iter(public_keys))
case n:
msg = f"{n} age keys were provided, please provide only 1: '{arg_value}'"
raise ClanError(msg)
def group_or_user_name_type(what: str) -> Callable[[str], str]: def group_or_user_name_type(what: str) -> Callable[[str], str]:

View File

@@ -93,6 +93,7 @@ def list_users(flake_dir: Path) -> list[str]:
def add_secret(flake_dir: Path, user: str, secret: str) -> None: def add_secret(flake_dir: Path, user: str, secret: str) -> None:
updated_paths = secrets.allow_member( updated_paths = secrets.allow_member(
flake_dir,
secrets.users_folder(sops_secrets_folder(flake_dir) / secret), secrets.users_folder(sops_secrets_folder(flake_dir) / secret),
sops_users_folder(flake_dir), sops_users_folder(flake_dir),
user, user,
@@ -106,7 +107,7 @@ def add_secret(flake_dir: Path, user: str, secret: str) -> None:
def remove_secret(flake_dir: Path, user: str, secret: str) -> None: def remove_secret(flake_dir: Path, user: str, secret: str) -> None:
updated_paths = secrets.disallow_member( updated_paths = secrets.disallow_member(
secrets.users_folder(sops_secrets_folder(flake_dir) / secret), user flake_dir, secrets.users_folder(sops_secrets_folder(flake_dir) / secret), user
) )
commit_files( commit_files(
updated_paths, updated_paths,

View File

@@ -62,6 +62,10 @@ KEYS = [
"age1eyyhln9g3cdwtrwpckugvqgtf5p8ugt0426sw38ra3wkc0t4rfhslq7txv", "age1eyyhln9g3cdwtrwpckugvqgtf5p8ugt0426sw38ra3wkc0t4rfhslq7txv",
"AGE-SECRET-KEY-1567QKA63Y9P62SHF5TCHVCT5GZX2LZ8NS0E9RKA2QHDA662SF5LQ2VJJYX", "AGE-SECRET-KEY-1567QKA63Y9P62SHF5TCHVCT5GZX2LZ8NS0E9RKA2QHDA662SF5LQ2VJJYX",
), ),
KeyPair(
"age1e9ufa6wrsr5danka50qp0np0832uz7jca7s00wyeg2nt3aqnvaks7p4jfr",
"AGE-SECRET-KEY-1Z89SHU9KAF709TTAZDARUWKC7H9TPZW4L8A2PVYSYAF7QVLCNQZQZ07U5J",
),
] ]

View File

@@ -10,9 +10,10 @@ if TYPE_CHECKING:
from .age_keys import KeyPair from .age_keys import KeyPair
@pytest.mark.with_core
def test_import_sops( def test_import_sops(
test_root: Path, test_root: Path,
test_flake: FlakeForTest, test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput, capture_output: CaptureOutput,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
@@ -24,7 +25,7 @@ def test_import_sops(
"machines", "machines",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"machine1", "machine1",
age_keys[0].pubkey, age_keys[0].pubkey,
] ]
@@ -35,7 +36,7 @@ def test_import_sops(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"user1", "user1",
age_keys[1].pubkey, age_keys[1].pubkey,
] ]
@@ -46,7 +47,7 @@ def test_import_sops(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"user2", "user2",
age_keys[2].pubkey, age_keys[2].pubkey,
] ]
@@ -57,7 +58,7 @@ def test_import_sops(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"user1", "user1",
] ]
@@ -68,7 +69,7 @@ def test_import_sops(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"user2", "user2",
] ]
@@ -80,7 +81,7 @@ def test_import_sops(
"secrets", "secrets",
"import-sops", "import-sops",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"--group", "--group",
"group1", "group1",
"--machine", "--machine",
@@ -90,10 +91,13 @@ def test_import_sops(
cli.run(cmd) cli.run(cmd)
with capture_output as output: with capture_output as output:
cli.run(["secrets", "users", "list", "--flake", str(test_flake.path)]) cli.run(["secrets", "users", "list", "--flake", str(test_flake_with_core.path)])
users = sorted(output.out.rstrip().split()) users = sorted(output.out.rstrip().split())
assert users == ["user1", "user2"] assert users == ["user1", "user2"]
with capture_output as output: with capture_output as output:
cli.run(["secrets", "get", "--flake", str(test_flake.path), "secret-key"]) cli.run(
["secrets", "get", "--flake", str(test_flake_with_core.path), "secret-key"]
)
assert output.out == "secret-value" assert output.out == "secret-value"

View File

@@ -40,79 +40,89 @@ def test_add_module_to_inventory(
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
) -> None: ) -> None:
base_path = test_flake_with_core.path base_path = test_flake_with_core.path
monkeypatch.chdir(test_flake_with_core.path)
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
cli.run( with monkeypatch.context():
[ monkeypatch.chdir(test_flake_with_core.path)
"secrets", monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
"users",
"add",
"--flake",
str(test_flake_with_core.path),
"user1",
age_keys[0].pubkey,
]
)
opts = CreateOptions(
clan_dir=Flake(str(base_path)),
machine=Machine(name="machine1", tags=[], deploy=MachineDeploy()),
)
create_machine(opts) cli.run(
(test_flake_with_core.path / "machines" / "machine1" / "facter.json").write_text( [
json.dumps( "secrets",
{ "users",
"version": 1, "add",
"system": "x86_64-linux", "--flake",
} str(test_flake_with_core.path),
"user1",
age_keys[0].pubkey,
]
)
opts = CreateOptions(
clan_dir=Flake(str(base_path)),
machine=Machine(name="machine1", tags=[], deploy=MachineDeploy()),
) )
)
subprocess.run(["git", "add", "."], cwd=test_flake_with_core.path, check=True)
inventory: Inventory = {} create_machine(opts)
(
test_flake_with_core.path / "machines" / "machine1" / "facter.json"
).write_text(
json.dumps(
{
"version": 1,
"system": "x86_64-linux",
}
)
)
subprocess.run(["git", "add", "."], cwd=test_flake_with_core.path, check=True)
inventory["services"] = { inventory: Inventory = {}
"borgbackup": {
"borg1": { inventory["services"] = {
"meta": {"name": "borg1"}, "borgbackup": {
"roles": { "borg1": {
"client": {"machines": ["machine1"]}, "meta": {"name": "borg1"},
"server": {"machines": ["machine1"]}, "roles": {
}, "client": {"machines": ["machine1"]},
"server": {"machines": ["machine1"]},
},
}
} }
} }
}
set_inventory(inventory, base_path, "Add borgbackup service") set_inventory(inventory, base_path, "Add borgbackup service")
# cmd = ["facts", "generate", "--flake", str(test_flake_with_core.path), "machine1"] # cmd = ["facts", "generate", "--flake", str(test_flake_with_core.path), "machine1"]
cmd = ["vars", "generate", "--flake", str(test_flake_with_core.path), "machine1"] cmd = [
"vars",
cli.run(cmd) "generate",
"--flake",
machine = MachineMachine( str(test_flake_with_core.path),
name="machine1", flake=Flake(str(test_flake_with_core.path)) "machine1",
)
generator = None
for gen in machine.vars_generators:
if gen.name == "borgbackup":
generator = gen
break
assert generator
ssh_key = machine.public_vars_store.get(generator, "borgbackup.ssh.pub")
cmd = nix_eval(
[
f"{base_path}#nixosConfigurations.machine1.config.services.borgbackup.repos",
"--json",
] ]
)
proc = run_no_stdout(cmd)
res = json.loads(proc.stdout.strip())
assert res["machine1"]["authorizedKeys"] == [ssh_key.decode()] cli.run(cmd)
machine = MachineMachine(
name="machine1", flake=Flake(str(test_flake_with_core.path))
)
generator = None
for gen in machine.vars_generators:
if gen.name == "borgbackup":
generator = gen
break
assert generator
ssh_key = machine.public_vars_store.get(generator, "borgbackup.ssh.pub")
cmd = nix_eval(
[
f"{base_path}#nixosConfigurations.machine1.config.services.borgbackup.repos",
"--json",
]
)
proc = run_no_stdout(cmd)
res = json.loads(proc.stdout.strip())
assert res["machine1"]["authorizedKeys"] == [ssh_key.decode()]

View File

@@ -1,7 +1,9 @@
import json import json
import logging import logging
import os import os
import random
import re import re
import string
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -20,14 +22,15 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@pytest.mark.with_core
def _test_identities( def _test_identities(
what: str, what: str,
test_flake: FlakeForTest, test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput, capture_output: CaptureOutput,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
sops_folder = test_flake.path / "sops" sops_folder = test_flake_with_core.path / "sops"
what_singular = what[:-1] what_singular = what[:-1]
test_secret_name = f"{what_singular}_secret" test_secret_name = f"{what_singular}_secret"
@@ -43,7 +46,7 @@ def _test_identities(
what, what,
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"foo", "foo",
age_keys[0].pubkey, age_keys[0].pubkey,
] ]
@@ -56,7 +59,7 @@ def _test_identities(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin", "admin",
admin_age_key.pubkey, admin_age_key.pubkey,
] ]
@@ -69,28 +72,30 @@ def _test_identities(
what, what,
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"foo", "foo",
age_keys[0].pubkey, age_keys[0].pubkey,
] ]
) )
with monkeypatch.context(): with monkeypatch.context() as m:
monkeypatch.setenv("SOPS_NIX_SECRET", "deadfeed") m.setenv("SOPS_NIX_SECRET", "deadfeed")
monkeypatch.setenv("SOPS_AGE_KEY", admin_age_key.privkey) m.setenv("SOPS_AGE_KEY", admin_age_key.privkey)
cli.run( cli.run(
[ [
"secrets", "secrets",
"set", "set",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
f"--{what_singular}", f"--{what_singular}",
"foo", "foo",
test_secret_name, test_secret_name,
] ]
) )
assert_secrets_file_recipients( assert_secrets_file_recipients(
test_flake.path, test_flake_with_core.path,
test_secret_name, test_secret_name,
expected_age_recipients_keypairs=[age_keys[0], admin_age_key], expected_age_recipients_keypairs=[age_keys[0], admin_age_key],
) )
@@ -103,14 +108,14 @@ def _test_identities(
what, what,
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"-f", "-f",
"foo", "foo",
age_keys[1].privkey, age_keys[1].privkey,
] ]
) )
assert_secrets_file_recipients( assert_secrets_file_recipients(
test_flake.path, test_flake_with_core.path,
test_secret_name, test_secret_name,
expected_age_recipients_keypairs=[age_keys[1], admin_age_key], expected_age_recipients_keypairs=[age_keys[1], admin_age_key],
) )
@@ -122,24 +127,35 @@ def _test_identities(
what, what,
"get", "get",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"foo", "foo",
] ]
) )
assert age_keys[1].pubkey in output.out assert age_keys[1].pubkey in output.out
with capture_output as output: with capture_output as output:
cli.run(["secrets", what, "list", "--flake", str(test_flake.path)]) cli.run(["secrets", what, "list", "--flake", str(test_flake_with_core.path)])
assert "foo" in output.out assert "foo" in output.out
cli.run(["secrets", what, "remove", "--flake", str(test_flake.path), "foo"]) cli.run(
["secrets", what, "remove", "--flake", str(test_flake_with_core.path), "foo"]
)
assert not (sops_folder / what / "foo" / "key.json").exists() assert not (sops_folder / what / "foo" / "key.json").exists()
with pytest.raises(ClanError): # already removed with pytest.raises(ClanError): # already removed
cli.run(["secrets", what, "remove", "--flake", str(test_flake.path), "foo"]) cli.run(
[
"secrets",
what,
"remove",
"--flake",
str(test_flake_with_core.path),
"foo",
]
)
with capture_output as output: with capture_output as output:
cli.run(["secrets", what, "list", "--flake", str(test_flake.path)]) cli.run(["secrets", what, "list", "--flake", str(test_flake_with_core.path)])
assert "foo" not in output.out assert "foo" not in output.out
user_or_machine_symlink = sops_folder / "secrets" / test_secret_name / what / "foo" user_or_machine_symlink = sops_folder / "secrets" / test_secret_name / what / "foo"
@@ -151,112 +167,186 @@ def _test_identities(
assert not user_or_machine_symlink.exists(follow_symlinks=False), err_msg assert not user_or_machine_symlink.exists(follow_symlinks=False), err_msg
@pytest.mark.with_core
def test_users( def test_users(
test_flake: FlakeForTest, test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput, capture_output: CaptureOutput,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_test_identities("users", test_flake, capture_output, age_keys, monkeypatch) with monkeypatch.context():
_test_identities(
"users", test_flake_with_core, capture_output, age_keys, monkeypatch
)
# some additional user-specific tests
admin_key = age_keys[2] @pytest.mark.with_core
sops_folder = test_flake.path / "sops" def test_multiple_user_keys(
test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput,
age_keys: list["KeyPair"],
monkeypatch: pytest.MonkeyPatch,
) -> None:
sops_folder = test_flake_with_core.path / "sops"
user_keys = { users_keys = {
"bob": [age_keys[0], age_keys[1]], "bob": {age_keys[0], age_keys[1]},
"alice": [age_keys[2]], "alice": {age_keys[2]},
"charlie": [age_keys[3], age_keys[4]], "charlie": {age_keys[3], age_keys[4], age_keys[5]},
} }
for user, keys in user_keys.items(): for user, user_keys in users_keys.items():
key_args = [f"--age-key={key.pubkey}" for key in keys] # add the user
# add the user keys
cli.run( cli.run(
[ [
"secrets", "secrets",
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
user, user,
*key_args, *[f"--age-key={key.pubkey}" for key in user_keys],
] ]
) )
assert (sops_folder / "users" / user / "key.json").exists() assert (sops_folder / "users" / user / "key.json").exists()
# check they are returned in get # check they are returned in get
with capture_output as output: with capture_output as output:
cli.run(["secrets", "users", "get", "--flake", str(test_flake.path), user]) cli.run(
[
"secrets",
"users",
"get",
"--flake",
str(test_flake_with_core.path),
user,
]
)
for key in keys: for user_key in user_keys:
assert key.pubkey in output.out assert user_key.pubkey in output.out
# set a secret # let's do some setting and getting of secrets
secret_name = f"{user}_secret"
cli.run(
[
"secrets",
"set",
"--flake",
str(test_flake.path),
"--user",
user,
secret_name,
]
)
# check the secret has each of our user's keys as a recipient def random_str() -> str:
# in addition the admin key should be there return "".join(random.choices(string.ascii_letters, k=10))
assert_secrets_file_recipients(
test_flake.path,
secret_name,
expected_age_recipients_keypairs=[admin_key, *keys],
)
if len(keys) == 1: for user_key in user_keys:
# set a secret using each of the user's private keys
with monkeypatch.context():
secret_name = f"{user}_secret_{random_str()}"
secret_value = random_str()
monkeypatch.setenv("SOPS_AGE_KEY", user_key.privkey)
monkeypatch.setenv("SOPS_NIX_SECRET", secret_value)
cli.run(
[
"secrets",
"set",
"--flake",
str(test_flake_with_core.path),
secret_name,
]
)
# check the secret has each of our user's keys as a recipient
assert_secrets_file_recipients(
test_flake_with_core.path,
secret_name,
expected_age_recipients_keypairs=[*user_keys],
)
# check we can get the secret
with capture_output as output:
cli.run(
[
"secrets",
"get",
"--flake",
str(test_flake_with_core.path),
secret_name,
]
)
assert secret_value in output.out
if len(user_keys) == 1:
continue continue
# remove one of the keys # remove one of the user keys,
cli.run( user_keys_iter = iter(user_keys)
[
"secrets",
"users",
"remove-key",
"--flake",
str(test_flake.path),
user,
keys[0].pubkey,
]
)
# check the secret has been updated key_to_remove = next(user_keys_iter)
assert_secrets_file_recipients( key_to_encrypt_with = next(user_keys_iter)
test_flake.path,
secret_name, with monkeypatch.context():
expected_age_recipients_keypairs=[admin_key, *keys[1:]], monkeypatch.setenv("SOPS_AGE_KEY", key_to_encrypt_with.privkey)
) monkeypatch.setenv("SOPS_NIX_SECRET", "deafbeef")
cli.run(
[
"secrets",
"users",
"remove-key",
"--flake",
str(test_flake_with_core.path),
user,
key_to_remove.pubkey,
]
)
# check the secret has been updated
assert_secrets_file_recipients(
test_flake_with_core.path,
secret_name,
expected_age_recipients_keypairs=list({*user_keys} - {key_to_remove}),
)
# add the key back
cli.run(
[
"secrets",
"users",
"add-key",
"--flake",
str(test_flake_with_core.path),
user,
key_to_remove.pubkey,
]
)
# check the secret has been updated
assert_secrets_file_recipients(
test_flake_with_core.path,
secret_name,
expected_age_recipients_keypairs=user_keys,
)
@pytest.mark.with_core
def test_machines( def test_machines(
test_flake: FlakeForTest, test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput, capture_output: CaptureOutput,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_test_identities("machines", test_flake, capture_output, age_keys, monkeypatch) _test_identities(
"machines", test_flake_with_core, capture_output, age_keys, monkeypatch
)
@pytest.mark.with_core
def test_groups( def test_groups(
test_flake: FlakeForTest, test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput, capture_output: CaptureOutput,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
with capture_output as output: with capture_output as output:
cli.run(["secrets", "groups", "list", "--flake", str(test_flake.path)]) cli.run(
["secrets", "groups", "list", "--flake", str(test_flake_with_core.path)]
)
assert output.out == "" assert output.out == ""
machine1_age_key = age_keys[0] machine1_age_key = age_keys[0]
@@ -270,7 +360,7 @@ def test_groups(
"groups", "groups",
"add-machine", "add-machine",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"machine1", "machine1",
] ]
@@ -282,7 +372,7 @@ def test_groups(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"groupb1", "groupb1",
"user1", "user1",
] ]
@@ -293,7 +383,7 @@ def test_groups(
"machines", "machines",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"machine1", "machine1",
machine1_age_key.pubkey, machine1_age_key.pubkey,
] ]
@@ -304,7 +394,7 @@ def test_groups(
"groups", "groups",
"add-machine", "add-machine",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"machine1", "machine1",
] ]
@@ -317,7 +407,7 @@ def test_groups(
"groups", "groups",
"add-machine", "add-machine",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"machine1", "machine1",
] ]
@@ -329,7 +419,7 @@ def test_groups(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"user1", "user1",
user1_age_key.pubkey, user1_age_key.pubkey,
] ]
@@ -340,7 +430,7 @@ def test_groups(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin", "admin",
admin_age_key.pubkey, admin_age_key.pubkey,
] ]
@@ -351,14 +441,16 @@ def test_groups(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"user1", "user1",
] ]
) )
with capture_output as output: with capture_output as output:
cli.run(["secrets", "groups", "list", "--flake", str(test_flake.path)]) cli.run(
["secrets", "groups", "list", "--flake", str(test_flake_with_core.path)]
)
out = output.out out = output.out
assert "user1" in out assert "user1" in out
assert "machine1" in out assert "machine1" in out
@@ -368,12 +460,13 @@ def test_groups(
with monkeypatch.context(): with monkeypatch.context():
monkeypatch.setenv("SOPS_NIX_SECRET", "deafbeef") monkeypatch.setenv("SOPS_NIX_SECRET", "deafbeef")
monkeypatch.setenv("SOPS_AGE_KEY", admin_age_key.privkey) monkeypatch.setenv("SOPS_AGE_KEY", admin_age_key.privkey)
cli.run( cli.run(
[ [
"secrets", "secrets",
"set", "set",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"--group", "--group",
"group1", "group1",
secret_name, secret_name,
@@ -381,7 +474,7 @@ def test_groups(
) )
assert_secrets_file_recipients( assert_secrets_file_recipients(
test_flake.path, test_flake_with_core.path,
secret_name, secret_name,
expected_age_recipients_keypairs=[ expected_age_recipients_keypairs=[
machine1_age_key, machine1_age_key,
@@ -400,13 +493,13 @@ def test_groups(
"groups", "groups",
"remove-user", "remove-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"user1", "user1",
] ]
) )
assert_secrets_file_recipients( assert_secrets_file_recipients(
test_flake.path, test_flake_with_core.path,
secret_name, secret_name,
expected_age_recipients_keypairs=[machine1_age_key, admin_age_key], expected_age_recipients_keypairs=[machine1_age_key, admin_age_key],
err_msg=( err_msg=(
@@ -422,13 +515,13 @@ def test_groups(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"user1", "user1",
] ]
) )
assert_secrets_file_recipients( assert_secrets_file_recipients(
test_flake.path, test_flake_with_core.path,
secret_name, secret_name,
expected_age_recipients_keypairs=[ expected_age_recipients_keypairs=[
machine1_age_key, machine1_age_key,
@@ -444,12 +537,12 @@ def test_groups(
"users", "users",
"remove", "remove",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"user1", "user1",
] ]
) )
assert_secrets_file_recipients( assert_secrets_file_recipients(
test_flake.path, test_flake_with_core.path,
secret_name, secret_name,
expected_age_recipients_keypairs=[machine1_age_key, admin_age_key], expected_age_recipients_keypairs=[machine1_age_key, admin_age_key],
err_msg=( err_msg=(
@@ -464,13 +557,13 @@ def test_groups(
"groups", "groups",
"remove-machine", "remove-machine",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"group1", "group1",
"machine1", "machine1",
] ]
) )
assert_secrets_file_recipients( assert_secrets_file_recipients(
test_flake.path, test_flake_with_core.path,
secret_name, secret_name,
expected_age_recipients_keypairs=[admin_age_key], expected_age_recipients_keypairs=[admin_age_key],
err_msg=( err_msg=(
@@ -479,11 +572,11 @@ def test_groups(
), ),
) )
first_group = next((test_flake.path / "sops" / "groups").iterdir(), None) first_group = next((test_flake_with_core.path / "sops" / "groups").iterdir(), None)
assert first_group is None assert first_group is None
# Check if the symlink to the group was removed from our foo test secret: # Check if the symlink to the group was removed from our foo test secret:
group_symlink = test_flake.path / "sops/secrets/foo/groups/group1" group_symlink = test_flake_with_core.path / "sops/secrets/foo/groups/group1"
err_msg = ( err_msg = (
"Symlink to group1's key in foo secret " "Symlink to group1's key in foo secret "
"was not cleaned up after group1 was removed" "was not cleaned up after group1 was removed"
@@ -520,61 +613,105 @@ def use_gpg_key(key: GpgKey, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("SOPS_AGE_KEY", old_key) monkeypatch.setenv("SOPS_AGE_KEY", old_key)
@pytest.mark.with_core
def test_secrets( def test_secrets(
test_flake: FlakeForTest, test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput, capture_output: CaptureOutput,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
gpg_key: GpgKey, gpg_key: GpgKey,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
) -> None: ) -> None:
with capture_output as output: with capture_output as output:
cli.run(["secrets", "list", "--flake", str(test_flake.path)]) cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path)])
assert output.out == "" assert output.out == ""
# Generate a new key for the clan # Generate a new key for the clan
monkeypatch.setenv("SOPS_AGE_KEY_FILE", str(test_flake.path / ".." / "age.key")) monkeypatch.setenv(
"SOPS_AGE_KEY_FILE", str(test_flake_with_core.path / ".." / "age.key")
)
with capture_output as output: with capture_output as output:
cli.run(["secrets", "key", "generate", "--flake", str(test_flake.path)]) cli.run(
["secrets", "key", "generate", "--flake", str(test_flake_with_core.path)]
)
assert "age private key" in output.out assert "age private key" in output.out
# Read the key that was generated # Read the key that was generated
with capture_output as output: with capture_output as output:
cli.run(["secrets", "key", "show", "--flake", str(test_flake.path)]) cli.run(["secrets", "key", "show", "--flake", str(test_flake_with_core.path)])
key = json.loads(output.out)["publickey"]
assert key.startswith("age1") key = json.loads(output.out)
assert key["publickey"].startswith("age1")
# Add testuser with the key that was generated for the clan # Add testuser with the key that was generated for the clan
cli.run( cli.run(
["secrets", "users", "add", "--flake", str(test_flake.path), "testuser", key] [
"secrets",
"users",
"add",
"--flake",
str(test_flake_with_core.path),
"testuser",
key["publickey"],
]
) )
with pytest.raises(ClanError): # does not exist yet with pytest.raises(ClanError): # does not exist yet
cli.run(["secrets", "get", "--flake", str(test_flake.path), "nonexisting"]) cli.run(
["secrets", "get", "--flake", str(test_flake_with_core.path), "nonexisting"]
)
monkeypatch.setenv("SOPS_NIX_SECRET", "foo") monkeypatch.setenv("SOPS_NIX_SECRET", "foo")
cli.run(["secrets", "set", "--flake", str(test_flake.path), "initialkey"]) cli.run(["secrets", "set", "--flake", str(test_flake_with_core.path), "initialkey"])
with capture_output as output: with capture_output as output:
cli.run(["secrets", "get", "--flake", str(test_flake.path), "initialkey"]) cli.run(
["secrets", "get", "--flake", str(test_flake_with_core.path), "initialkey"]
)
assert output.out == "foo" assert output.out == "foo"
with capture_output as output: with capture_output as output:
cli.run(["secrets", "users", "list", "--flake", str(test_flake.path)]) cli.run(["secrets", "users", "list", "--flake", str(test_flake_with_core.path)])
users = output.out.rstrip().split("\n") users = output.out.rstrip().split("\n")
assert len(users) == 1, f"users: {users}" assert len(users) == 1, f"users: {users}"
owner = users[0] owner = users[0]
monkeypatch.setenv("EDITOR", "cat") monkeypatch.setenv("EDITOR", "cat")
cli.run(["secrets", "set", "--edit", "--flake", str(test_flake.path), "initialkey"]) cli.run(
[
"secrets",
"set",
"--edit",
"--flake",
str(test_flake_with_core.path),
"initialkey",
]
)
monkeypatch.delenv("EDITOR") monkeypatch.delenv("EDITOR")
cli.run(["secrets", "rename", "--flake", str(test_flake.path), "initialkey", "key"]) cli.run(
[
"secrets",
"rename",
"--flake",
str(test_flake_with_core.path),
"initialkey",
"key",
]
)
with capture_output as output: with capture_output as output:
cli.run(["secrets", "list", "--flake", str(test_flake.path)]) cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path)])
assert output.out == "key\n" assert output.out == "key\n"
with capture_output as output: with capture_output as output:
cli.run(["secrets", "list", "--flake", str(test_flake.path), "nonexisting"]) cli.run(
[
"secrets",
"list",
"--flake",
str(test_flake_with_core.path),
"nonexisting",
]
)
assert output.out == "" assert output.out == ""
with capture_output as output: with capture_output as output:
cli.run(["secrets", "list", "--flake", str(test_flake.path), "key"]) cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path), "key"])
assert output.out == "key\n" assert output.out == "key\n"
# using the `age_keys` KeyPair, add a machine and rotate its key # using the `age_keys` KeyPair, add a machine and rotate its key
@@ -585,7 +722,7 @@ def test_secrets(
"machines", "machines",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"machine1", "machine1",
age_keys[1].pubkey, age_keys[1].pubkey,
] ]
@@ -596,18 +733,22 @@ def test_secrets(
"machines", "machines",
"add-secret", "add-secret",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"machine1", "machine1",
"key", "key",
] ]
) )
with capture_output as output: with capture_output as output:
cli.run(["secrets", "machines", "list", "--flake", str(test_flake.path)]) cli.run(
["secrets", "machines", "list", "--flake", str(test_flake_with_core.path)]
)
assert output.out == "machine1\n" assert output.out == "machine1\n"
with use_age_key(age_keys[1].privkey, monkeypatch): with use_age_key(age_keys[1].privkey, monkeypatch):
with capture_output as output: with capture_output as output:
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) cli.run(
["secrets", "get", "--flake", str(test_flake_with_core.path), "key"]
)
assert output.out == "foo" assert output.out == "foo"
# rotate machines key # rotate machines key
@@ -617,7 +758,7 @@ def test_secrets(
"machines", "machines",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"-f", "-f",
"machine1", "machine1",
age_keys[0].privkey, age_keys[0].privkey,
@@ -627,7 +768,9 @@ def test_secrets(
# should also rotate the encrypted secret # should also rotate the encrypted secret
with use_age_key(age_keys[0].privkey, monkeypatch): with use_age_key(age_keys[0].privkey, monkeypatch):
with capture_output as output: with capture_output as output:
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) cli.run(
["secrets", "get", "--flake", str(test_flake_with_core.path), "key"]
)
assert output.out == "foo" assert output.out == "foo"
cli.run( cli.run(
@@ -636,7 +779,7 @@ def test_secrets(
"machines", "machines",
"remove-secret", "remove-secret",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"machine1", "machine1",
"key", "key",
] ]
@@ -648,7 +791,7 @@ def test_secrets(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"user1", "user1",
age_keys[1].pubkey, age_keys[1].pubkey,
] ]
@@ -659,13 +802,13 @@ def test_secrets(
"users", "users",
"add-secret", "add-secret",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"user1", "user1",
"key", "key",
] ]
) )
with capture_output as output, use_age_key(age_keys[1].privkey, monkeypatch): with capture_output as output, use_age_key(age_keys[1].privkey, monkeypatch):
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) cli.run(["secrets", "get", "--flake", str(test_flake_with_core.path), "key"])
assert output.out == "foo" assert output.out == "foo"
cli.run( cli.run(
[ [
@@ -673,7 +816,7 @@ def test_secrets(
"users", "users",
"remove-secret", "remove-secret",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"user1", "user1",
"key", "key",
] ]
@@ -686,7 +829,7 @@ def test_secrets(
"groups", "groups",
"add-secret", "add-secret",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin-group", "admin-group",
"key", "key",
] ]
@@ -697,7 +840,7 @@ def test_secrets(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin-group", "admin-group",
"user1", "user1",
] ]
@@ -708,7 +851,7 @@ def test_secrets(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin-group", "admin-group",
owner, owner,
] ]
@@ -719,7 +862,7 @@ def test_secrets(
"groups", "groups",
"add-secret", "add-secret",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin-group", "admin-group",
"key", "key",
] ]
@@ -730,7 +873,7 @@ def test_secrets(
"secrets", "secrets",
"set", "set",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"--group", "--group",
"admin-group", "admin-group",
"key2", "key2",
@@ -739,7 +882,9 @@ def test_secrets(
with use_age_key(age_keys[1].privkey, monkeypatch): with use_age_key(age_keys[1].privkey, monkeypatch):
with capture_output as output: with capture_output as output:
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) cli.run(
["secrets", "get", "--flake", str(test_flake_with_core.path), "key"]
)
assert output.out == "foo" assert output.out == "foo"
# Add an user with a GPG key # Add an user with a GPG key
@@ -749,7 +894,7 @@ def test_secrets(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"--pgp-key", "--pgp-key",
gpg_key.fingerprint, gpg_key.fingerprint,
"user2", "user2",
@@ -763,7 +908,7 @@ def test_secrets(
"groups", "groups",
"add-user", "add-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin-group", "admin-group",
"user2", "user2",
] ]
@@ -771,7 +916,9 @@ def test_secrets(
with use_gpg_key(gpg_key, monkeypatch): # user2 with use_gpg_key(gpg_key, monkeypatch): # user2
with capture_output as output: with capture_output as output:
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) cli.run(
["secrets", "get", "--flake", str(test_flake_with_core.path), "key"]
)
assert output.out == "foo" assert output.out == "foo"
cli.run( cli.run(
@@ -780,7 +927,7 @@ def test_secrets(
"groups", "groups",
"remove-user", "remove-user",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin-group", "admin-group",
"user2", "user2",
] ]
@@ -791,7 +938,7 @@ def test_secrets(
capture_output as output, capture_output as output,
): ):
# user2 is not in the group anymore # user2 is not in the group anymore
cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) cli.run(["secrets", "get", "--flake", str(test_flake_with_core.path), "key"])
print(output.out) print(output.out)
cli.run( cli.run(
@@ -800,22 +947,23 @@ def test_secrets(
"groups", "groups",
"remove-secret", "remove-secret",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"admin-group", "admin-group",
"key", "key",
] ]
) )
cli.run(["secrets", "remove", "--flake", str(test_flake.path), "key"]) cli.run(["secrets", "remove", "--flake", str(test_flake_with_core.path), "key"])
cli.run(["secrets", "remove", "--flake", str(test_flake.path), "key2"]) cli.run(["secrets", "remove", "--flake", str(test_flake_with_core.path), "key2"])
with capture_output as output: with capture_output as output:
cli.run(["secrets", "list", "--flake", str(test_flake.path)]) cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path)])
assert output.out == "" assert output.out == ""
@pytest.mark.with_core
def test_secrets_key_generate_gpg( def test_secrets_key_generate_gpg(
test_flake: FlakeForTest, test_flake_with_core: FlakeForTest,
capture_output: CaptureOutput, capture_output: CaptureOutput,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
gpg_key: GpgKey, gpg_key: GpgKey,
@@ -830,14 +978,16 @@ def test_secrets_key_generate_gpg(
"key", "key",
"generate", "generate",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
] ]
) )
assert "age private key" not in output.out assert "age private key" not in output.out
assert re.match(r"PGP key.+is already set", output.err) is not None assert re.match(r"PGP key.+is already set", output.err) is not None
with capture_output as output: with capture_output as output:
cli.run(["secrets", "key", "show", "--flake", str(test_flake.path)]) cli.run(
["secrets", "key", "show", "--flake", str(test_flake_with_core.path)]
)
key = json.loads(output.out) key = json.loads(output.out)
assert key["type"] == "pgp" assert key["type"] == "pgp"
assert key["publickey"] == gpg_key.fingerprint assert key["publickey"] == gpg_key.fingerprint
@@ -849,12 +999,13 @@ def test_secrets_key_generate_gpg(
"users", "users",
"add", "add",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"--pgp-key", "--pgp-key",
gpg_key.fingerprint, gpg_key.fingerprint,
"testuser", "testuser",
] ]
) )
with capture_output as output: with capture_output as output:
cli.run( cli.run(
[ [
@@ -862,7 +1013,7 @@ def test_secrets_key_generate_gpg(
"users", "users",
"get", "get",
"--flake", "--flake",
str(test_flake.path), str(test_flake_with_core.path),
"testuser", "testuser",
] ]
) )
@@ -873,8 +1024,26 @@ def test_secrets_key_generate_gpg(
assert key["type"] == "pgp" assert key["type"] == "pgp"
assert key["publickey"] == gpg_key.fingerprint assert key["publickey"] == gpg_key.fingerprint
monkeypatch.setenv("SOPS_NIX_SECRET", "secret-value") with monkeypatch.context() as m:
cli.run(["secrets", "set", "--flake", str(test_flake.path), "secret-name"]) m.setenv("SOPS_NIX_SECRET", "secret-value")
with capture_output as output:
cli.run(["secrets", "get", "--flake", str(test_flake.path), "secret-name"]) cli.run(
assert output.out == "secret-value" [
"secrets",
"set",
"--flake",
str(test_flake_with_core.path),
"secret-name",
]
)
with capture_output as output:
cli.run(
[
"secrets",
"get",
"--flake",
str(test_flake_with_core.path),
"secret-name",
]
)
assert output.out == "secret-value"

View File

@@ -44,50 +44,55 @@ def test_secrets_upload(
config["clan"]["networking"]["targetHost"] = addr config["clan"]["networking"]["targetHost"] = addr
config["clan"]["core"]["facts"]["secretUploadDirectory"] = str(flake.path / "facts") config["clan"]["core"]["facts"]["secretUploadDirectory"] = str(flake.path / "facts")
flake.refresh() flake.refresh()
monkeypatch.chdir(str(flake.path))
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
sops_dir = flake.path / "facts" with monkeypatch.context():
monkeypatch.chdir(str(flake.path))
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
# the flake defines this path as the location where the sops key should be installed sops_dir = flake.path / "facts"
sops_key = sops_dir / "key.txt"
sops_key2 = sops_dir / "key2.txt"
# Create old state, which should be cleaned up # the flake defines this path as the location where the sops key should be installed
sops_dir.mkdir() sops_key = sops_dir / "key.txt"
sops_key.write_text("OLD STATE") sops_key2 = sops_dir / "key2.txt"
sops_key2.write_text("OLD STATE2")
cli.run( # Create old state, which should be cleaned up
[ sops_dir.mkdir()
"secrets", sops_key.write_text("OLD STATE")
"users", sops_key2.write_text("OLD STATE2")
"add",
"--flake",
str(flake.path),
"user1",
age_keys[0].pubkey,
]
)
cli.run( cli.run(
[ [
"secrets", "secrets",
"machines", "users",
"add", "add",
"--flake", "--flake",
str(flake.path), str(flake.path),
"vm1", "user1",
age_keys[1].pubkey, age_keys[0].pubkey,
] ]
) )
monkeypatch.setenv("SOPS_NIX_SECRET", age_keys[0].privkey)
cli.run(["secrets", "set", "--flake", str(flake.path), "vm1-age.key"])
flake_path = flake.path.joinpath("flake.nix") cli.run(
[
"secrets",
"machines",
"add",
"--flake",
str(flake.path),
"vm1",
age_keys[1].pubkey,
]
)
cli.run(["facts", "upload", "--flake", str(flake_path), "vm1"]) with monkeypatch.context() as m:
m.setenv("SOPS_NIX_SECRET", age_keys[0].privkey)
assert sops_key.exists() cli.run(["secrets", "set", "--flake", str(flake.path), "vm1-age.key"])
assert sops_key.read_text() == age_keys[0].privkey
assert not sops_key2.exists() flake_path = flake.path.joinpath("flake.nix")
cli.run(["facts", "upload", "--flake", str(flake_path), "vm1"])
assert sops_key.exists()
assert sops_key.read_text() == age_keys[0].privkey
assert not sops_key2.exists()

View File

@@ -32,27 +32,29 @@ def test_run(
test_flake_with_core: FlakeForTest, test_flake_with_core: FlakeForTest,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
) -> None: ) -> None:
monkeypatch.chdir(test_flake_with_core.path) with monkeypatch.context():
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey) monkeypatch.chdir(test_flake_with_core.path)
cli.run( monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
[
"secrets", cli.run(
"users", [
"add", "secrets",
"user1", "users",
age_keys[0].pubkey, "add",
] "user1",
) age_keys[0].pubkey,
cli.run( ]
[ )
"secrets", cli.run(
"groups", [
"add-user", "secrets",
"admins", "groups",
"user1", "add-user",
] "admins",
) "user1",
cli.run(["vms", "run", "--no-block", "vm1", "-c", "shutdown", "-h", "now"]) ]
)
cli.run(["vms", "run", "--no-block", "vm1", "-c", "shutdown", "-h", "now"])
@pytest.mark.skipif(no_kvm, reason="Requires KVM") @pytest.mark.skipif(no_kvm, reason="Requires KVM")

View File

@@ -302,6 +302,7 @@ class SecretStore(StoreBase):
for group in self.machine.deployment["sops"]["defaultGroups"]: for group in self.machine.deployment["sops"]["defaultGroups"]:
allow_member( allow_member(
self.machine.flake_dir,
groups_folder(secret_path), groups_folder(secret_path),
sops_groups_folder(self.machine.flake_dir), sops_groups_folder(self.machine.flake_dir),
group, group,
@@ -310,6 +311,7 @@ class SecretStore(StoreBase):
) )
update_keys( update_keys(
self.machine.flake_dir,
secret_path, secret_path,
collect_keys_for_path(secret_path), collect_keys_for_path(secret_path),
) )