Date: 2026-05-12 Status: Draft
Replace the fleet demo's inline users.deploy = { } host definitions with a standalone user registry (den.users.registry) and fleet-level access mappings (fleet.user-access). Users are defined once with extended schema (email, groups, ssh-keys), then policies resolve them onto hosts based on environment or host-level group membership.
The current fleet demo shows a single deploy user hardcoded on every host. This doesn't demonstrate:
- Centralized user management with rich metadata
- Policy-driven user assignment by environment or host
- Group-based selection (admin vs deploy roles)
- SSH key provisioning scoped to granted hosts only
A standalone top-level option den.users.registry — not namespaced under fleet. Each entry carries identity metadata:
den.users.registry = {
alice = {
email = "alice@example.com";
groups = [ "admin" ];
ssh-keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG... alice@workstation" ];
};
bob = {
email = "bob@example.com";
groups = [ "deploy" ];
ssh-keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG... bob@laptop" ];
};
};The registry type is a lib.types.submodule that imports den.schema.user, mirroring how environmentType imports den.schema.environment in environments.nix. This ensures registry entries are proper user entities with userName, classes, aspect, etc. The extended options (email, groups, ssh-keys) are added via the schema extension below.
Users are promoted to real entities (den.schema.user.isEntity = true), same as environments. This means they appear in the scope tree as named entities and policies use resolve.to "user" rather than resolve.shared.
The user schema is extended via den.schema.user.imports, same pattern as the host schema extension in environments.nix:
den.schema.user.isEntity = true;
extendUserSchema = { ... }: {
options.email = lib.mkOption {
type = lib.types.str;
default = "";
description = "User email address";
};
options.groups = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Group memberships for access policy selection";
};
options.ssh-keys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "SSH public keys for authorized_keys";
};
};
den.schema.user.imports = [ extendUserSchema ];The policies filter the registry by group membership and resolve matching entries directly:
# matchedNames is the filtered list of usernames from the registry
map (name: resolve.to "user" { user = config.den.users.registry.${name}; }) matchedNamesAspects like ssh-keys can then read user.ssh-keys from the user entity in scope.
A fleet.user-access option with two sub-attrs:
fleet.user-access = {
by-environment = {
staging = { groups = [ "admin" "deploy" ]; };
prod = { groups = [ "admin" ]; };
};
by-host = {
# Available but unused in demo.
# lb-prod = { groups = [ "admin" ]; };
};
};Type: lib.types.attrsOf (lib.types.submodule { options.groups = lib.mkOption { type = lib.types.listOf lib.types.str; default = []; }; }) for both by-environment and by-host.
Two new policies in modules/policies/fleet.nix replace the old inline user definitions. These bypass the default host-to-users core policy — the demo explicitly shows policy-driven user resolution.
env-users — fires at host scope via den.schema.host.includes. Looks up config.fleet.user-access.by-environment.${host.environment} to get the granted groups, then filters config.den.users.registry for entries whose groups intersect. For each matching registry entry, emits resolve.to "user" { user = registryEntry; } — the registry entry is the user entity, carrying name, email, groups, and ssh-keys directly from the registry.
host-users — fires at host scope via den.schema.host.includes. Looks up config.fleet.user-access.by-host.${host.name}, same registry filter + resolve.
Both policies require { host, config, ... }: destructuring.
A new ssh-keys aspect replaces the old deploy aspect:
den.aspects.ssh-keys = {
nixos = { user, ... }: {
users.users.${user.userName}.openssh.authorizedKeys.keys = user.ssh-keys;
};
};Included via den.default.includes so it fires for every user scope. Since users only exist on hosts where policies granted access, keys are provisioned only where appropriate.
| User | Groups | Access via | Hosts |
|---|---|---|---|
| alice | admin |
by-environment.{prod,staging} |
all hosts (admin on both envs) |
| bob | deploy |
by-environment.staging |
web-staging |
| File | Action |
|---|---|
modules/users.nix |
New — registry option, registry type, user schema extension, access mapping option |
modules/aspects/users/ssh-keys.nix |
New — SSH authorized keys aspect (replaces deploy.nix) |
modules/policies/fleet.nix |
Edit — add env-users and host-users policies, append to den.schema.host.includes, exclude den.policies.host-to-users from host schema |
modules/den.nix |
Edit — remove users.deploy = { } from all hosts, add ssh-keys aspect to den.default.includes |
modules/aspects/users/deploy.nix |
Delete |
flake
+-- fleet
+-- environment:prod
| +-- host:lb-prod
| | +-- user:alice (via prod/admin)
| +-- host:web-prod-1
| | +-- user:alice (via prod/admin)
| +-- host:web-prod-2
| +-- user:alice (via prod/admin)
+-- environment:staging
+-- host:web-staging
+-- user:alice (via staging/admin)
+-- user:bob (via staging/deploy)