Add kanidm identity provider on id.qo.is #184

Open
fabianhauser wants to merge 7 commits from kanidm-identity-provider into main
Owner

Adds a Kanidm instance at https://id.qo.is as a reusable qois.kanidm module, and
onboards Grafana as the first OIDC relying party. Groups are provisioned from the repo
(append-only, so UI-managed membership survives a redeploy); persons are not.

Kanidm has no plaintext listener and haproxy forwards 443 by SNI with PROXY protocol, so
nginx keeps terminating public TLS and security.acme.certs."id.qo.is".postRun copies the
certificate into /var/lib/kanidm/ as kanidm:kanidm 0400. kanidm.service is ordered
after acme-order-renew-id.qo.is.service, so the certificate is there before it first
starts. Kanidm binds [::1]:8443 behind nginx. LDAPS listens on 636 but no firewall port
is opened, so it is host-local.

The kanidm↔grafana coupling lives in nixos-modules/kanidm-grafana/, so the OAuth2 client
is provisioned on whichever host runs kanidm regardless of where grafana runs. Grafana
itself only knows qois.grafana.sso.* (enabled by default, sso.enable = false in tests).
The client secret is shared across hosts through the new qois.sharedSecretsFile option.

Access is a single kanidm group: members of sysadmin get GrafanaAdmin, everyone else
who can log in gets Viewer.

Blocking: secret migration in private/

kanidm/oauth2/grafana currently sits in the host secrets file and must move to the
shared one, otherwise lindberg-webapps does not build:

sops set private/nixos-modules/shared-secrets/default.sops.yaml \
  '["kanidm"]["oauth2"]["grafana"]' "\"$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 48)\""
sops unset private/nixos-configurations/lindberg-webapps/secrets.sops.yaml '["kanidm"]["oauth2"]'

Verified locally

  • vm-test-run-kanidm — now runs a pebble ACME node: the certificate is really issued,
    postRun creates /var/lib/kanidm and installs it as kanidm:kanidm 0400, and kanidm
    starts on that ordering. Plus /status, OIDC discovery, the provisioned client secret
    (400 vs. 401 on /oauth2/token), LDAPS reachable locally but not from a second node,
    telegraf x509_cert
  • vm-test-run-kanidm-grafana — the grafana client is provisioned in kanidm and
    /login/generic_oauth redirects to kanidm's /ui/oauth2 with client_id=grafana and a
    PKCE code_challenge
  • vm-test-run-grafana and checks.formatting
  • every host configuration except lindberg-webapps, which is blocked on the migration
    above

To verify after deploying

  • A public A/AAAA record for id.qo.is pointing at the loadbalancer exists
  • ls -l /var/lib/kanidm/{fullchain,key}.pem is kanidm:kanidm 0400 and
    kanidm.service came up on the first try
  • Log in at https://id.qo.is/ui as idm_admin, create a person, add them to
    sysadmin, then confirm the OAuth button on https://monitoring.qo.is/login logs
    them in as a Grafana admin
  • ldapsearch -H ldaps://id.qo.is:636 -x -b 'dc=id,dc=qo,dc=is' '(name=<person>)'
    works on lindberg-webapps and fails from anywhere else
