diff --git a/README.md b/README.md index bb1a1bb..7084008 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,33 @@ The home config is also exposed for use beyond these hosts: (`inputs..homeModules.default`). Consumers must supply the module args these expect: `inputs` always, `identity` for git/desktop, `portable` for sway. +## Directory authentication (SSSD → Authentik LDAP) + +Every NixOS host authenticates users against the Authentik LDAP outpost via +SSSD, implemented in [`modules/sssd.nix`](./modules/sssd.nix) and enabled by +default through the `services.authentikLdap.enable` option (added to +`baseModules`). The **EDaaS** WSL box opts out +(`services.authentikLdap.enable = false`) as a work-managed environment; the +macOS host is unaffected (SSSD is Linux-only). + +- Connects over LDAPS to `ldap.lyrapup.pet:636`, search base + `dc=ldap,dc=goauthentik,dc=io`, binding as + `cn=sssd-bind,ou=users,dc=ldap,dc=goauthentik,dc=io`. +- The schema mappings match Authentik's non-standard object classes + (`goauthentik.io/ldap/user`, `goauthentik.io/ldap/group`) over the POSIX + attributes (`uid`, `uidNumber`, `gidNumber`, `homeDirectory`). +- Home directories are created on first login (`pam_mkhomedir`). + +### Secrets (agenix) + +The LDAP bind credential is an [agenix](https://github.com/ryantm/agenix) +secret, decrypted at activation with each host's SSH host key. The decrypted +plaintext is a full `sssd.conf` drop-in delivered to +`/etc/sssd/conf.d/01-ldap-authtok.conf`, so the password never enters the Nix +store. Owner setup (host recipient keys, encrypting the bind password, DNS for +`ldap.lyrapup.pet`, rebuild) is documented in +[`secrets/README.md`](./secrets/README.md). + ## Applying ```sh diff --git a/flake.nix b/flake.nix index 87bc70d..0d4a557 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,15 @@ url = "github:cachix/git-hooks.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; + # agenix: age-encrypted secrets, decrypted at activation with each host's + # SSH host key. Provides the SSSD LDAP bind credential (secrets/, see + # modules/sssd.nix). The darwin module is intentionally unused (SSSD is + # Linux-only). + agenix = { + url = "github:ryantm/agenix"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.home-manager.follows = "home-manager"; + }; # Declarative Neovim (the editor; see home/editor.nix). Release # branch matched to the pinned nixpkgs (26.05); follows our nixpkgs to keep a # single nixpkgs in the closure. editor.nix sets programs.nixvim.nixpkgs.source @@ -123,7 +132,9 @@ ./modules/users.nix ./modules/common-nixos.nix ./modules/features.nix + ./modules/sssd.nix commonModule + inputs.agenix.nixosModules.default home-manager.nixosModules.home-manager { home-manager.useGlobalPkgs = true; diff --git a/hosts/EDaaS/configuration.nix b/hosts/EDaaS/configuration.nix index 89da4eb..bcb5ab9 100644 --- a/hosts/EDaaS/configuration.nix +++ b/hosts/EDaaS/configuration.nix @@ -62,6 +62,11 @@ features.swayDesktop.enable = false; + # Opt out of fleet-wide SSSD/Authentik LDAP auth: this is a work-managed WSL + # box, not part of the personal directory. Every other NixOS host inherits the + # default-true from modules/sssd.nix. + services.authentikLdap.enable = false; + # NOTE: this user's systemd --user lingering -- so the home-manager renovate # timer fires without an open login session -- is enabled from the host table # in flake.nix (users.emmathorpe.linger = true) and applied by diff --git a/modules/sssd.nix b/modules/sssd.nix new file mode 100644 index 0000000..9a49acb --- /dev/null +++ b/modules/sssd.nix @@ -0,0 +1,130 @@ +# Authentik LDAP authentication for NixOS hosts. +# +# Wires SSSD (System Security Services Daemon) to the Authentik LDAP outpost so +# every Linux host authenticates users against the same directory that backs the +# SSO stack. Enabled by default on every NixOS host via baseModules; the EDaaS +# WSL box opts out (services.authentikLdap.enable = false) because it is a +# work-managed Windows-hosted environment. +# +# The Authentik LDAP provider exposes NON-standard object classes/attributes +# (goauthentik.io/ldap/user, goauthentik.io/ldap/group) alongside the POSIX +# attributes (uid, uidNumber, gidNumber, homeDirectory), so the schema mappings +# below are explicit rather than relying on an RFC2307 default. +# +# The bind password is NOT inlined: services.sssd.config renders to the world- +# readable Nix store, so the credential is delivered out-of-band by agenix as an +# sssd.conf drop-in under /etc/sssd/conf.d/ (SSSD merges conf.d/*.conf after the +# main file). See secrets/README.md. +{ + config, + lib, + ... +}: +let + cfg = config.services.authentikLdap; + + # Directory coordinates for the Authentik LDAP provider. + ldapUri = "ldaps://ldap.lyrapup.pet:636"; + searchBase = "dc=ldap,dc=goauthentik,dc=io"; + bindDn = "cn=sssd-bind,ou=users,dc=ldap,dc=goauthentik,dc=io"; +in +{ + options.services.authentikLdap.enable = + lib.mkEnableOption "SSSD authentication against the Authentik LDAP outpost" + // { + default = true; + }; + + config = lib.mkIf cfg.enable { + services.sssd = { + enable = true; + + # Non-secret sssd.conf. The bind password is injected separately via the + # agenix conf.d drop-in (ldap_default_authtok lives there, not here) to + # keep it out of the Nix store. + config = '' + [sssd] + config_file_version = 2 + services = nss, pam + domains = default + + [nss] + # Do not walk the whole directory for `getent passwd` etc. + filter_users = root + filter_groups = root + + [pam] + + [domain/default] + # --- Providers -------------------------------------------------------- + id_provider = ldap + auth_provider = ldap + chpass_provider = none + access_provider = permit + + # --- Connection ------------------------------------------------------- + ldap_uri = ${ldapUri} + ldap_search_base = ${searchBase} + ldap_default_bind_dn = ${bindDn} + ldap_default_authtok_type = password + # ldap_default_authtok is supplied by the agenix drop-in in conf.d. + + # --- TLS (LDAPS on 636; no StartTLS) --------------------------------- + ldap_id_use_start_tls = false + ldap_tls_reqcert = demand + + # --- Schema: Authentik LDAP provider --------------------------------- + # Authentik returns DN-valued group membership (member/memberOf), so + # rfc2307bis (not rfc2307) is the correct base schema. + ldap_schema = rfc2307bis + + # Users: goauthentik.io/ldap/user, keyed by uid; POSIX attrs are + # standard names (uidNumber/gidNumber/homeDirectory). + ldap_user_object_class = goauthentik.io/ldap/user + ldap_user_name = uid + ldap_user_uid_number = uidNumber + ldap_user_gid_number = gidNumber + ldap_user_home_directory = homeDirectory + ldap_user_gecos = displayName + ldap_user_shell = loginShell + + # Groups: goauthentik.io/ldap/group, keyed by cn. + ldap_group_object_class = goauthentik.io/ldap/group + ldap_group_name = cn + ldap_group_gid_number = gidNumber + ldap_group_member = member + + # --- Behaviour -------------------------------------------------------- + cache_credentials = true + enumerate = false + ''; + }; + + # agenix delivers the bind password as an sssd.conf drop-in. The decrypted + # plaintext IS a valid conf.d snippet: + # + # [domain/default] + # ldap_default_authtok = + # + # SSSD requires conf.d files to be root-owned and 0600 or it ignores them. + age.secrets.ldap-bind = { + file = ../secrets/ldap-bind.age; + path = "/etc/sssd/conf.d/01-ldap-authtok.conf"; + owner = "root"; + group = "root"; + mode = "0600"; + }; + + # Restart SSSD when the credential drop-in changes. agenix writes secrets in + # a system activation script that runs before systemd (re)starts services on + # a `switch`, so the file is present by the time sssd starts; the trigger + # picks up rotations of the bind password. + systemd.services.sssd.restartTriggers = [ config.age.secrets.ldap-bind.path ]; + + # Create home directories on first login for LDAP users (they have no + # locally-provisioned home). NixOS wires nss + the SSSD PAM stack when + # services.sssd.enable is true; mkHomeDir adds pam_mkhomedir to it. + security.pam.services.login.makeHomeDir = true; + security.pam.services.sshd.makeHomeDir = true; + }; +} diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..2bfccb5 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,92 @@ +# Secrets (agenix) + +Encrypted secrets for the fleet, managed with [agenix](https://github.com/ryantm/agenix). + +Each secret is an age-encrypted file (`*.age`) encrypted to a set of recipient +public keys declared in [`secrets.nix`](./secrets.nix). A host decrypts its +secrets at activation using its SSH **host** key +(`/etc/ssh/ssh_host_ed25519_key`), so every host that must read a secret has to +be listed as a recipient for it. + +`secrets.nix` is read only by the `agenix` CLI. It is never imported into the +NixOS evaluation. + +## Secrets in this repo + +| File | Purpose | Recipients | +| --------------- | ----------------------------------------------------------------------- | ----------------------------------- | +| `ldap-bind.age` | SSSD → Authentik LDAP bind credential, as an `sssd.conf` drop-in snippet | all SSSD-enabled hosts (not EDaaS) | + +Consumed by [`modules/sssd.nix`](../modules/sssd.nix) via +`age.secrets.ldap-bind.path`, which places the decrypted snippet at +`/etc/sssd/conf.d/01-ldap-authtok.conf`. + +> **`ldap-bind.age` is not committed yet.** Only `ldap-bind.age.PLACEHOLDER` +> ships in this change (real host recipient keys and the real password were not +> available when it was written). Follow the steps below to create the real +> secret, then delete the `.PLACEHOLDER`. + +## Owner setup checklist + +Run these once (per new host or when the bind password rotates): + +### 1. Collect host recipient keys + +On each SSSD-enabled host (all Linux hosts **except** EDaaS): + +```sh +cat /etc/ssh/ssh_host_ed25519_key.pub +``` + +Paste each value into the matching placeholder in `secrets.nix`, replacing the +`AAAA_PLACEHOLDER_REPLACE_ME_*` strings. (Optionally uncomment and set `admin` +to an operator user key so the secret can be edited off-host.) + +### 2. Encrypt the bind password + +The plaintext must be a **full sssd.conf drop-in snippet**, because SSSD cannot +read `ldap_default_authtok` from a separate file — it only merges `conf.d/*.conf`. +The content is exactly: + +```ini +[domain/default] +ldap_default_authtok = +``` + +Use the password of the `sssd-bind` (Terraform: `sssd-bind`) Authentik LDAP +service account. Then, from the repo root: + +```sh +# Requires the agenix CLI: `nix run github:ryantm/agenix -- -e secrets/ldap-bind.age` +cd secrets +agenix -e ldap-bind.age +``` + +An `$EDITOR` opens; paste the two-line snippet above, save, quit. agenix writes +the encrypted `ldap-bind.age`. Commit it and delete `ldap-bind.age.PLACEHOLDER`. + +### 3. Rekey after changing recipients + +If you add/remove hosts in `secrets.nix`, re-encrypt every secret to the new +recipient set: + +```sh +cd secrets +agenix -r +``` + +### 4. DNS + +`ldap.lyrapup.pet` must resolve to the Authentik LDAP outpost and serve LDAPS on +port 636 with a certificate the hosts trust (`ldap_tls_reqcert = demand`). If the +cert is not from a system-trusted CA, add it to the hosts' trust store +(`security.pki.certificateFiles`) or relax `ldap_tls_reqcert` in +`modules/sssd.nix`. + +### 5. Rebuild + +```sh +sudo nixos-rebuild switch --flake .# +``` + +Verify with `getent passwd ` and `id `. diff --git a/secrets/ldap-bind.age.PLACEHOLDER b/secrets/ldap-bind.age.PLACEHOLDER new file mode 100644 index 0000000..19d2870 --- /dev/null +++ b/secrets/ldap-bind.age.PLACEHOLDER @@ -0,0 +1,21 @@ +THIS IS A PLACEHOLDER, NOT A REAL AGE SECRET. + +The real secrets/ldap-bind.age is produced by the repo owner with `agenix -e` +(see secrets/README.md) and is a binary age-encrypted blob. It is intentionally +NOT committed here because: + * the real host age recipients are not available to the author of this change + (they are each host's /etc/ssh/ssh_host_ed25519_key.pub), and + * fabricating an encrypted blob or fake host keys would be misleading. + +Committing this file as `ldap-bind.age` would let modules/sssd.nix reference +`../secrets/ldap-bind.age` and evaluate, but SSSD would fail to decrypt it at +runtime. Do ONE of the following before deploying: + + 1. Preferred: generate the real secret (secrets/README.md), commit it as + secrets/ldap-bind.age, and delete this .PLACEHOLDER file. + +The decrypted plaintext must be a valid sssd.conf drop-in (NOT the bare +password): + + [domain/default] + ldap_default_authtok = diff --git a/secrets/secrets.nix b/secrets/secrets.nix new file mode 100644 index 0000000..0cf6b73 --- /dev/null +++ b/secrets/secrets.nix @@ -0,0 +1,15 @@ +let + lyrathorpe-mbp = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_mbp"; + lyrathorpe-t400 = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_t400"; + lyrathorpe-macpro31 = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_macpro31"; + lyrathorpe-rpi5 = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_rpi5"; + sssdHosts = [ + lyrathorpe-mbp + lyrathorpe-t400 + lyrathorpe-macpro31 + lyrathorpe-rpi5 + ]; +in +{ + "ldap-bind.age".publicKeys = sssdHosts; +}