Merge pull request 'add option to set defaultGroups for secrets' (#858) from Mic92-target_host into main
All checks were successful
checks-impure / test (push) Successful in 1m54s
checks / test (push) Successful in 2m46s

This commit is contained in:
clan-bot 2024-02-16 16:29:28 +00:00
commit 36b20f18d4
14 changed files with 86 additions and 14 deletions

View File

@ -64,7 +64,13 @@
''; '';
default = pkgs.writers.writeJSON "secrets.json" (lib.mapAttrs default = pkgs.writers.writeJSON "secrets.json" (lib.mapAttrs
(_name: secret: { (_name: secret: {
secrets = builtins.attrNames secret.secrets; secrets = lib.mapAttrsToList
(name: secret: {
inherit name;
} // lib.optionalAttrs (secret ? groups) {
inherit (secret) groups;
})
secret.secrets;
facts = lib.mapAttrs (_: secret: secret.path) secret.facts; facts = lib.mapAttrs (_: secret: secret.path) secret.facts;
generator = secret.generator.finalScript; generator = secret.generator.finalScript;
}) })

View File

@ -108,6 +108,14 @@
''; '';
default = "${config'.clanCore.secretsDirectory}/${config'.clanCore.secretsPrefix}${config.name}"; default = "${config'.clanCore.secretsDirectory}/${config'.clanCore.secretsPrefix}${config.name}";
}; };
} // lib.optionalAttrs (config'.clanCore.secretStore == "sops") {
groups = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = config'.clanCore.sops.defaultGroups;
description = ''
Groups to decrypt the secret for. By default we always use the user's key.
'';
};
}; };
})); }));
description = '' description = ''

View File

