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
Contributor

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

keycloak provides oidc-client, scope mesh
keycloak serves issuer (default https://keycloak.novox.be/realms/master), authorization-path, token-path, userinfo-path (Keycloak's /protocol/openid-connect/*), plus the port the mesh adds
keycloak grants / receives /var/lib/keycloak/grants, /var/lib/keycloak/grants/mesh.json (same shape as postgres)
consumer contributes label + endpoint (the mesh composes name / internal-name from these, as it does for a route, including reach per ADR 0138), and callback: the path the browser is sent back to
consumer ${bound:oidc-client:…} as (= the client id), issuer, authorization-path, token-path, userinfo-path, port, at, from
consumer secrets the pair credential = the client secret, as a file
  • Client id: the grant's as, 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.
  • Client secret: the minted pair credential (ADR 0048). Keycloak generates nothing.
  • Redirect URIs: https://<name><callback>, plus https://<internal-name><callback> when the route also has an internal name. rootUrl/baseUrl are https://<first name>. A contribution with no callback (a path starting with /), or with no composed name, is refused. No client is made for it.
  • Realm: read from the issuer's /realms/<realm>. The assignment sets one value, issuer. Settings reach both keycloak's served facts and its sidecar's config.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 novox with {"issuer": "https://keycloak.novox.be/realms/Novox"}.
  • Client shape: confidential (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 flat roles claim 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.ts connects them to the sdk harness.
  • Idempotent. An existing mesh client is updated in place: the mesh's fields are laid over the representation Keycloak already has, so fields set by someone else survive. The mapper is checked separately.
  • Only what the mesh made is touched. Clients it creates carry the attribute mesh.provisioned=true. A client with the same id but no mark is refused and logged. It is never adopted, updated or deleted.
  • Removal follows postgres. When the harness withdraws a grant it applied, the client is deleted, but only if it carries the mark.
  • holds reads 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.
  • Runtime changes: it now mounts the module's own admin secret (MESH_KEYCLOAK_PASSWORD_FILE, which client.ts now reads) and the grants directory, and sets MESH_RECEIVES. Before this, config.json was {} and no admin password reached the sidecar, so keycloak's existing tools could not start either.

grafana

  • Requires oidc-client and contributes {label: grafana, endpoint: web, callback: /login/generic_oauth}.
  • The new oidc.env file is the container's env-file. It carries HAL's GF_AUTH_GENERIC_OAUTH_* settings: name Keycloak; scopes openid 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:…}.
  • The secret reaches grafana as GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE → ${dir:state}/oidc-client.secret, owned 472:472, mode 0400 (same pattern as admin). No secret is in the environment.
  • The server has restart-on: [oidc-env, oidc-secret].
  • ⚠️ GF_SERVER_ROOT_URL=https://grafana.zurag.be is a literal. Grafana builds its redirect_uri from root_url. Without it, Keycloak answers Invalid 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's KC_HOSTNAME. It goes away when 122 lands.

Migration: the existing HAL client grafana in realm Novox

Chosen: the provisioner creates a new client, mesh_ace_grafana, with a minted secret, and leaves grafana alone for the operator to delete after cutover. No secret accept is needed for this pair.

Why this is safer than taking over grafana in place:

  • Matching by the old id means guessing ownership. The provisioner would then treat a hand-made client as its own: it would reset fields someone tuned, and delete it when the grant is withdrawn. The mark and the mesh_ prefix exist so that never happens.
  • The client id is derived by the mesh. Keeping grafana would need a second naming rule on both ends.
  • Nothing needs the old secret. The ADR 0092 path (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.
  • Users are not affected. Grafana links an OAuth user by the token's 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:

  1. Merge.
  2. Build keycloak and grafana.
  3. Set keycloak's issuer for novox.
  4. Assign grafana (#153's plan). The mesh mints the pair and keycloak makes mesh_ace_grafana.
  5. Verify login.
  6. Delete the grafana client in realm Novox.

Tested

  • Typecheck and build under keycloak's own tsconfig.json, with the Dockerfile's tsc line, against @novox/mesh-sdk 0.1.1 (packed from mesh-sdk main) and TypeScript 5.9.
  • npm test (new modules/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; holds catching secret, redirect and mapper drift; an unmarked client with the same id refused with only reads made; the HAL grafana client never touched; removal; no callback means no client.
  • mesh-controller (origin/main e7da39d), MESH_CATALOGUE=<this branch> go test -count=1 ./internal/catalogue/ ./internal/inventory/ ./cmd/...: all ok. TestEveryCatalogueManifestParses ran, not skipped: 73 manifests. A scratch test, not committed, resolved the real keycloak (novox) and grafana (ace) manifests through Resolve → Declaration → ContributionsFrom:
    • grafana's oidc.env renders with client id mesh_ace_grafana and https://keycloak.novox.be/realms/Novox/protocol/openid-connect/{auth,token,userinfo}, with no placeholder left.
    • The secret file is sealed for oidc-client and owned 472:472.
    • keycloak's mesh.json carries as: mesh_ace_grafana, secret: /var/lib/keycloak/grants/ace.grafana.secret and values {callback, name: grafana.zurag.be, …}.
    • keycloak's config.json carries the settled issuer.
  • End to end on ace, throwaway containers only (removed):
    • A throwaway keycloak on the catalogue digest (Keycloak 26.0.8) with a realm Novox and a HAL-style grafana client.
    • The compiled provisioner was run from a hand-written mesh.json and a dummy pair credential. It created mesh_ace_grafana: confidential, secret equal to the pair credential, redirect https://127.0.0.1:18501/login/generic_oauth, mark, mapper. The HAL client was left unchanged.
    • Against the real admin API: a rotated secret was applied in place, a widened redirect was detected and repaired, three applies gave one client and one mapper, remove('grafana') returned "not ours", and removal worked.
    • A throwaway grafana on the pinned digest (13.2.2), configured from the manifest's oidc.env with the lab's bound values, completed the login: redirect to /realms/Novox/…/auth with client_id=mesh_ace_grafana and PKCE, Keycloak login, callback, then /api/user returned the Keycloak user with org role Admin (mapped through the roles claim).

Open / model gaps

  • Settings go to every destination. A setting lands in every mergeable file, every contribution and every served fact of the module. So keycloak's issuer setting also appears in keycloak's own postgres-database and route contribution values. This is harmless today (both providers ignore unknown keys; postgres re-applies once), but a setting cannot be scoped to one provision.
  • serves cannot be composed. The issuer and the realm cannot be derived from one another in the manifest, so the realm is parsed from the issuer.
  • Issue 122 (GF_SERVER_ROOT_URL), same as KC_HOSTNAME.
  • A realm change leaves the old realm's clients behind. The harness remembers what it applied only in memory. When the sidecar restarts with a new issuer, it starts over in the new realm and never removes the old realm's clients. Postgres has the same limit for a withdrawn grant across a restart.
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 | | | |---|---| | keycloak `provides` | `oidc-client`, scope `mesh` | | keycloak `serves` | `issuer` (default `https://keycloak.novox.be/realms/master`), `authorization-path`, `token-path`, `userinfo-path` (Keycloak's `/protocol/openid-connect/*`), plus the `port` the mesh adds | | keycloak `grants` / `receives` | `/var/lib/keycloak/grants`, `/var/lib/keycloak/grants/mesh.json` (same shape as postgres) | | consumer contributes | `label` + `endpoint` (the mesh composes `name` / `internal-name` from these, as it does for a route, including reach per ADR 0138), and **`callback`**: the path the browser is sent back to | | consumer `${bound:oidc-client:…}` | `as` (= the client id), `issuer`, `authorization-path`, `token-path`, `userinfo-path`, `port`, `at`, `from` | | consumer `secrets` | the pair credential = the client secret, as a file | - **Client id:** the grant's `as`, 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. - **Client secret:** the minted pair credential (ADR 0048). Keycloak generates nothing. - **Redirect URIs:** `https://<name><callback>`, plus `https://<internal-name><callback>` when the route also has an internal name. `rootUrl`/`baseUrl` are `https://<first name>`. A contribution with no `callback` (a path starting with `/`), or with no composed name, is refused. No client is made for it. - **Realm:** read from the issuer's `/realms/<realm>`. The assignment sets one value, `issuer`. Settings reach both keycloak's served facts and its sidecar's `config.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 novox` with `{"issuer": "https://keycloak.novox.be/realms/Novox"}`. - **Client shape:** confidential (`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 flat `roles` claim 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.ts` connects them to the sdk harness. - **Idempotent.** An existing mesh client is updated in place: the mesh's fields are laid over the representation Keycloak already has, so fields set by someone else survive. The mapper is checked separately. - **Only what the mesh made is touched.** Clients it creates carry the attribute `mesh.provisioned=true`. A client with the same id but no mark is refused and logged. It is never adopted, updated or deleted. - **Removal follows postgres.** When the harness withdraws a grant it applied, the client is deleted, but only if it carries the mark. - **`holds`** reads 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. - **Runtime changes:** it now mounts the module's own `admin` secret (`MESH_KEYCLOAK_PASSWORD_FILE`, which `client.ts` now reads) and the grants directory, and sets `MESH_RECEIVES`. Before this, `config.json` was `{}` and no admin password reached the sidecar, so keycloak's existing tools could not start either. ## grafana - Requires `oidc-client` and contributes `{label: grafana, endpoint: web, callback: /login/generic_oauth}`. - The new `oidc.env` file is the container's `env-file`. It carries HAL's `GF_AUTH_GENERIC_OAUTH_*` settings: name Keycloak; scopes `openid 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:…}`. - The secret reaches grafana as `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE` → `${dir:state}/oidc-client.secret`, owned 472:472, mode 0400 (same pattern as `admin`). No secret is in the environment. - The server has `restart-on: [oidc-env, oidc-secret]`. - ⚠️ **`GF_SERVER_ROOT_URL=https://grafana.zurag.be` is a literal.** Grafana builds its `redirect_uri` from `root_url`. Without it, Keycloak answers `Invalid 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's `KC_HOSTNAME`. It goes away when 122 lands. ## Migration: the existing HAL client `grafana` in realm Novox **Chosen: the provisioner creates a new client, `mesh_ace_grafana`, with a minted secret, and leaves `grafana` alone for the operator to delete after cutover.** No `secret accept` is needed for this pair. Why this is safer than taking over `grafana` in place: - Matching by the old id means guessing ownership. The provisioner would then treat a hand-made client as its own: it would reset fields someone tuned, and delete it when the grant is withdrawn. The mark and the `mesh_` prefix exist so that never happens. - The client id is derived by the mesh. Keeping `grafana` would need a second naming rule on both ends. - Nothing needs the old secret. The ADR 0092 path (`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. - **Users are not affected.** Grafana links an OAuth user by the token's `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: 1. Merge. 2. Build keycloak and grafana. 3. Set keycloak's `issuer` for novox. 4. Assign grafana (#153's plan). The mesh mints the pair and keycloak makes `mesh_ace_grafana`. 5. Verify login. 6. Delete the `grafana` client in realm Novox. ## Tested - **Typecheck and build** under keycloak's own `tsconfig.json`, with the Dockerfile's `tsc` line, against `@novox/mesh-sdk` 0.1.1 (packed from mesh-sdk main) and TypeScript 5.9. - **`npm test`** (new `modules/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; `holds` catching secret, redirect and mapper drift; an unmarked client with the same id refused with only reads made; the HAL `grafana` client never touched; removal; no callback means no client. - **mesh-controller (origin/main e7da39d)**, `MESH_CATALOGUE=<this branch> go test -count=1 ./internal/catalogue/ ./internal/inventory/ ./cmd/...`: all ok. `TestEveryCatalogueManifestParses` ran, not skipped: 73 manifests. A scratch test, not committed, resolved the real keycloak (novox) and grafana (ace) manifests through `Resolve` → `Declaration` → `ContributionsFrom`: - grafana's `oidc.env` renders with client id `mesh_ace_grafana` and `https://keycloak.novox.be/realms/Novox/protocol/openid-connect/{auth,token,userinfo}`, with no placeholder left. - The secret file is sealed for `oidc-client` and owned 472:472. - keycloak's `mesh.json` carries `as: mesh_ace_grafana`, `secret: /var/lib/keycloak/grants/ace.grafana.secret` and `values {callback, name: grafana.zurag.be, …}`. - keycloak's `config.json` carries the settled issuer. - **End to end on ace**, throwaway containers only (removed): - A throwaway keycloak on the catalogue digest (Keycloak 26.0.8) with a realm Novox and a HAL-style `grafana` client. - The **compiled provisioner** was run from a hand-written `mesh.json` and a dummy pair credential. It created `mesh_ace_grafana`: confidential, secret equal to the pair credential, redirect `https://127.0.0.1:18501/login/generic_oauth`, mark, mapper. The HAL client was left unchanged. - Against the real admin API: a rotated secret was applied in place, a widened redirect was detected and repaired, three applies gave one client and one mapper, `remove('grafana')` returned "not ours", and removal worked. - A throwaway grafana on the pinned digest (13.2.2), configured from the manifest's `oidc.env` with the lab's bound values, **completed the login**: redirect to `/realms/Novox/…/auth` with `client_id=mesh_ace_grafana` and PKCE, Keycloak login, callback, then `/api/user` returned the Keycloak user with org role **Admin** (mapped through the `roles` claim). ## Open / model gaps - **Settings go to every destination.** A setting lands in every mergeable file, every contribution and every served fact of the module. So keycloak's `issuer` setting also appears in keycloak's own `postgres-database` and `route` contribution values. This is harmless today (both providers ignore unknown keys; postgres re-applies once), but a setting cannot be scoped to one provision. - **`serves` cannot be composed.** The issuer and the realm cannot be derived from one another in the manifest, so the realm is parsed from the issuer. - **Issue 122** (`GF_SERVER_ROOT_URL`), same as `KC_HOSTNAME`. - **A realm change leaves the old realm's clients behind.** The harness remembers what it applied only in memory. When the sidecar restarts with a new issuer, it starts over in the new realm and never removes the old realm's clients. Postgres has the same limit for a withdrawn grant across a restart.
mesh-admin added 3 commits 2026-09-29 22:39:57 +00:00
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.
HAL's grafana logged in through a hand-made Keycloak client whose secret sat
in its .env. Requiring oidc-client gives it a client the mesh makes and keeps:
the id and URLs come from the binding, the secret arrives as a file grafana
reads itself (__FILE), and the callback it contributes is what keycloak
registers as its redirect.

GF_SERVER_ROOT_URL is still a literal: a module cannot yet learn the public
name the mesh composes for its own endpoint (hq issue 122), and without it
grafana sends a redirect Keycloak refuses.
Author
Contributor

Review — changes needed before merge

The provision itself is right: grant-derived client ids, pair credential as the client secret, the mesh.provisioned mark (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").

  1. grafana: GF_SERVER_ROOT_URL=https://grafana.zurag.be — wrong on every other machine.
  2. keycloak: default issuer https://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 live route.json on ace has as/at/from/serves only). Fixing 122 there — e.g. name / internal-name in the route binding, so grafana writes https://${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.

## Review — changes needed before merge The provision itself is right: grant-derived client ids, pair credential as the client secret, the `mesh.provisioned` mark (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"). 1. grafana: `GF_SERVER_ROOT_URL=https://grafana.zurag.be` — wrong on every other machine. 2. keycloak: default `issuer` `https://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 live `route.json` on ace has as/at/from/serves only). Fixing 122 there — e.g. `name` / `internal-name` in the route binding, so grafana writes `https://${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.
Author
Contributor

Reworked (commit above): the three domain literals are gone — GF_SERVER_ROOT_URL and keycloak's KC_HOSTNAME come 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.

Reworked (commit above): the three domain literals are gone — `GF_SERVER_ROOT_URL` and keycloak's `KC_HOSTNAME` come 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.
jschoubben added 1 commit 2026-09-29 22:49:10 +00:00
GF_SERVER_ROOT_URL=https://grafana.zurag.be, KC_HOSTNAME=https://keycloak.novox.be
and the served issuer's novox default were domains in definitions — wrong on
every other machine (ADR 0112). The names now come from ${bound:route:name}
(mesh-controller #149, hq 122): grafana's in oidc.env, keycloak's in a
hostname.env its server reads. The issuer includes the realm and stays the
assignment's, with no default: unset, a consumer asking for it is refused
and the provisioner says so, rather than both quietly using novox's URL.

Rendered through mesh-controller #149 from these manifests on a zurag.be
node: KC_HOSTNAME=https://keycloak.zurag.be, GF_SERVER_ROOT_URL=
https://grafana.zurag.be, OIDC URLs from the issuer setting. Needs #149
merged and rolled out first.
jschoubben added 4 commits 2026-09-30 11:02:26 +00:00
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.
grafana's data source and Node-RED's influxdb nodes reached ace's
InfluxDB by a LAN IP or a public name nobody routes, with a credential
somebody made by hand. Now a consumer requires influxdb-api and is told
where it is, which org and default bucket it serves, and signs in with
the password the mesh minted for the pair.

The credential is a v1-compatibility authorization, made per grant by
the new provisioner: InfluxDB 2.x generates API tokens itself and
ignores one the caller sends, so a v2 token could only be accepted by
hand per pair; a v1 authorization takes a caller-chosen password (8-72
characters, the mesh mints 40) and reads/writes every bucket as a
database of its name over InfluxQL and line protocol. A consumer
contributes `access` (read, write, read-write) and, for writing, the
buckets; a missing bucket is made and never deleted. Only
authorizations named mesh_* and marked [mesh] are ever changed or
removed; anything else of that name is refused and left alone.

The org and default bucket are served facts the assignment's settings
set, reaching both the consumers and the provisioner's config.json.
grafana's data source requires influxdb-api, which influxdb provides only
on #152's branch; merged so this branch's catalogue has the provider of
everything grafana requires. #152 should merge first.
ace's grafana reads InfluxDB through a data source somebody typed into
its database: a LAN address, a database InfluxDB 2 does not have, and a
password for a v1 user of an earlier instance. Nothing in the mesh knew
it existed, so migrating influxdb could only break it further.

grafana now requires influxdb-api, contributes read access, and the mesh
renders a provisioning file grafana reads at start: the address, port,
org's default bucket (as the InfluxQL database) and its own login from
the binding, the password by $__file from the pair credential the mesh
delivers, 0400 for grafana's uid 472. It is a data source of its own
name and uid, read-only in the UI and not the default, so the data
source a person made is never overwritten; a changed binding or a
rotated password restarts grafana, which re-reads the file.

Includes #152 (merged into this branch): influxdb provides influxdb-api.
Author
Contributor

New on this branch: grafana's InfluxDB data source comes from influxdb-api (d2f0373)

  • Includes #152. feat/influxdb-for-ace is merged in (c5e273e, a merge, not a rebase) so that this branch's catalogue has a provider for everything grafana requires. Merge #152 first.
  • What grafana gets: grafana requires influxdb-api and contributes {access: read}, which is read access to every bucket in the org. The mesh renders datasource-influxdb.yaml, a Grafana provisioning file mounted into /etc/grafana/provisioning/datasources/:
    • The URL is ${bound:influxdb-api:scheme}://…:at:…:port.
    • The user is ${bound:influxdb-api:as} and the InfluxQL database is ${bound:influxdb-api:bucket}.
    • The password is $__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.
    • Grafana restarts, and so re-reads the file, when the file or the secret changes.
  • Doesn't overwrite anything a person made. The data source has its own name and uid (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 with isDefault: false.
  • E2E: throwaway grafana 13.2.2 (the pinned digest) plus InfluxDB 2.9.1 with the provisioner.
    • /api/datasources/uid/mesh-influxdb-api/health returned "datasource is working. 1 measurements found".
    • /api/ds/query returned data.
    • After a password rotation the health check returned OK again once grafana restarted.
  • MESH_CATALOGUE=<this branch> go test ./internal/catalogue/ passes.
  • Migration note for ace: the "3D Printing" dashboard follows the default data source. That is still the old user-made one, which points at a LAN IP and a database sensors that InfluxDB 2 doesn't have. To show data, the operator switches the dashboard (or the default) to InfluxDB (mesh) in the UI. The mesh deliberately doesn't do that.
**New on this branch: grafana's InfluxDB data source comes from `influxdb-api` (d2f0373)** - **Includes #152.** `feat/influxdb-for-ace` is merged in (c5e273e, a merge, not a rebase) so that this branch's catalogue has a provider for everything grafana requires. **Merge #152 first.** - **What grafana gets:** grafana `requires` `influxdb-api` and contributes `{access: read}`, which is read access to every bucket in the org. The mesh renders `datasource-influxdb.yaml`, a Grafana provisioning file mounted into `/etc/grafana/provisioning/datasources/`: - The URL is `${bound:influxdb-api:scheme}://…:at:…:port`. - The user is `${bound:influxdb-api:as}` and the InfluxQL database is `${bound:influxdb-api:bucket}`. - The password is `$__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. - Grafana restarts, and so re-reads the file, when the file or the secret changes. - **Doesn't overwrite anything a person made.** The data source has its own name and uid (`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 with `isDefault: false`. - **E2E:** throwaway grafana 13.2.2 (the pinned digest) plus InfluxDB 2.9.1 with the provisioner. - `/api/datasources/uid/mesh-influxdb-api/health` returned "datasource is working. 1 measurements found". - `/api/ds/query` returned data. - After a password rotation the health check returned OK again once grafana restarted. - `MESH_CATALOGUE=<this branch> go test ./internal/catalogue/` passes. - **Migration note for ace:** the "3D Printing" dashboard follows the default data source. That is still the old user-made one, which points at a LAN IP and a database `sensors` that InfluxDB 2 doesn't have. To show data, the operator switches the dashboard (or the default) to `InfluxDB (mesh)` in the UI. The mesh deliberately doesn't do that.
You are not authorized to merge this pull request.
This pull request can be merged automatically.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/oidc-client-provision:feat/oidc-client-provision
git checkout feat/oidc-client-provision
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-catalog#155