Dynamic User Assignment
The problem
Section titled “The problem”You have multiple hosts across multiple environments. You want:
alice(admin) on all prod and staging hostsbob(deploy) on staging hosts only
The naive approach — manually listing users on every host — repeats user data and scatters policy across the DOM.
Nest’s approach: define users once in a registry, write rules that synthesize them as DOM children based on environment.
User entity trait
Section titled “User entity trait”The user trait is an entity with two class outputs:
nest.trait.user.class = { # Wrap nixos config contributions in a nixos namespace nixos = select: cfg: { nixos = cfg; };
# Contribute to parent host's nixos.users.users user = select: cfg: { nixos.users.users.${select.node.name} = cfg; };};When a user child is processed by collectChildFrags, its user class output flows up into the parent host’s config — this is how nixos.users.users.alice = { … } appears in the host’s nixosSystem.
Role marker traits
Section titled “Role marker traits”nest.trait.admin = { }; # marker only — no outputnest.trait.deploy = { }; # marker only — no outputUser registry in the DOM
Section titled “User registry in the DOM”Users are defined as DOM nodes with only marker traits. They have no class, so they produce no nixosConfiguration. They are fully visible to select:
nest.users.alice = { is = [ nest.admin ]; sshKeys = [ "ssh-ed25519 AAAAC3… alice@workstation" ];};nest.users.bob = { is = [ nest.deploy ]; sshKeys = [ "ssh-ed25519 AAAAC3… bob@laptop" ];};Environment namespaces
Section titled “Environment namespaces”nest.prod.env = "prod";nest.staging.env = "staging";
nest.prod.lb-prod = { is = [ nest.host nest.lb ]; … };nest.prod.web-1 = { is = [ nest.host nest.web ]; … };nest.staging.web-s = { is = [ nest.host nest.web ]; … };Every host in prod inherits env = "prod". Every host in staging inherits env = "staging".
Rule synth: inject users as children
Section titled “Rule synth: inject users as children”Two rules, each targeting a different env, synthesize the appropriate users as virtual DOM children:
nest.rules = [
# prod: admin users only { is = [ nest.host (nest.attrs { env = "prod"; }) ]; synth = { select, ... }: { node.children = map (u: { inherit (u) name sshKeys; is = [ nest.user nest.admin ]; }) (select nest.admin); }; }
# staging: admin + deploy users { is = [ nest.host (nest.attrs { env = "staging"; }) ]; synth = { select, ... }: { node.children = map (u: { inherit (u) name sshKeys; is = [ nest.user nest.admin ]; }) (select nest.admin) ++ map (u: { inherit (u) name sshKeys; is = [ nest.user nest.deploy ]; }) (select nest.deploy); }; }
];select nest.admin finds alice (she has nest.admin in her is). The synth creates a copy of alice as a virtual child of the host, with is = [nest.user nest.admin].
User rules: apply config to synthesized children
Section titled “User rules: apply config to synthesized children”Once the virtual children exist, standard rules apply:
nest.rules = [ # …synth rules above…
# Base user config — fires on all nest.user nodes { is = nest.user; user = { user, ... }: { isNormalUser = true; openssh.authorizedKeys.keys = user.sshKeys; }; }
# Admin role — adds wheel group { is = nest.admin; user.extraGroups = [ "wheel" ]; }
# Sudo — on hosts in envs that have admin users { is = [ nest.host (nest.or [ (nest.attrs { env = "prod"; }) (nest.attrs { env = "staging"; }) ])]; nixos.security.sudo.wheelNeedsPassword = false; }];How it flows
Section titled “How it flows”For lb-prod (env = “prod”):
- Synth rule matches
[nest.host (nest.attrs {env="prod"})]→ creates alice-child withis = [nest.user nest.admin] - Alice-child re-annotated:
userrule fires →__mergedCfg.user = { isNormalUser=true; openssh… },adminrule fires →__mergedCfg.user.extraGroups = ["wheel"] collectChildFrags lb-prodprocesses alice-child →classFns.user select cfg={ nixos.users.users.alice = { isNormalUser=true; extraGroups=["wheel"]; openssh… } }- Deep-merged into lb-prod’s nixos config
For web-staging (env = “staging”):
- Staging synth rule fires → creates alice-child + bob-child
- bob-child has
is = [nest.user nest.deploy]— no wheel group - Both contribute to web-staging’s
nixos.users.users
Adding a new user
Section titled “Adding a new user”-
Add to registry:
nest.users.carol = {is = [ nest.admin ];sshKeys = [ "ssh-ed25519 …" ];}; -
Done. Every prod and staging host gets carol automatically. No rule changes.
Why rule synth vs trait synth
Section titled “Why rule synth vs trait synth”This pattern uses rule synth (not trait.host.synth) so the user injection is policy-driven: different rules fire on different environments. If you moved the logic to trait synth, you’d need to put the env-branching inside the single synth function — less composable, harder to override per environment.
Rule synth lets you write one rule per policy, keeping each rule focused and independently readable.