@ -22,6 +22,14 @@ let
secrets = filterDir containsMachineOrGroups secretsDir; secrets = filterDir containsMachineOrGroups secretsDir;
in in
{ {
options = {
clanCore.sops.defaultGroups = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "admins" ];
description = "The default groups to for encryption use when no groups are specified.";
};
};
config = lib.mkIf (config.clanCore.secretStore == "sops") { config = lib.mkIf (config.clanCore.secretStore == "sops") {
clanCore.secretsDirectory = "/run/secrets"; clanCore.secretsDirectory = "/run/secrets";
clanCore.secretsPrefix = config.clanCore.machineName + "-"; clanCore.secretsPrefix = config.clanCore.machineName + "-";

View File

@ -4,6 +4,7 @@ from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import Any
from clan_cli.dirs import vm_state_dir from clan_cli.dirs import vm_state_dir
from qemu.qmp import QEMUMonitorProtocol from qemu.qmp import QEMUMonitorProtocol
@ -107,7 +108,7 @@ class Machine:
return self.deployment_info["factsModule"] return self.deployment_info["factsModule"]
@property @property
def secrets_data(self) -> dict: def secrets_data(self) -> dict[str, dict[str, Any]]:
if self.deployment_info["secretsData"]: if self.deployment_info["secretsData"]:
try: try:
return json.loads(Path(self.deployment_info["secretsData"]).read_text()) return json.loads(Path(self.deployment_info["secretsData"]).read_text())

View File

@ -17,9 +17,13 @@ def check_secrets(machine: Machine) -> bool:
missing_facts = [] missing_facts = []
for service in machine.secrets_data: for service in machine.secrets_data:
for secret in machine.secrets_data[service]["secrets"]: for secret in machine.secrets_data[service]["secrets"]:
if not secret_store.exists(service, secret): if isinstance(secret, str):
secret_name = secret
else:
secret_name = secret["name"]
if not secret_store.exists(service, secret_name):
log.info(f"Secret {secret} for service {service} is missing") log.info(f"Secret {secret} for service {service} is missing")
missing_secrets.append((service, secret)) missing_secrets.append((service, secret_name))
for fact in machine.secrets_data[service]["facts"]: for fact in machine.secrets_data[service]["facts"]:
if not fact_store.exists(service, fact): if not fact_store.exists(service, fact):

View File

@ -69,12 +69,22 @@ def generate_service_secrets(
files_to_commit = [] files_to_commit = []
# store secrets # store secrets
for secret in machine.secrets_data[service]["secrets"]: for secret in machine.secrets_data[service]["secrets"]:
secret_file = secrets_dir / secret if isinstance(secret, str):
# TODO: This is the old NixOS module, can be dropped everyone has updated.
secret_name = secret
groups = []
else:
secret_name = secret["name"]
groups = secret.get("groups", [])
secret_file = secrets_dir / secret_name
if not secret_file.is_file(): if not secret_file.is_file():
msg = f"did not generate a file for '{secret}' when running the following command:\n" msg = f"did not generate a file for '{secret_name}' when running the following command:\n"
msg += machine.secrets_data[service]["generator"] msg += machine.secrets_data[service]["generator"]
raise ClanError(msg) raise ClanError(msg)
secret_path = secret_store.set(service, secret, secret_file.read_bytes()) secret_path = secret_store.set(
service, secret_name, secret_file.read_bytes(), groups
)
if secret_path: if secret_path:
files_to_commit.append(secret_path) files_to_commit.append(secret_path)

View File

@ -10,7 +10,9 @@ class SecretStoreBase(ABC):
pass pass
@abstractmethod @abstractmethod
def set(self, service: str, name: str, value: bytes) -> Path | None: def set(
self, service: str, name: str, value: bytes, groups: list[str]
) -> Path | None:
pass pass
@abstractmethod @abstractmethod

View File

@ -12,7 +12,9 @@ class SecretStore(SecretStoreBase):
def __init__(self, machine: Machine) -> None: def __init__(self, machine: Machine) -> None:
self.machine = machine self.machine = machine
def set(self, service: str, name: str, value: bytes) -> Path | None: def set(
self, service: str, name: str, value: bytes, groups: list[str]
) -> Path | None:
subprocess.run( subprocess.run(
nix_shell( nix_shell(
["nixpkgs#pass"], ["nixpkgs#pass"],
@ -104,5 +106,10 @@ class SecretStore(SecretStoreBase):
def upload(self, output_dir: Path) -> None: def upload(self, output_dir: Path) -> None:
for service in self.machine.secrets_data: for service in self.machine.secrets_data:
for secret in self.machine.secrets_data[service]["secrets"]: for secret in self.machine.secrets_data[service]["secrets"]:
(output_dir / secret).write_bytes(self.get(service, secret)) if isinstance(secret, dict):
secret_name = secret["name"]
else:
# TODO: drop old format soon
secret_name = secret
(output_dir / secret_name).write_bytes(self.get(service, secret_name))
(output_dir / ".pass_info").write_bytes(self.generate_hash()) (output_dir / ".pass_info").write_bytes(self.generate_hash())

View File

@ -28,7 +28,9 @@ class SecretStore:
) )
add_machine(self.machine.flake_dir, self.machine.name, pub_key, False) add_machine(self.machine.flake_dir, self.machine.name, pub_key, False)
def set(self, service: str, name: str, value: bytes) -> Path | None: def set(
self, service: str, name: str, value: bytes, groups: list[str]
) -> Path | None:
path = ( path = (
sops_secrets_folder(self.machine.flake_dir) / f"{self.machine.name}-{name}" sops_secrets_folder(self.machine.flake_dir) / f"{self.machine.name}-{name}"
) )
@ -37,6 +39,7 @@ class SecretStore:
path, path,
value.decode(), value.decode(),
add_machines=[self.machine.name], add_machines=[self.machine.name],
add_groups=groups,
) )
return path return path

View File

@ -14,7 +14,9 @@ class SecretStore(SecretStoreBase):
self.dir = vm_state_dir(str(machine.flake), machine.name) / "secrets" self.dir = vm_state_dir(str(machine.flake), machine.name) / "secrets"
self.dir.mkdir(parents=True, exist_ok=True) self.dir.mkdir(parents=True, exist_ok=True)
def set(self, service: str, name: str, value: bytes) -> Path | None: def set(
self, service: str, name: str, value: bytes, groups: list[str]
) -> Path | None:
secret_file = self.dir / service / name secret_file = self.dir / service / name
secret_file.parent.mkdir(parents=True, exist_ok=True) secret_file.parent.mkdir(parents=True, exist_ok=True)
secret_file.write_bytes(value) secret_file.write_bytes(value)

View File

@ -142,7 +142,7 @@ def allow_member(
) -> None: ) -> None:
source = source_folder / name source = source_folder / name
if not source.exists(): if not source.exists():
msg = f"{name} does not exist in {source_folder}: " msg = f"Cannot encrypt {group_folder.parent.name} for '{name}' group. '{name}' group does not exist in {source_folder}: "
msg += list_directory(source_folder) msg += list_directory(source_folder)
raise ClanError(msg) raise ClanError(msg)
group_folder.mkdir(parents=True, exist_ok=True) group_folder.mkdir(parents=True, exist_ok=True)
@ -150,7 +150,7 @@ def allow_member(
if user_target.exists(): if user_target.exists():
if not user_target.is_symlink(): if not user_target.is_symlink():
raise ClanError( raise ClanError(
f"Cannot add user {name}. {user_target} exists but is not a symlink" f"Cannot add user '{name}' to {group_folder.parent.name} secret. {user_target} exists but is not a symlink"
) )
os.remove(user_target) os.remove(user_target)

View File

@ -16,6 +16,7 @@
system.stateVersion = lib.version; system.stateVersion = lib.version;
sops.age.keyFile = "__CLAN_SOPS_KEY_PATH__"; sops.age.keyFile = "__CLAN_SOPS_KEY_PATH__";
clanCore.secretsUploadDirectory = "__CLAN_SOPS_KEY_DIR__"; clanCore.secretsUploadDirectory = "__CLAN_SOPS_KEY_DIR__";
clanCore.sops.defaultGroups = [ "admins" ];
clan.virtualisation.graphics = false; clan.virtualisation.graphics = false;
clan.networking.zerotier.controller.enable = true; clan.networking.zerotier.controller.enable = true;

View File

@ -33,6 +33,17 @@ def test_generate_secret(
age_keys[0].pubkey, age_keys[0].pubkey,
] ]
) )
cli.run(
[
"--flake",
str(test_flake_with_core.path),
"secrets",
"groups",
"add-user",
"admins",
"user1",
]
)
cmd = ["--flake", str(test_flake_with_core.path), "secrets", "generate", "vm1"] cmd = ["--flake", str(test_flake_with_core.path), "secrets", "generate", "vm1"]
cli.run(cmd) cli.run(cmd)
has_secret(test_flake_with_core.path, "vm1-age.key") has_secret(test_flake_with_core.path, "vm1-age.key")

View File

@ -110,6 +110,15 @@ def test_run(
age_keys[0].pubkey, age_keys[0].pubkey,
] ]
) )
cli.run(
[
"secrets",
"groups",
"add-user",
"admins",
"user1",
]
)
cli.run(["vms", "run", "vm1"]) cli.run(["vms", "run", "vm1"])