kanidm: the effector ports off lldap — service accounts, one api-token, no root #5

Closed
opened 2026-08-11 16:07:21 +00:00 by agent.teyla · 1 comment
Collaborator

Stable ID: VE-KANIDM-PORT
Source: owner ruling, 2026-08-11 (in-session, HITL) — filed retroactively after the work, per AGENTS.md § Tickets, and when one is required
Linked: lar.ad/flake-ops#51 (the cutover that stranded us), #72 (personas are kanidm service accounts), #91 (what flake-ops declares for this service), #97 (the off-repo spike this lifts from)

The problem

Every directory line in this repo addressed lldap, which left the fleet on 2026-08-10 (flake-ops#51; rollback material retired by #69 the same day; the lldap/* sops keys deleted at phase 5 on 2026-08-11). src/lldap.rs, services.vedanta.lldap.url, VEDANTA_LLDAP_* and the try-code-in-production-like-a-gigachad recipe all named a daemon that no longer exists — the recipe's /run/secrets/lldap/admin_password was already a deleted path, so it was broken rather than merely stale.

The ruling: how Vedanta reaches kanidm

Vedanta holds its own kanidm identity — service account sa-vedanta, one read-write api-token, delivered through DOTENV_FILE.

The alternative on the table was the operator path vedanta-spike/mint.nu uses: ssh to heimdallr as root and kanidmd recover-account idm_admin. Refused, and worth keeping written down so it is not re-litigated:

  • recovering idm_admin at will is admin — it is not the narrow grant it appears to be;
  • it forces this unit to run as root, discarding module.nix's NoNewPrivileges and ProtectSystem = "strict";
  • a token's rights can be read out of the directory; root's cannot.

Rights are granted by group membership at bootstrap and deliberately not declared in the flake (owner: "no admin in the flake"). A privileged helper standing behind Vedanta was also refused: Vedanta IS the privileged component.

What the live instance taught us

Probed against kanidm 1.11 on heimdallr before any Rust was written — the same introspect-first standard the lldap effector was held to. None of this is in the docs and some of it is actively misleading:

  • kanidm refuses + in a name400 {"schemaviolation":{"invalidattributesyntax":"name"}}, both prefixes — but accepts it in mail. So a+/sa+ stays a JWT sub contract, the directory entity is a-freki/sa-librefang, the plus-addressed mailbox survives intact, and the two are joined by UUID. This is why the spike names its accounts sa-<persona> — a workaround nobody had recorded as one.
  • A -w api-token in idm_service_account_admins can create a service account (200, body null) and write a group's members (200). That is the premise of the whole design, and it is true.
  • A person cannot hold an api-token500 MissingClass("service_account"). Personas-as-persons would mean a standing password each; provision.persons could have declared them, but it cannot declare credentials. into-person is deprecated migration cruft.
  • Deleting an account in a high-privilege group is refused 403 until it is removed from that group first.
  • kanidmd recover-account has no -o json; the machine-parsable interface is kanidmd scripting recover-account, whose field is output, not password. The CLI flag is -w/--readwrite, not --rw — with the wrong flag the CLI returns empty stdout rather than a usage error, so the failure reads as "no token".

What changed

  • src/kanidm.rs replaces src/lldap.rs (deleted). Bearer api-token; POST /v1/service_account (name + displayname + mail in one call), read back the UUID, POST /v1/group/<g>/_attr/member. GET /v1/self for reconcile, which now names the SPN it was green as.
  • src/main.rsmint() maps principal → directory name, creates, then joins the roster group. Partial success is reported, not rolled back: if the group join fails the account exists, the UUID is already logged, and the error says exactly that. Nothing here grows a revoke path.
  • Cargo.tomlldap3 out; reqwest gains TLS. The feature is rustls, not the pre-0.13 rustls-tls spelling, and rustls rather than OpenSSL so compiled-in roots cannot fail under ProtectSystem = "strict".
  • module.nixlldap.urlkanidm.url (the public origin, not kanidm's self-signed loopback :8443) plus kanidm.group. Hardening untouched.
  • justfile — the gigachad recipe passes one bearer token instead of a bind DN and a deleted sops path.
  • CONTEXT.md / AGENTS.md — the hazard section becomes a landed port; the "bind account over-grant" open question becomes "sa-vedanta's rights are sufficient, not minimal."

just check green (3 tests, up from 1), nix build .#default green. Note for the next contributor: src = self sees tracked files only, so a new module must be git added before nix build can see it, while cargo in the devShell compiles it happily.

Not done here

  • sa-vedanta does not exist yet — nothing has minted end to end. Blocked on the flake-ops bootstrap act.
  • The roster group agents is undeployed and will be wiped by overwriteMembers once deployed. Both are flake-ops'.
  • No credential is set on a minted account, by design and unchanged.
Stable ID: VE-KANIDM-PORT Source: owner ruling, 2026-08-11 (in-session, HITL) — filed retroactively after the work, per AGENTS.md § *Tickets, and when one is required* Linked: lar.ad/flake-ops#51 (the cutover that stranded us), #72 (personas are kanidm service accounts), #91 (what flake-ops declares for this service), #97 (the off-repo spike this lifts from) ## The problem Every directory line in this repo addressed **lldap**, which left the fleet on 2026-08-10 (`flake-ops#51`; rollback material retired by #69 the same day; the `lldap/*` sops keys deleted at phase 5 on 2026-08-11). `src/lldap.rs`, `services.vedanta.lldap.url`, `VEDANTA_LLDAP_*` and the `try-code-in-production-like-a-gigachad` recipe all named a daemon that no longer exists — the recipe's `/run/secrets/lldap/admin_password` was already a deleted path, so it was broken rather than merely stale. ## The ruling: how Vedanta reaches kanidm **Vedanta holds its own kanidm identity** — service account `sa-vedanta`, one read-write api-token, delivered through `DOTENV_FILE`. The alternative on the table was the operator path `vedanta-spike/mint.nu` uses: ssh to heimdallr as root and `kanidmd recover-account idm_admin`. Refused, and worth keeping written down so it is not re-litigated: - recovering `idm_admin` at will **is** admin — it is not the narrow grant it appears to be; - it forces this unit to run as root, discarding `module.nix`'s `NoNewPrivileges` and `ProtectSystem = "strict"`; - a token's rights can be read out of the directory; root's cannot. Rights are granted by group membership at bootstrap and deliberately **not declared in the flake** (owner: *"no admin in the flake"*). A privileged helper standing behind Vedanta was also refused: **Vedanta IS the privileged component**. ## What the live instance taught us Probed against kanidm 1.11 on heimdallr before any Rust was written — the same introspect-first standard the lldap effector was held to. None of this is in the docs and some of it is actively misleading: - **kanidm refuses `+` in a `name`** — `400 {"schemaviolation":{"invalidattributesyntax":"name"}}`, both prefixes — **but accepts it in `mail`**. So `a+`/`sa+` stays a **JWT `sub` contract**, the directory entity is `a-freki`/`sa-librefang`, the plus-addressed mailbox survives intact, and the two are joined by **UUID**. This is why the spike names its accounts `sa-<persona>` — a workaround nobody had recorded as one. - **A `-w` api-token in `idm_service_account_admins` can** create a service account (`200`, body `null`) and write a group's members (`200`). That is the premise of the whole design, and it is true. - **A person cannot hold an api-token** — `500 MissingClass("service_account")`. Personas-as-persons would mean a standing password each; `provision.persons` could have declared them, but it cannot declare credentials. `into-person` is deprecated migration cruft. - **Deleting an account in a high-privilege group is refused `403`** until it is removed from that group first. - `kanidmd recover-account` has **no `-o json`**; the machine-parsable interface is `kanidmd scripting recover-account`, whose field is `output`, not `password`. The CLI flag is `-w`/`--readwrite`, **not `--rw`** — with the wrong flag the CLI returns empty stdout rather than a usage error, so the failure reads as "no token". ## What changed - **`src/kanidm.rs`** replaces `src/lldap.rs` (deleted). Bearer api-token; `POST /v1/service_account` (name + displayname + mail in one call), read back the **UUID**, `POST /v1/group/<g>/_attr/member`. `GET /v1/self` for `reconcile`, which now names the SPN it was green as. - **`src/main.rs`** — `mint()` maps principal → directory name, creates, then joins the roster group. Partial success is **reported, not rolled back**: if the group join fails the account exists, the UUID is already logged, and the error says exactly that. Nothing here grows a revoke path. - **`Cargo.toml`** — `ldap3` out; `reqwest` gains TLS. The feature is `rustls`, **not** the pre-0.13 `rustls-tls` spelling, and rustls rather than OpenSSL so compiled-in roots cannot fail under `ProtectSystem = "strict"`. - **`module.nix`** — `lldap.url` → `kanidm.url` (the **public origin**, not kanidm's self-signed loopback `:8443`) plus `kanidm.group`. Hardening untouched. - **`justfile`** — the gigachad recipe passes one bearer token instead of a bind DN and a deleted sops path. - **`CONTEXT.md` / `AGENTS.md`** — the hazard section becomes a landed port; the "bind account over-grant" open question becomes *"`sa-vedanta`'s rights are sufficient, not minimal."* `just check` green (3 tests, up from 1), `nix build .#default` green. Note for the next contributor: `src = self` sees **tracked** files only, so a new module must be `git add`ed before `nix build` can see it, while `cargo` in the devShell compiles it happily. ## Not done here - `sa-vedanta` does not exist yet — nothing has minted end to end. Blocked on the flake-ops bootstrap act. - The roster group `agents` is undeployed **and** will be wiped by `overwriteMembers` once deployed. Both are flake-ops'. - No credential is set on a minted account, by design and unchanged.
Author
Collaborator

Bootstrapped by lar.ad/flake-ops#98 (mints sa-vedanta and its token — blocks the first end-to-end mint) and blocked on lar.ad/flake-ops#99 (agents undeployed, and overwriteMembers wipes it). Governance for how this ticket came to be written after the work: #6.

Bootstrapped by lar.ad/flake-ops#98 (mints `sa-vedanta` and its token — blocks the first end-to-end mint) and blocked on lar.ad/flake-ops#99 (`agents` undeployed, and `overwriteMembers` wipes it). Governance for how this ticket came to be written after the work: #6.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Ting/Vedanta#5
No description provided.