Skip to content

Dynamic User Assignment

You have multiple hosts across multiple environments. You want:

  • alice (admin) on all prod and staging hosts
  • bob (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.

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.

nest.trait.admin = { }; # marker only — no output
nest.trait.deploy = { }; # marker only — no output

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" ];
};
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".

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;
}
];

For lb-prod (env = “prod”):

  1. Synth rule matches [nest.host (nest.attrs {env="prod"})] → creates alice-child with is = [nest.user nest.admin]
  2. Alice-child re-annotated: user rule fires → __mergedCfg.user = { isNormalUser=true; openssh… }, admin rule fires → __mergedCfg.user.extraGroups = ["wheel"]
  3. collectChildFrags lb-prod processes alice-child → classFns.user select cfg = { nixos.users.users.alice = { isNormalUser=true; extraGroups=["wheel"]; openssh… } }
  4. 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
  1. Add to registry:

    nest.users.carol = {
    is = [ nest.admin ];
    sshKeys = [ "ssh-ed25519 …" ];
    };
  2. Done. Every prod and staging host gets carol automatically. No rule changes.

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.

Contribute Community Sponsor