Adds a Kanidm instance at `https://id.qo.is` as a reusable `qois.kanidm` module, and onboards Grafana as the first OIDC relying party. Groups are provisioned from the repo (append-only, so UI-managed membership survives a redeploy); persons are not. Kanidm has no plaintext listener and haproxy forwards 443 by SNI with PROXY protocol, so nginx keeps terminating public TLS and `security.acme.certs."id.qo.is".postRun` copies the certificate into `/var/lib/kanidm/` as `kanidm:kanidm` `0400`. `kanidm.service` is ordered after `acme-order-renew-id.qo.is.service`, so the certificate is there before it first starts. Kanidm binds `[::1]:8443` behind nginx. LDAPS listens on `636` but no firewall port is opened, so it is host-local. The kanidm↔grafana coupling lives in `nixos-modules/kanidm-grafana/`, so the OAuth2 client is provisioned on whichever host runs kanidm regardless of where grafana runs. Grafana itself only knows `qois.grafana.sso.*` (enabled by default, `sso.enable = false` in tests). The client secret is shared across hosts through the new `qois.sharedSecretsFile` option. Access is a single kanidm group: members of `sysadmin` get `GrafanaAdmin`, everyone else who can log in gets `Viewer`. ## Blocking: secret migration in `private/` `kanidm/oauth2/grafana` currently sits in the host secrets file and must move to the shared one, otherwise `lindberg-webapps` does not build: ```bash sops set private/nixos-modules/shared-secrets/default.sops.yaml \ '["kanidm"]["oauth2"]["grafana"]' "\"$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 48)\"" sops unset private/nixos-configurations/lindberg-webapps/secrets.sops.yaml '["kanidm"]["oauth2"]' ``` ## Verified locally - `vm-test-run-kanidm` — now runs a pebble ACME node: the certificate is really issued, `postRun` creates `/var/lib/kanidm` and installs it as `kanidm:kanidm 0400`, and kanidm starts on that ordering. Plus `/status`, OIDC discovery, the provisioned client secret (400 vs. 401 on `/oauth2/token`), LDAPS reachable locally but not from a second node, telegraf `x509_cert` - `vm-test-run-kanidm-grafana` — the `grafana` client is provisioned in kanidm and `/login/generic_oauth` redirects to kanidm's `/ui/oauth2` with `client_id=grafana` and a PKCE `code_challenge` - `vm-test-run-grafana` and `checks.formatting` - every host configuration except `lindberg-webapps`, which is blocked on the migration above ## To verify after deploying - [x] A public `A`/`AAAA` record for `id.qo.is` pointing at the loadbalancer exists - [ ] `ls -l /var/lib/kanidm/{fullchain,key}.pem` is `kanidm:kanidm` `0400` and `kanidm.service` came up on the first try - [ ] Log in at `https://id.qo.is/ui` as `idm_admin`, create a person, add them to `sysadmin`, then confirm the OAuth button on `https://monitoring.qo.is/login` logs them in as a Grafana admin - [ ] `ldapsearch -H ldaps://id.qo.is:636 -x -b 'dc=id,dc=qo,dc=is' '(name=<person>)'` works on lindberg-webapps and fails from anywhere else
Add kanidm identity provider on id.qo.is
Some checks failed
CI / build (push) Failing after 4m9s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
599b5ef771
Grafana is onboarded as the first OIDC relying party. The public TLS
certificate is issued to nginx by ACME and copied into /var/lib/kanidm by
the certificate's postRun hook, because kanidm has no plaintext listener
and haproxy forwards 443 by SNI.
Add secrets
All checks were successful
CI / build (push) Successful in 2m29s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
ebefbefe8d
@ -9,0 +12,4 @@
oauthRoles = {
editors = [ "Editor" ];
admins = [ "Admin" ];
server-admins = [ "GrafanaAdmin" ];
Author
Owner

Just add a sysadmin group in kandidm, which is server-admins; no need for others at this point.

Just add a sysadmin group in kandidm, which is server-admins; no need for others at this point.
fabianhauser marked this conversation as resolved
@ -124,2 +141,4 @@
};
# Single sign-on through kanidm. The local admin account stays available as a fallback.
qois.kanidm.oauth2Clients.grafana = mkIf kanidm.enable {
Author
Owner

This will not be applied on hosts where kanidm.enable is true, but grafana.enable is false. Move the kanidm logic into kanidm-grafana nixos module; for grafana's config, keep it in here and don't rely on kanidm.enable (sso should be enabled by default. think how to make it easy to run tests without it, e.g. an enableSSO option or so?)

This will not be applied on hosts where kanidm.enable is true, but grafana.enable is false. Move the kanidm logic into `kanidm-grafana` nixos module; for grafana's config, keep it in here and don't rely on kanidm.enable (sso should be enabled by default. think how to make it easy to run tests without it, e.g. an enableSSO option or so?)
fabianhauser marked this conversation as resolved
@ -81,12 +82,30 @@ in
qois.prometheus.enable = true;
qois.loki.enable = true;
qois.kanidm = {
Author
Owner

Move this test also to kanidm-grafana module :)

Move this test also to kanidm-grafana module :)
fabianhauser marked this conversation as resolved
@ -0,0 +2,4 @@
[Kanidm](https://kanidm.github.io/kanidm/stable/) runs on `lindberg-webapps` and serves
[id.qo.is](https://id.qo.is). It is the identity provider for services that support OIDC;
Grafana is currently the only relying party.
Author
Owner

Don't mention grafana and lindberg-webapps here.

Don't mention grafana and lindberg-webapps here.
fabianhauser marked this conversation as resolved
@ -0,0 +4,4 @@
[id.qo.is](https://id.qo.is). It is the identity provider for services that support OIDC;
Grafana is currently the only relying party.
Persons are **not** provisioned from this repository — create them in the web UI. Groups
Author
Owner

Is this called persons in kanidm? Not users? (it should match, check)

Is this called persons in kanidm? Not users? (it should match, check)
fabianhauser marked this conversation as resolved
@ -0,0 +21,4 @@
'["kanidm"]["oauth2"]["grafana"]' "\"$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 48)\""
```
Afterwards publish the submodule as described in [the README](../../README.md):
Author
Owner

Remove the submodule mention, and "Both passwords are re-app..." - the user should know what they are doing.

Remove the submodule mention, and "Both passwords are re-app..." - the user should know what they are doing.
fabianhauser marked this conversation as resolved
@ -0,0 +36,4 @@
Both passwords are re-applied on every start, so changing them in sops is enough.
## First Deploy
Author
Owner

Remove first deploy block

Remove first deploy block
fabianhauser marked this conversation as resolved
@ -0,0 +52,4 @@
## Administration
Log in at [id.qo.is/ui](https://id.qo.is/ui) as `idm_admin` with the password from sops to
Author
Owner

Make this a table with keywords only, no sentences

Make this a table with keywords only, no sentences
fabianhauser marked this conversation as resolved
@ -0,0 +59,4 @@
Group membership is appended by provisioning, never overwritten, so members added in the
UI survive a redeploy. Removing a declared group from the repository does delete it.
## Onboarding a Service
Author
Owner

Remove this section

Remove this section
fabianhauser marked this conversation as resolved
@ -0,0 +78,4 @@
and a sops secret `kanidm/oauth2/<service>` readable by kanidm and the service. Add the
secret as shown above before deploying.
## LDAP
Author
Owner

Condense this section to the bash ldapsearch block only, no explanation except port is only open on localhost at this time

Condense this section to the bash ldapsearch block only, no explanation except port is only open on localhost at this time
fabianhauser marked this conversation as resolved
@ -0,0 +94,4 @@
## Backup / Restore
`/var/lib/kanidm` is part of the borg backup and holds both the live database and the
nightly online backups kanidm writes to `/var/lib/kanidm/backups` (7 versions kept). Only
Author
Owner

Reduce this versions to 2 (it's in borg after a day anyway). make this paragraph a bullet list with keywords only

Reduce this versions to 2 (it's in borg after a day anyway). make this paragraph a bullet list with keywords only
fabianhauser marked this conversation as resolved
@ -0,0 +29,4 @@
cfg = config.qois.kanidm;
stateDir = "/var/lib/kanidm";
Author
Owner

Make sure that kanidm does actually use this dir - or take the dir from some existing config... option (e.g. kanidm config or systemd service config)

Make sure that kanidm does *actually* use this dir - or take the dir from some existing `config...` option (e.g. kanidm config or systemd service config)
fabianhauser marked this conversation as resolved
@ -0,0 +92,4 @@
package = mkOption {
type = package;
default = pkgs.kanidmWithSecretProvisioning_1_11;
Author
Owner

Add a note to the update docs about this

Add a note to the update docs about this
fabianhauser marked this conversation as resolved
@ -0,0 +236,4 @@
tls_key = tlsKey;
http_client_address_info.x-forward-for = [ "::1" ];
# Consistent database snapshots; copying the live sqlite file is not restore-safe.
online_backup.versions = 7;
Author
Owner

Reduce this, it's in borg (mentioned earlier as well)

Reduce this, it's in borg (mentioned earlier as well)
fabianhauser marked this conversation as resolved
@ -0,0 +266,4 @@
# The certificate only appears once ACME has issued it for the first time, so kanidm
# keeps retrying until nginx' certificate has been copied over.
systemd.services.kanidm.serviceConfig = {
Author
Owner

Is there a max retires by default? There should be some limit, but relatively generous compared to some other services (e.g. 10m)

Is there a max retires by default? There should be some limit, but relatively generous compared to some other services (e.g. 10m)
fabianhauser marked this conversation as resolved
@ -0,0 +271,4 @@
RestartSec = 60;
};
systemd.tmpfiles.settings."10-qois-kanidm".${stateDir}.d = {
Author
Owner

What is this needed for?

What is this needed for?
fabianhauser marked this conversation as resolved
@ -0,0 +48,4 @@
};
};
# TODO: Migrate this to the testing helper acme server
Author
Owner

resolve todo

resolve todo
fabianhauser marked this conversation as resolved
Move kanidm-grafana coupling into its own module
Some checks failed
CI / build (push) Failing after 2m56s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
7f6ba3caf5
Grafana's SSO settings no longer depend on qois.kanidm, so the OAuth2 client is
provisioned wherever kanidm runs. The client secret becomes a shared secret,
reachable from either side through the new qois.sharedSecretsFile option.

Kanidm now waits for the ACME unit that installs its certificate instead of
restart-looping until it appears, and its test drives a real pebble CA.
Author
Owner

All 17 comments addressed in 7f6ba3c.

Module layering — new nixos-modules/kanidm-grafana/ owns the coupling: it declares
options.qois.grafana.sso (enable defaults to true), the kanidm OAuth2 client, and the
shared client secret. The kanidm side is gated on qois.kanidm.enable, so it applies on a
host running kanidm without grafana; qois.grafana no longer reads config.qois.kanidm
at all. Tests opt out with sso.enable = false. The grafana subtest moved to the new
module's own test.

Shared secret — added qois.sharedSecretsFile (nixos-modules/meta/secrets.nix,
pointing at the private submodule's shared sops file) so kanidm/oauth2/grafana is a
single secret readable by both sides regardless of which host they land on. This needs the
migration in private/ noted in the PR description; without it lindberg-webapps does not
build.

Roles — reduced to the sysadmin group mapping to GrafanaAdmin. roles in the
oauth2 client API now keys on real kanidm group names instead of deriving
<client>.<suffix> groups.

kanidm/default.nix

  • stateDir is dirOf services.kanidm.server.settings.db_path — the read-only option
    kanidm actually uses.
  • online_backup.versions 7 → 2.
  • Dropped the retry config. kanidm.service is now ordered after/wants
    acme-order-renew-id.qo.is.service, the unit whose postRun installs the certificate,
    so the certificate is there before the first start.
  • Dropped the tmpfiles rule; install -d in the cert script creates the directory, which
    is needed because postRun now runs before systemd creates StateDirectory.
  • updates.md notes the pinned kanidmWithSecretProvisioning_1_11 and that kanidm minor
    versions must be upgraded one at a time.

Test TODO — resolved. kanidm/test.nix has a pebble ACME node and no certificate
mkForces left; the test downloads pebble's runtime-generated CA and asserts the issued
certificate lands in /var/lib/kanidm as kanidm:kanidm 0400.

README — trimmed as requested: no host or service names in the intro, secrets section
is just the sops set commands, First Deploy and Onboarding sections gone, Administration
is a table, LDAP is the ldapsearch block plus the localhost note, Backup/Restore prose
replaced by bullets (numbered steps kept). Confirmed kanidm's own term is persons
(services.kanidm.provision.persons, kanidm person create).

All 17 comments addressed in 7f6ba3c. **Module layering** — new `nixos-modules/kanidm-grafana/` owns the coupling: it declares `options.qois.grafana.sso` (`enable` defaults to `true`), the kanidm OAuth2 client, and the shared client secret. The kanidm side is gated on `qois.kanidm.enable`, so it applies on a host running kanidm without grafana; `qois.grafana` no longer reads `config.qois.kanidm` at all. Tests opt out with `sso.enable = false`. The grafana subtest moved to the new module's own test. **Shared secret** — added `qois.sharedSecretsFile` (`nixos-modules/meta/secrets.nix`, pointing at the private submodule's shared sops file) so `kanidm/oauth2/grafana` is a single secret readable by both sides regardless of which host they land on. This needs the migration in `private/` noted in the PR description; without it `lindberg-webapps` does not build. **Roles** — reduced to the `sysadmin` group mapping to `GrafanaAdmin`. `roles` in the oauth2 client API now keys on real kanidm group names instead of deriving `<client>.<suffix>` groups. **`kanidm/default.nix`** - `stateDir` is `dirOf services.kanidm.server.settings.db_path` — the read-only option kanidm actually uses. - `online_backup.versions` 7 → 2. - Dropped the retry config. `kanidm.service` is now ordered `after`/`wants` `acme-order-renew-id.qo.is.service`, the unit whose `postRun` installs the certificate, so the certificate is there before the first start. - Dropped the tmpfiles rule; `install -d` in the cert script creates the directory, which is needed because `postRun` now runs before systemd creates `StateDirectory`. - `updates.md` notes the pinned `kanidmWithSecretProvisioning_1_11` and that kanidm minor versions must be upgraded one at a time. **Test TODO** — resolved. `kanidm/test.nix` has a pebble ACME node and no certificate `mkForce`s left; the test downloads pebble's runtime-generated CA and asserts the issued certificate lands in `/var/lib/kanidm` as `kanidm:kanidm 0400`. **README** — trimmed as requested: no host or service names in the intro, secrets section is just the `sops set` commands, First Deploy and Onboarding sections gone, Administration is a table, LDAP is the `ldapsearch` block plus the localhost note, Backup/Restore prose replaced by bullets (numbered steps kept). Confirmed kanidm's own term is *persons* (`services.kanidm.provision.persons`, `kanidm person create`).
fabianhauser force-pushed kanidm-identity-provider from 7f6ba3caf5
Some checks failed
CI / build (push) Failing after 2m56s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
to 68249ec4f2
Some checks failed
CI / build (push) Failing after 3m49s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
2026-08-29 18:50:51 +00:00
Compare
fabianhauser force-pushed kanidm-identity-provider from 68249ec4f2
Some checks failed
CI / build (push) Failing after 3m49s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
to 9a6dff0868
All checks were successful
CI / build (push) Successful in 3m17s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
2026-08-29 19:02:34 +00:00
Compare
Service modules declared their telegraf inputs directly, so module tests had to
retype them to keep telegraf from running the host-level inputs of the telegraf
module and srvos. Tests now restrict telegraf to config.qois.telegraf.serviceInputs
instead of a hand-copied literal.
Move grafana single sign-on into the kanidm-grafana module
All checks were successful
CI / build (push) Successful in 3m52s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
40b26e020a
The sso options lived in the qois.grafana namespace but were declared by the glue
module and consumed by the grafana module. They are now qois.kanidm-grafana, which
also configures grafana's auth.generic_oauth, so neither base module knows about the
other and no other module's test has to opt out.

The oauth2 client secret moves from the all-hosts shared file to
private/nixos-modules/kanidm/<host>.sops.yaml, encrypted only for that host and the
host running kanidm.
Author
Owner

Second round addressed. Two commits: Add telegraf serviceInputs option and Move grafana single sign-on into the kanidm-grafana module.

Module layering. options.qois.grafana.sso is gone. The glue module now owns options.qois.kanidm-grafana (enable, clientId, scopes, roles, secretFile) and configures services.grafana.settings."auth.generic_oauth" itself, including the JMESPath role mapping. enable defaults to false and is set explicitly next to qois.kanidm.enable on lindberg-webapps, so neither grafana/test.nix nor kanidm/test.nix mentions it any more. Endpoints come from qois.kanidm.domain / qois.grafana.domain, which hold their defaults regardless of enable, so a split-host setup still works.

Secret scope. qois.sharedSecretsFile is removed. sops-config gained a rule per host for private/nixos-modules/kanidm/<host>.sops.yaml, encrypted for that host plus kanidmHost. The generated .sops.yaml has five hosts at two age keys and lindberg-webapps at one, since it is the kanidm host. The secret has been migrated out of shared-secrets.

kanidm module. postRun inlined, with User/Group read from systemd.services.kanidm.serviceConfig and install coming from the acme unit's own path — no coreutils reference. extraSettingssettings. Firewall sentence dropped from ldapPort. networking.hosts kept, so the local probes measure the local nginx rather than hairpinning through the loadbalancer.

Observability. kanidm 1.11 links only the tracing half of the OTel SDK (opentelemetry_sdk/src/trace/*, TracerProvider, no MeterProvider), so otel_grpc_endpoint emits sampled spans and no metrics — it can't answer "is kanidm up". The module now runs inputs.http_response against /status, which the test asserts instead of x509_cert. The id.qo.is/status entry added to the central blackbox list in the first round is removed: on the host running both it was the identical probe twice, and the module-level one follows kanidm wherever it runs.

Tests. kanidm-grafana now uses a pebble node and real issuance for both monitoring.acme.test and id.acme.test; the tls_chain/tls_key and nginx certificate overrides are gone, so the module's own certificate path is exercised, and curl verifies properly rather than using -k.

Keeping test telegraf config in sync. Rather than another "keep in sync" comment, service modules now declare qois.telegraf.serviceInputs, and tests use mkForce config.qois.telegraf.serviceInputs. The git, postgresql and loadbalancer tests lose their copied literals too — including loadbalancer's second copy of the internal statsIpPort constant.

Two things worth flagging that came out of verifying this:

  1. Typing serviceInputs as types.attrs first — shallow // merge — silently dropped four of the five x509_cert declarations on lindberg-webapps. It now uses the same TOML format type as services.telegraf.extraConfig, so lists concatenate. Verified against the generated config: vault, static-page, kanidm, grafana and git all present.
  2. In the test, telegraf could not verify kanidm's certificate, because pebble generates its issuing CA at runtime and the system trust store cannot contain it. The test node points telegraf at the downloaded CA via SSL_CERT_FILE and restarts it once the file exists, since Go caches the pool on first handshake.

Verified locally: vm-test-run-{kanidm,kanidm-grafana,grafana,git,postgresql,loadbalancer,telegraf,prometheus,vault}, formatting, deploy-activate, deploy-schema, and the lindberg-webapps build.

Second round addressed. Two commits: `Add telegraf serviceInputs option` and `Move grafana single sign-on into the kanidm-grafana module`. **Module layering.** `options.qois.grafana.sso` is gone. The glue module now owns `options.qois.kanidm-grafana` (`enable`, `clientId`, `scopes`, `roles`, `secretFile`) and configures `services.grafana.settings."auth.generic_oauth"` itself, including the JMESPath role mapping. `enable` defaults to false and is set explicitly next to `qois.kanidm.enable` on `lindberg-webapps`, so neither `grafana/test.nix` nor `kanidm/test.nix` mentions it any more. Endpoints come from `qois.kanidm.domain` / `qois.grafana.domain`, which hold their defaults regardless of `enable`, so a split-host setup still works. **Secret scope.** `qois.sharedSecretsFile` is removed. `sops-config` gained a rule per host for `private/nixos-modules/kanidm/<host>.sops.yaml`, encrypted for that host plus `kanidmHost`. The generated `.sops.yaml` has five hosts at two age keys and `lindberg-webapps` at one, since it *is* the kanidm host. The secret has been migrated out of `shared-secrets`. **kanidm module.** postRun inlined, with `User`/`Group` read from `systemd.services.kanidm.serviceConfig` and `install` coming from the acme unit's own `path` — no coreutils reference. `extraSettings` → `settings`. Firewall sentence dropped from `ldapPort`. `networking.hosts` kept, so the local probes measure the local nginx rather than hairpinning through the loadbalancer. **Observability.** kanidm 1.11 links only the tracing half of the OTel SDK (`opentelemetry_sdk/src/trace/*`, `TracerProvider`, no `MeterProvider`), so `otel_grpc_endpoint` emits sampled spans and no metrics — it can't answer "is kanidm up". The module now runs `inputs.http_response` against `/status`, which the test asserts instead of `x509_cert`. The `id.qo.is/status` entry added to the central blackbox list in the first round is removed: on the host running both it was the identical probe twice, and the module-level one follows kanidm wherever it runs. **Tests.** `kanidm-grafana` now uses a pebble node and real issuance for both `monitoring.acme.test` and `id.acme.test`; the `tls_chain`/`tls_key` and nginx certificate overrides are gone, so the module's own certificate path is exercised, and `curl` verifies properly rather than using `-k`. **Keeping test telegraf config in sync.** Rather than another "keep in sync" comment, service modules now declare `qois.telegraf.serviceInputs`, and tests use `mkForce config.qois.telegraf.serviceInputs`. The `git`, `postgresql` and `loadbalancer` tests lose their copied literals too — including loadbalancer's second copy of the internal `statsIpPort` constant. Two things worth flagging that came out of verifying this: 1. Typing `serviceInputs` as `types.attrs` first — shallow `//` merge — silently dropped four of the five `x509_cert` declarations on `lindberg-webapps`. It now uses the same TOML format type as `services.telegraf.extraConfig`, so lists concatenate. Verified against the generated config: vault, static-page, kanidm, grafana and git all present. 2. In the test, telegraf could not verify kanidm's certificate, because pebble generates its issuing CA at runtime and the system trust store cannot contain it. The test node points telegraf at the downloaded CA via `SSL_CERT_FILE` and restarts it once the file exists, since Go caches the pool on first handshake. Verified locally: `vm-test-run-{kanidm,kanidm-grafana,grafana,git,postgresql,loadbalancer,telegraf,prometheus,vault}`, `formatting`, `deploy-activate`, `deploy-schema`, and the `lindberg-webapps` build.
fabianhauser force-pushed kanidm-identity-provider from 40b26e020a
All checks were successful
CI / build (push) Successful in 3m52s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
to d4324b45c9
All checks were successful
CI / build (push) Successful in 4m36s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
2026-08-30 14:09:11 +00:00
Compare
@ -17,2 +17,4 @@
private.nixosModules.default
];
qois.kanidm.secretsFile = "${private}/nixos-modules/kanidm/${config.networking.hostName}.sops.yaml";
Author
Owner

move this config to the module (you can add another file and import it unconditionally)

move this config to the module (you can add another file and import it unconditionally)
fabianhauser marked this conversation as resolved
@ -77,2 +77,4 @@
enable = true;
domain = serverDomain;
# Single sign-on is covered by the kanidm-grafana module test.
sso.enable = false;
Author
Owner

this option should be defined in this module.

this option should be defined in this module.
fabianhauser marked this conversation as resolved
@ -0,0 +4,4 @@
...
}:
# Binds grafana to kanidm as an OIDC relying party. Both sides may live on different
# hosts, so each half activates on its own and the shared client secret is declared here.
Author
Owner

Remove the comment

Remove the comment
@ -0,0 +86,4 @@
};
config = mkIf cfg.enable (mkMerge [
# The client is provisioned wherever kanidm runs, even if grafana runs on another host.
Author
Owner

Remove the comment

Remove the comment
@ -0,0 +91,4 @@
})
(mkIf configureGrafana {
qois.grafana.sso.secretFile = config.sops.secrets.${secretName}.path;
Author
Owner

Also configure the rest of the grafana sso config here, not in the grafana/default.nix.

Also configure the rest of the grafana sso config here, not in the grafana/default.nix.
fabianhauser marked this conversation as resolved
@ -0,0 +125,4 @@
sops.secrets.${secretName} = {
sopsFile = kanidm.secretsFile;
mode = "0440";
owner = if kanidm.enable then "kanidm" else config.users.users.grafana.name;
Author
Owner
Do this in a less hacky way like described in https://raw.githubusercontent.com/Mic92/sops-nix/refs/heads/master/README.md
@ -0,0 +36,4 @@
};
qois.kanidm = {
enable = true;
Author
Owner

use our kanidm module, it makes no sense to configure kanidm separately, then the test isn't worth much.

use our kanidm module, it makes no sense to configure kanidm separately, then the test isn't worth much.
fabianhauser marked this conversation as resolved
@ -0,0 +60,4 @@
qois.postgresql.package = pkgs.postgresql;
# Dummy sops file so secret paths resolve at eval time; nothing reads them at
Author
Owner

Remove this comment, it's obvious

Remove this comment, it's obvious
fabianhauser marked this conversation as resolved
@ -0,0 +62,4 @@
installCert = pkgs.writeShellScript "kanidm-install-cert" ''
# Runs before kanidm's first start, so systemd has not created StateDirectory yet.
${pkgs.coreutils}/bin/install -d -o kanidm -g kanidm -m 0700 ${stateDir}
Author
Owner

This group and user name should be taken from the systemd service. Maybe it better do declare coreutils as builddep, so that we save some duplication here.

This group and user name should be taken from the systemd service. Maybe it better do declare coreutils as builddep, so that we save some duplication here.
fabianhauser marked this conversation as resolved
@ -0,0 +93,4 @@
type = port;
default = 636;
description = ''
Port of the LDAPS interface. No firewall port is opened for it, so it is only
Author
Owner

No mention of firewall here

No mention of firewall here
fabianhauser marked this conversation as resolved
@ -0,0 +182,4 @@
'';
};
extraSettings = mkOption {
Author
Owner

just name this settings.

just name this settings.
fabianhauser marked this conversation as resolved
@ -0,0 +231,4 @@
"kanidm/idm-admin-password".owner = "kanidm";
};
# postRun of this unit installs the certificate kanidm needs to start.
Author
Owner

Remove the comment

Remove the comment
@ -0,0 +240,4 @@
};
security.acme.certs.${cfg.domain} = {
postRun = "${installCert}";
Author
Owner

declare ths postrun inline here, it's short enough.

declare ths postrun inline here, it's short enough.
@ -0,0 +250,4 @@
{ sources = [ "https://${cfg.domain}:443" ]; }
];
networking.hosts."127.0.0.1" = [ cfg.domain ];
Author
Owner

is the localhost reference required?

is the localhost reference required?
@ -0,0 +62,4 @@
sops.secrets = mkForce { };
# Covered by the kanidm-grafana module test, and it would need a sops secret here.
qois.grafana.sso.enable = false;
Author
Owner

This option should be named qois.kanidm-grafana.enable = false;
also, it should not be neccessary here since we'd manually define this along with qois.kanidm.enable = true; on the host it's actually used (and in that test). Remove any mention here and make sure this is true.

This option should be named qois.kanidm-grafana.enable = false; also, it should not be neccessary here since we'd manually define this along with qois.kanidm.enable = true; on the host it's actually used (and in that test). Remove any mention here and make sure this is true.
@ -0,0 +69,4 @@
443
];
qois.telegraf.enable = mkForce true;
Author
Owner

Make sure telegraf only runs the modules we want in this tests, not the whole rest.

Make sure telegraf only runs the modules we want in this tests, not the whole rest.
@ -0,0 +34,4 @@
download_ca(client, caDomain)
with subtest("acme-certificate"):
# postRun installed the issued certificate where kanidm reads it.
Author
Owner

the subtest name should be clear enough so this comment is not needed.

the subtest name should be clear enough so this comment is not needed.
@ -0,0 +80,4 @@
with subtest("ldaps-port-isolation"):
client.fail(ldapsearch(serverDomain))
with subtest("telegraf-metrics"):
Author
Owner

The x509 is not relevant to check - https://kanidm.github.io/kanidm/stable/monitoring_the_platform.html however, the kanidm observability should be checked.

The x509 is not relevant to check - https://kanidm.github.io/kanidm/stable/monitoring_the_platform.html however, the kanidm observability should be checked.
@ -0,0 +7,4 @@
inherit (lib.types) path;
in
{
options.qois.sharedSecretsFile = mkOption {
Author
Owner

The secrets should not be accessible for all hosts - just the one that runs grafana, and the one that kanidm is running on. Create an additional encryption config in sops-config for the kanidm module, that has one file per host and encrypts the secrets for them and the host kanidm is running on.

The secrets should not be accessible for all hosts - just the one that runs grafana, and the one that kanidm is running on. Create an additional encryption config in `sops-config` for the kanidm module, that has one file per host and encrypts the secrets for them and the host kanidm is running on.
fabianhauser marked this conversation as resolved
All checks were successful
CI / build (push) Successful in 4m36s
CI / deploy (docs-ops.qo.is) (push) Has been skipped
CI / deploy (system-physical) (push) Has been skipped
CI / deploy (system-vm) (push) Has been skipped
CI / deploy-ci (push) Has been skipped
This pull request has changes conflicting with the target branch.
  • flake.lock
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin kanidm-identity-provider:kanidm-identity-provider
git switch kanidm-identity-provider
Sign in to join this conversation.
No reviewers
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
qo.is/infrastructure!184
No description provided.