Case Study: Access Control & Environments
This guide builds an access-control layer in user space: no framework
changes, only den.schema, policies, parametric aspects and a quirk. It is a
pattern, not a battery — copy it and adapt the data model to your fleet.
The problem
Section titled “The problem”With den’s defaults, users are declared per host:
den.hosts.x86_64-linux.igloo.users.tux = { };den.hosts.x86_64-linux.iceberg.users.tux = { };den.hosts.x86_64-linux.iceberg.users.alice = { };This stops scaling once there are more than a handful of hosts and people.
“Who can log into what” is scattered over every host declaration,
extraGroups is repeated per user per host, and nothing ties “alice is a
developer” to “developers get docker and log into dev machines”.
The pattern replaces per-host user lists with three facts, each stated once:
- what groups exist and which groups imply which,
- which groups each user is in,
- which groups each environment and host admits.
Everything else — the set of users on a host, their Unix groups — is derived.
Data model
Section titled “Data model”| Concept | Declared as | Meaning |
|---|---|---|
| Environment | acl.environments.<env> | A named slice of the fleet (prod, dev) carrying shared settings (domain, timezone) and a baseline list of login gates. |
| Group | acl.groups.<name> | A name with labels (what consumes it) and members (other groups whose members inherit this one). |
| User | acl.users.<name> | An identity plus the groups it is directly in. No host list. |
| Host gate | den.hosts.<sys>.<host>.system-access-groups | Extra login gates for one host, added to its environment’s. |
Resolution for a user U on host H in environment E:
- Closure. Expand
U.groupstransitively: ifdevelopers.memberscontainsadmins, every admin is also a developer. - Login.
Ugets an account onHiff the closure intersectsE.system-access-groups ++ H.system-access-groups. - Unix groups.
U’sextraGroupsonHare the closure filtered to groups labelledposix.
Login is opt-in: a group admits nobody unless some gate names it. That is what lets identity-only users (a web-SSO account, say) live in the same registry without ever getting a Unix account.
Labels are an open set. This guide consumes posix; an identity-provider
module can consume oauth-grant from the same graph (see
Extending).
How it maps onto den
Section titled “How it maps onto den”| Piece | den primitive |
|---|---|
environment as a scope between flake and host | den.schema.environment.isEntity + a resolve.to "environment" policy (Policies) |
| Host-to-environment membership | den.schema.host.imports adding an environment option (Schema) |
Users admitted by ACL instead of host.users | a host policy emitting resolve.to "user", with den.policies.host-to-users excluded |
| Per-user Unix groups | a parametric { host, user } aspect in den.schema.user.includes (Parametric aspects) |
| Environment settings in class modules | the environment context argument, inherited by every host scope under it |
| Host-side view of admitted users | a quirk exposed from user scope with pipe.expose (Cross-Scope Pipes) |
flowchart TB F["flake"] -- "to-environments" --> E["environment: dev"] E -- "environment-to-hosts (resolve.to host + instantiate)" --> H["host: iceberg"] H -- "acl-users: closure(user.groups) meets env gates ++ host gates?" --> U1["user: alice"] H -- "acl-users" --> U2["user: bob"] U1 -- "posixGroups: closure filtered to posix" --> G["users.users.alice.extraGroups"]
Each arrow is one policy. Only the acl-users step consults the group graph.
The groups, users and environments registries are plain lib.types
options under a user-chosen acl.* namespace. They are not den API; den
only sees the entities the policies resolve.
Worked example
Section titled “Worked example”Three files, for any flake-parts + import-tree den flake with home-manager
wired (the default template is enough). They replace
its host and user declarations; keep your own den.default for
stateVersion and hardware.
1. Schema
Section titled “1. Schema”{ lib, den, ... }:let inherit (lib) mkOption types; groupNames = mkOption { type = types.listOf types.str; default = [ ]; };
environmentType = den.lib.schema.mkInstanceType { strict = den.schema.environment.isStrict; extraModules = [ ( { name, config, ... }: { config._module.args.environment = config; options = { name = mkOption { type = types.str; default = name; }; domain = mkOption { type = types.str; }; timezone = mkOption { type = types.str; default = "UTC"; }; system-access-groups = groupNames; }; } ) ]; } den.schema.environment;
groupType = types.submodule { options = { description = mkOption { type = types.str; default = ""; }; labels = groupNames; members = groupNames; }; };
# Mirrors den's own user entity shape so batteries such as define-user # find userName, classes and aspect. userType = den.lib.schema.mkInstanceType { strict = den.schema.user.isStrict; extraModules = [ ( { name, config, ... }: { config._module.args.user = config; options = { name = mkOption { type = types.str; default = name; }; userName = mkOption { type = types.str; default = name; }; classes = mkOption { type = types.listOf types.str; default = [ "homeManager" ]; }; aspect = mkOption { type = types.raw; default = den.aspects.${name} or { }; }; groups = groupNames; }; } ) ]; } den.schema.user;in{ options.acl = { environments = mkOption { type = types.attrsOf environmentType; default = { }; }; groups = mkOption { type = types.attrsOf groupType; default = { }; }; users = mkOption { type = types.attrsOf userType; default = { }; }; };
config = { den.schema.environment.isEntity = true; den.schema.user.isEntity = true; den.schema.host.imports = [ { options.environment = mkOption { type = types.str; }; options.system-access-groups = groupNames; } ]; };}den.lib.schema.mkInstanceType { … } den.schema.<kind> builds the registry
entry type from the kind itself, so any den.schema.environment.imports /
den.schema.user.imports you add later apply to registry entries too, and
passing strict = den.schema.<kind>.isStrict closes the entries whenever
the kind is strict. A plain
types.submodule that imports the kind stays open.
2. Resolution
Section titled “2. Resolution”{ lib, den, config, ... }:let inherit (den.lib.policy) resolve instantiate; inherit (config.acl) environments groups users;
# A member of group G is also a member of every group listing G in `members`. closure = direct: map (g: g.key) ( builtins.genericClosure { startSet = map (key: { inherit key; }) direct; operator = { key }: lib.mapAttrsToList (name: _: { key = name; }) ( lib.filterAttrs (_: g: lib.elem key g.members) groups ); } );
gatesFor = environment: host: environment.system-access-groups ++ host.system-access-groups;
canLogin = environment: host: user: lib.any (g: lib.elem g (gatesFor environment host)) (closure user.groups);
posixGroups = user: lib.filter (g: lib.elem "posix" groups.${g}.labels) (closure user.groups);
allHosts = lib.concatMap lib.attrValues (lib.attrValues den.hosts);in{ # flake -> environment den.policies.to-environments = _: lib.mapAttrsToList (_: environment: resolve.to "environment" { inherit environment; }) environments;
# environment -> host den.policies.environment-to-hosts = { environment, ... }: lib.concatMap ( host: lib.optionals (host.environment == environment.name && host.intoAttr != [ ]) [ (resolve.to "host" { inherit host; }) (instantiate host) ] ) allHosts;
# host -> user, for every user the gates admit den.policies.acl-users = { host, environment, ... }: map (user: resolve.to "user" { inherit user; }) ( lib.filter (canLogin environment host) (lib.attrValues users) );
den.schema.flake.includes = [ den.policies.to-environments ]; den.schema.environment.includes = [ den.policies.environment-to-hosts ]; den.schema.host.includes = [ den.policies.acl-users ];
# The environment walk replaces den's default host and user walks. den.schema.host.excludes = [ den.policies.host-to-users ]; den.schema.flake-system.excludes = [ den.policies.system-to-os-outputs den.policies.system-to-hm-outputs ];
# Fires once per admitted user; the name keeps one entry per user and host. den.schema.user.includes = [ ( { host, user }: { name = "acl-posix-groups/${user.userName}@${host.name}"; nixos = { users.users.${user.userName}.extraGroups = posixGroups user; users.groups = lib.genAttrs (posixGroups user) (_: { }); }; } ) ];}acl-users destructures environment although it runs at host scope: a
host resolved under an environment scope inherits that binding, so the policy
sees the host’s own environment without looking it up.
3. Data
Section titled “3. Data”{ den, ... }:{ acl.environments = { prod = { domain = "example.com"; system-access-groups = [ "admins" ]; }; dev = { domain = "dev.example.com"; timezone = "Europe/Berlin"; system-access-groups = [ "developers" ]; }; };
acl.groups = { admins.description = "Log in anywhere"; developers = { labels = [ "posix" ]; members = [ "admins" ]; # every admin is a developer }; wheel = { labels = [ "posix" ]; members = [ "admins" ]; }; docker = { labels = [ "posix" ]; members = [ "developers" ]; }; grafana-viewers.members = [ "developers" ]; };
acl.users = { tux.groups = [ "admins" ]; alice.groups = [ "developers" ]; bob.groups = [ "grafana-viewers" ]; };
den.hosts.x86_64-linux = { igloo.environment = "prod"; iceberg = { environment = "dev"; system-access-groups = [ "grafana-viewers" ]; # host-only gate }; };
den.aspects.environment-defaults.nixos = { environment, ... }: { networking.domain = environment.domain; time.timeZone = environment.timezone; };
den.default.includes = [ den.batteries.define-user den.batteries.hostname den.aspects.environment-defaults ];}The group graph this data declares. An arrow from A to B means members of A
are also members of B; green groups are labelled posix:
flowchart BT admins --> developers admins --> wheel developers --> docker developers --> grafana-viewers tux(["tux"]) -.-> admins alice(["alice"]) -.-> developers bob(["bob"]) -.-> grafana-viewers classDef posix fill:#dfd,stroke:#393 class developers,wheel,docker posix
Result:
| Host | Gates | Users | extraGroups |
|---|---|---|---|
igloo (prod) | admins | tux | tux: developers wheel docker |
iceberg (dev) | developers, grafana-viewers | alice, bob, tux | alice: developers docker; bob: none; tux: developers wheel docker |
tux reaches iceberg through admins → developers. bob is admitted only
because iceberg adds grafana-viewers as a host gate; on igloo he has no
account. igloo gets time.timeZone = "UTC" and networking.domain = "example.com" from its environment; iceberg gets Berlin and
dev.example.com.
Reading the environment in aspects
Section titled “Reading the environment in aspects”Any class module or parametric aspect under a host can take environment
as a context argument, exactly like host or user:
den.aspects.acme.nixos = { environment, ... }: { security.acme.certs.${environment.domain}.domain = "*.${environment.domain}"; };Quirk emitters can take environment too, so records collected across hosts can carry their
environment:
den.quirks.monitoring-targets.description = "Scrape targets";
den.aspects.node-exporter.monitoring-targets = { environment, host, ... }: { inherit (host) name; env = environment.name; };Hosts in one environment are siblings under its scope, so
pipe.collect gathers only same-environment peers, and pipe.collectAll
reaches across environments. See Quirks & Pipes.
Extending
Section titled “Extending”Tell host aspects who is admitted
Section titled “Tell host aspects who is admitted”Some host-level modules need the list of users resolved onto the host
(hardware.openrazer.users, a group’s member list, authorized_keys for a
shared account). Emit one record per user and expose it upward:
{ lib, den, ... }:let inherit (den.lib.policy) pipe;in{ den.quirks.login-users.description = "Users admitted to a host by the ACL";
den.policies.expose-login-users = { user, ... }: [ (pipe.from "login-users" [ pipe.expose ]) ];
den.schema.user.includes = [ den.policies.expose-login-users ( { host, user }: { name = "login-users/${user.userName}@${host.name}"; login-users = { inherit (user) name groups; }; } ) ];
den.aspects.wireshark.nixos = { login-users, lib, ... }: { programs.wireshark.enable = true; users.users = lib.genAttrs (map (u: u.name) login-users) (_: { extraGroups = [ "wireshark" ]; }); };}A host that includes wireshark puts exactly its admitted users in the
wireshark group, and nobody else.
Other consumers of the same graph
Section titled “Other consumers of the same graph”Groups with other labels are read directly from config.acl at module level,
outside the pipeline. An identity-provider module, for instance, provisions
every user (admitted anywhere or not) and every group labelled
oauth-grant, reusing closure from the policies file (move it to a shared
let or a _module.args value):
oauthGroups = lib.filterAttrs (_: g: lib.elem "oauth-grant" g.labels) config.acl.groups;membersOf = group: lib.attrNames (lib.filterAttrs (_: u: lib.elem group (closure u.groups)) config.acl.users);Identity-only users are registry entries whose closure reaches no gate. They exist for the identity provider and never resolve onto a host.
More gates and grants
Section titled “More gates and grants”- Role defaults. Give
system-access-groupsa default computed from another host option (if role == "server" then [ "server-access" ] else …) instead of setting it on every host. - Grants separate from gates. If “who is allowed into prod” and “what a
host accepts” are owned by different people, add a second list (for example
acl.grants.<env>) and admit users whose closure hitsgrants ∩ gates. - A fleet scope. Insert a
fleetentity between flake and environments (resolve.to "fleet", as intemplates/fleet-demo) when you need one place to hang fleet-wide context or pipe collection.
See also
Section titled “See also”- Fleets & Multi-Host — the scope tree the policies build
- Template: Fleet Demo — the same topology without ACLs
- Policies and policy reference —
resolve.to,instantiate,pipe.* - Schema reference —
den.schema.<kind>.imports,includes,excludes,isEntity - Cross-Scope Pipes —
pipe.expose,pipe.collect,pipe.collectAll