oidc-client: keycloak makes each consumer its client; grafana logs in through it #155
Open
mesh-admin
wants to merge 8 commits from
feat/oidc-client-provision into main
pull from: feat/oidc-client-provision
merge into: :main
:main
:fix/resolver-passes-the-dnssec-bit
:fix/mailu-admin-asks-the-machines-resolver
:fix/postgres-is-not-named-after-the-seat
:fix/110-the-resolver-answers-a-container
:feat/qbittorrent-for-ace
:feat/servarr-api-provision
:feat/home-assistant-for-ace
:feat/tautulli-for-ace
:feat/bookshelf-for-ace
:feat/lidarr-for-ace
:feat/radarr-for-ace
:feat/sonarr-for-ace
:feat/jackett-for-ace
:feat/oidc-client-provision
:feat/mosquitto-placed
:feat/nodered-for-ace
:feat/influxdb-for-ace
:feat/kometa-for-ace
:feat/plex-for-ace
:fix/manifests-publish-software-ports
:feat/n8n-for-ace
:feat/letta-for-ace
:feat/baserow-for-ace
:feat/supabase-for-ace
:feat/nzbget-for-ace
:feat/matrix-for-ace
:feat/bazarr-for-ace
:feat/redis-for-ace
:feat/mssql-for-ace
:fix/sidecars-dial-the-port-they-were-given
:feat/grafana-for-ace
:feat/unifi-for-ace
:feat/icecast-for-ace
:feat/ombi-for-ace
:chore/remove-the-network-checker-module
:feat/a-network-checker-module
:feat/modules-name-their-endpoints
:fix/a-routed-module-listens-from-the-mesh
:fix/the-resolver-declares-both-protocols
:fix/sshd-declares-the-daemon-it-owns
:fix/fail2ban-bans-through-what-every-machine-has
:fix/fail2ban-declares-the-log-its-own-jail-reads
:fix/fail2ban-restarts-on-its-log-target
:fix/fail2ban-declares-where-it-logs
:feat/the-catalogue-hears-what-it-missed
:feat/the-catalogue-prepares-its-own-schema
:fix/the-catalogue-declares-the-event-it-emits
:feat/a-merge-rebuilds-what-it-changed
:fix/a-merge-older-than-the-watching-is-history
:fix/a-merge-announced-is-said
:fix/the-forge-watches-every-repository
:feat/the-forge-announces-every-merge
:feat/nats-serves-the-meshs-certificate
:fix/nats-declares-its-base
:feat/amqp-leaves-the-catalogue
:restore/broker-claim
:revert/broker-seat-claim
:fix/broker-seat-must-stay-held
:fix/go-126-base
:feat/nats-genesis
:feat/ssh-client-module
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
A new mesh provision,
oidc-client. keycloak provides it and grafana is the first consumer. Nothing is deployed by this PR.Based on #153 (
feat/grafana-for-ace,a5e21cb). This branch fast-forwards onto it, so merge #153 first, or this PR carries #153's grafana commit with it.The provision
providesoidc-client, scopemeshservesissuer(defaulthttps://keycloak.novox.be/realms/master),authorization-path,token-path,userinfo-path(Keycloak's/protocol/openid-connect/*), plus theportthe mesh addsgrants/receives/var/lib/keycloak/grants,/var/lib/keycloak/grants/mesh.json(same shape as postgres)label+endpoint(the mesh composesname/internal-namefrom these, as it does for a route, including reach per ADR 0138), andcallback: the path the browser is sent back to${bound:oidc-client:…}as(= the client id),issuer,authorization-path,token-path,userinfo-path,port,at,fromsecretsas, e.g.mesh_ace_grafana. Postgres uses the same rule for its role name. The consumer reads it as${bound:oidc-client:as}, so both ends agree by construction.https://<name><callback>, plushttps://<internal-name><callback>when the route also has an internal name.rootUrl/baseUrlarehttps://<first name>. A contribution with nocallback(a path starting with/), or with no composed name, is refused. No client is made for it./realms/<realm>. The assignment sets one value,issuer. Settings reach both keycloak's served facts and its sidecar'sconfig.json, so the realm the consumer is told and the realm its client is made in cannot differ. For novox:settings set keycloak <file> --node novoxwith{"issuer": "https://keycloak.novox.be/realms/Novox"}.client-secret), standard flow only (no implicit flow, no direct grants, no service account). It gets one mapper,realm roles, which puts the realm roles into a flatrolesclaim in the id token, access token and userinfo. Grafana's HAL role path reads that claim.Provisioner (keycloak's sidecar)
modules/keycloak/oidc.ts: create or update,holds, and remove.provisioner/index.tsconnects them to the sdk harness.mesh.provisioned=true. A client with the same id but no mark is refused and logged. It is never adopted, updated or deleted.holdsreads the client and returns false if any of these changed behind the mesh's back: present and marked, enabled, confidential, the exact redirect set, the mapper, the secret. The harness then re-applies.adminsecret (MESH_KEYCLOAK_PASSWORD_FILE, whichclient.tsnow reads) and the grants directory, and setsMESH_RECEIVES. Before this,config.jsonwas{}and no admin password reached the sidecar, so keycloak's existing tools could not start either.grafana
oidc-clientand contributes{label: grafana, endpoint: web, callback: /login/generic_oauth}.oidc.envfile is the container'senv-file. It carries HAL'sGF_AUTH_GENERIC_OAUTH_*settings: name Keycloak; scopesopenid email profile roles; the role path; PKCE; allow sign-up; allow assign grafana admin. The client id and the auth/token/userinfo URLs come from${bound:…}.GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE→${dir:state}/oidc-client.secret, owned 472:472, mode 0400 (same pattern asadmin). No secret is in the environment.restart-on: [oidc-env, oidc-secret].GF_SERVER_ROOT_URL=https://grafana.zurag.beis a literal. Grafana builds itsredirect_urifromroot_url. Without it, Keycloak answersInvalid parameter: redirect_uri(verified). A module cannot learn the name the mesh composes for its own endpoint (hq issue 122), so this is the same kind of literal as keycloak'sKC_HOSTNAME. It goes away when 122 lands.Migration: the existing HAL client
grafanain realm NovoxChosen: the provisioner creates a new client,
mesh_ace_grafana, with a minted secret, and leavesgrafanaalone for the operator to delete after cutover. Nosecret acceptis needed for this pair.Why this is safer than taking over
grafanain place:mesh_prefix exist so that never happens.grafanawould need a second naming rule on both ends.secret accept … --provider novox, with the old secret as the pair credential) would only matter if the old client were kept. With a new client, a minted secret is simpler and can be rotated.sub, which is the Keycloak user id in the realm, whatever client issued the token. The one existing Keycloak user in grafana.db stays linked.Cutover:
issuerfor novox.mesh_ace_grafana.grafanaclient in realm Novox.Tested
tsconfig.json, with the Dockerfile'stscline, against@novox/mesh-sdk0.1.1 (packed from mesh-sdk main) and TypeScript 5.9.npm test(newmodules/keycloak/test/oidc.test.ts, against a fake admin API, same pattern as gitea's tests): 10/10. Covers realm from issuer; redirects from the composed names; the confidential client and its secret; idempotence; update in place that keeps unowned fields;holdscatching secret, redirect and mapper drift; an unmarked client with the same id refused with only reads made; the HALgrafanaclient never touched; removal; no callback means no client.MESH_CATALOGUE=<this branch> go test -count=1 ./internal/catalogue/ ./internal/inventory/ ./cmd/...: all ok.TestEveryCatalogueManifestParsesran, not skipped: 73 manifests. A scratch test, not committed, resolved the real keycloak (novox) and grafana (ace) manifests throughResolve→Declaration→ContributionsFrom:oidc.envrenders with client idmesh_ace_grafanaandhttps://keycloak.novox.be/realms/Novox/protocol/openid-connect/{auth,token,userinfo}, with no placeholder left.oidc-clientand owned 472:472.mesh.jsoncarriesas: mesh_ace_grafana,secret: /var/lib/keycloak/grants/ace.grafana.secretandvalues {callback, name: grafana.zurag.be, …}.config.jsoncarries the settled issuer.grafanaclient.mesh.jsonand a dummy pair credential. It createdmesh_ace_grafana: confidential, secret equal to the pair credential, redirecthttps://127.0.0.1:18501/login/generic_oauth, mark, mapper. The HAL client was left unchanged.remove('grafana')returned "not ours", and removal worked.oidc.envwith the lab's bound values, completed the login: redirect to/realms/Novox/…/authwithclient_id=mesh_ace_grafanaand PKCE, Keycloak login, callback, then/api/userreturned the Keycloak user with org role Admin (mapped through therolesclaim).Open / model gaps
issuersetting also appears in keycloak's ownpostgres-databaseandroutecontribution values. This is harmless today (both providers ignore unknown keys; postgres re-applies once), but a setting cannot be scoped to one provision.servescannot be composed. The issuer and the realm cannot be derived from one another in the manifest, so the realm is parsed from the issuer.GF_SERVER_ROOT_URL), same asKC_HOSTNAME.The module stated /var/lib/grafana-module and /services/grafana/data, a layout no definition may carry (ADR 0112). State and data are now placed directories; the admin secret lives beside the broker account under the mesh's own state. The admin password reached grafana through an env-file. Grafana honours GF_SECURITY_ADMIN_PASSWORD__FILE, so it is now a 0400 file owned by the image's user (472) and mounted, and "secrets-in-environment" is gone (ADR 0086). The runtime sidecar was given no credential at all - its config file was "{}", so GrafanaClient.fromEnv threw and the tools and the alert watcher did nothing. It now carries user/password from the same secret, and it calls grafana on the machine port the mesh assigned (${port:3000}) rather than a literal 3000. Image pinned to the 13.2.2 build ace's predecessor runs; the old pin was 13.2.1, older than the data it would open. Verified: catalogue tests with MESH_CATALOGUE pointing here; a throwaway container of the pinned image with the file-mounted secret answers /api/health and authenticates admin with the file's value (default admin/admin refused); restarted over the same data with a different file value, the original password still holds - so a migrated instance's password must be accepted, not minted; data owned by another uid fails to start, so a moved data directory must be chowned to 472.A module that logs people in through Keycloak had to be given a client by hand, with its secret copied into the consumer's environment. As a provision the mesh derives the client id (the consumer's identity, mesh_<node>_<module>) and mints its secret, and delivers both ends: keycloak creates exactly that confidential client, the consumer names it through ${bound:oidc-client:as}. The consumer says where its browser comes back to (`callback`) and which endpoint it is reached on (`label`/`endpoint`), so the redirect is built from the same names the mesh composes for its route. keycloak serves the issuer and the endpoint paths under it; the issuer is the one value an assignment sets, and the realm is read out of it, so consumer and client cannot disagree. Only what the mesh made is touched: its clients carry mesh.provisioned=true; a client of the same id without the mark is refused, never adopted, updated or deleted. The runtime now gets the admin password as a file, which its tools also needed and never had.Review — changes needed before merge
The provision itself is right: grant-derived client ids, pair credential as the client secret, the
mesh.provisionedmark (never adopt, update or delete an unmarked client), in-place updates, and a real end-to-end login. Keep all of that.Blocking: two domain literals in manifests. A manifest never carries a domain (ADR 0112, the migration note: "a hostname, a domain … if your manifest contains any of those, it is wrong").
GF_SERVER_ROOT_URL=https://grafana.zurag.be— wrong on every other machine.issuerhttps://keycloak.novox.be/realms/master— a novox domain as a catalogue default. The issuer must be the assignment's with no domain-bearing default (unset → refuse clearly), or composed by the mesh.Both are hq 122 (a module cannot ask for its own public name), status located in
declaration.go: the controller composes each consumer's name (composeName) but does not put it into the consumer's own route binding (boundFile— searxng's liveroute.jsonon ace has as/at/from/serves only). Fixing 122 there — e.g.name/internal-namein the route binding, so grafana writeshttps://${bound:route:name}— removes both literals. Held until the operator decides how 122 gets fixed.Non-blocking: the scratch resolution test (keycloak on novox + grafana on ace through Resolve/Declaration/ContributionsFrom) is worth committing to mesh-controller as a permanent check.
Reworked (commit above): the three domain literals are gone —
GF_SERVER_ROOT_URLand keycloak'sKC_HOSTNAMEcome from${bound:route:name}(mesh-controller #149, which fixes hq 122); the issuer has no default and is the assignment's. Rendered through #149 from these manifests on a zurag.be node:KC_HOSTNAME=https://keycloak.zurag.be,GF_SERVER_ROOT_URL=https://grafana.zurag.be. Depends on mesh-controller #149 being merged and rolled out; merge #153 first. For novox at rollout:settings set keycloak <{"issuer":"https://keycloak.novox.be/realms/Novox"}> --node novox— keycloak's own KC_HOSTNAME stays keycloak.novox.be via its route label.The manifest named /services/influxdb and /var/lib/influxdb-module — one machine's paths — and passed the admin password and token through the environment. ace is moving its 2022 instance onto the mesh, so the module has to be what it is on any machine. - data, config and state are placed directories; the data keeps 1000:1000, the image's influxdb user, which is who owns ace's data today. - the init secrets reach the image through its own DOCKER_INFLUXDB_INIT_{PASSWORD,ADMIN_TOKEN}_FILE; the vault's files are mounted read-only. secrets-in-environment is gone. - the sidecar reads its token from the same file (MESH_INFLUXDB_TOKEN_FILE, added to client.ts) and reaches the server at its assigned machine port (${port:8086}) instead of assuming 8086. The unused config-dir mount, which held the CLI's copy of the admin token, is dropped. - the api endpoint contributes a route: the web UI is how people use it, and reach is the assignment's to say. Verified: catalogue tests pass with MESH_CATALOGUE pointed at this tree. The pinned 2.9.1 image, run on a scratch copy of ace's 2.4.0 data, opens it, runs its metadata migrations (backing up the pre-upgrade bolt/sqlite) and hashes the two stored tokens; /health passes. A fresh setup through the _FILE variables, with dummy secrets as root-owned 0600 files, accepts the token (200 on /api/v2/buckets) and the password (204 on /signin). client.ts typechecks strict and reads the token file, tolerating the endpoints key in its config.New on this branch: grafana's InfluxDB data source comes from
influxdb-api(d2f0373)feat/influxdb-for-aceis merged in (c5e273e, a merge, not a rebase) so that this branch's catalogue has a provider for everything grafana requires. Merge #152 first.requiresinfluxdb-apiand contributes{access: read}, which is read access to every bucket in the org. The mesh rendersdatasource-influxdb.yaml, a Grafana provisioning file mounted into/etc/grafana/provisioning/datasources/:${bound:influxdb-api:scheme}://…:at:…:port.${bound:influxdb-api:as}and the InfluxQL database is${bound:influxdb-api:bucket}.$__file{/run/secrets/influxdb-api}, read from the pair credential. That copy is 0400 and owned by 472, like the oidc secret. No secret is ever written into the YAML.InfluxDB (mesh)/mesh-influxdb-api), is read-only in the UI and is not the default. Proven: a user-made default "InfluxDB" data source with the uid ace uses was byte-for-byte unchanged after grafana started with the file. The mesh's source was added beside it withisDefault: false./api/datasources/uid/mesh-influxdb-api/healthreturned "datasource is working. 1 measurements found"./api/ds/queryreturned data.MESH_CATALOGUE=<this branch> go test ./internal/catalogue/passes.sensorsthat InfluxDB 2 doesn't have. To show data, the operator switches the dashboard (or the default) toInfluxDB (mesh)in the UI. The mesh deliberately doesn't do that.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.