Compare commits

..
Author SHA1 Message Date
jschoubben fe0ed3b74e influxdb: place its directories, hand secrets over as files, name its UI
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.
2026-09-29 23:42:18 +02:00
11 changed files with 74 additions and 720 deletions
+16 -49
View File
@@ -5,7 +5,7 @@
"alert.firing"
],
"own-secrets": {
"admin": "/var/lib/mesh/grafana/admin",
"admin": "/var/lib/grafana-module/admin.secret",
"broker": "/var/lib/mesh/grafana/broker"
},
"capabilities": [
@@ -30,68 +30,45 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/grafana-module",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/grafana/data",
"mode": "0700",
"owner": "472:472"
},
{
"id": "admin-secret",
"id": "server-env",
"type": "file",
"path": "${dir:state}/admin.secret",
"mode": "0400",
"owner": "472:472",
"content": "${secret:admin}"
},
{
"id": "oidc-secret",
"type": "file",
"path": "${dir:state}/oidc-client.secret",
"mode": "0400",
"owner": "472:472",
"content": "${secret:oidc-client}"
},
{
"id": "oidc-env",
"type": "file",
"path": "${dir:state}/oidc.env",
"mode": "0644",
"content": "GF_SERVER_ROOT_URL=https://${bound:route:name}\nGF_AUTH_GENERIC_OAUTH_ENABLED=true\nGF_AUTH_GENERIC_OAUTH_NAME=Keycloak\nGF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as}\nGF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client\nGF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles\nGF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path}\nGF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path}\nGF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path}\nGF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer'\nGF_AUTH_GENERIC_OAUTH_USE_PKCE=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true\n"
"path": "/var/lib/grafana-module/server.env",
"mode": "0600",
"content": "GF_SECURITY_ADMIN_PASSWORD=${secret:admin}\n"
},
{
"id": "server",
"type": "container",
"name": "grafana",
"image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0",
"image": "grafana/grafana@sha256:f772d434e8fab0049deb2b1b30abd43342bcfca1537614aa8d36080232cf4283",
"ports": [
"3000"
],
"volumes": [
"${dir:data}:/var/lib/grafana",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:state}/oidc-client.secret:/run/secrets/oidc-client:ro"
"/services/grafana/data:/var/lib/grafana"
],
"env": {
"GF_SECURITY_ADMIN_PASSWORD__FILE": "/run/secrets/admin"
},
"env-file": [
"${dir:state}/oidc.env"
"/var/lib/grafana-module/server.env"
],
"restart-on": [
"oidc-env",
"oidc-secret"
]
"secrets-in-environment": "grafana honours GF_SECURITY_ADMIN_PASSWORD__FILE; convertible, awaiting a bed that exercises the admin password (assigned-grafana serves tools only)"
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/grafana/config.json",
"mode": "0600",
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
"content": "{}\n",
"merge": "json"
},
{
@@ -105,7 +82,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_URL": "http://127.0.0.1:3000",
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
@@ -115,26 +92,16 @@
}
],
"requires": [
"route",
"oidc-client"
"route"
],
"contributes": {
"route": {
"label": "grafana",
"endpoint": "web"
},
"oidc-client": {
"label": "grafana",
"endpoint": "web",
"callback": "/login/generic_oauth"
}
},
"binds": {
"route": "${dir:state}/route.json",
"oidc-client": "${dir:state}/oidc.json"
},
"secrets": {
"oidc-client": "/var/lib/mesh/grafana/oidc-client"
"route": "/var/lib/mesh/grafana/route.json"
},
"build": {
"on": [
+11 -2
View File
@@ -24,6 +24,13 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/** A secret delivered as a file, trimmed; undefined when there is none, so the caller can fall back. */
function tokenFromFile(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim() || undefined; }
catch { return undefined; }
}
export class InfluxDBClient {
readonly baseUrl: string;
@@ -43,8 +50,10 @@ export class InfluxDBClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE);
const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
// The token reaches the process as a file (novox/hq ADR 0086); the environment variable stays
// only for a workstation running the tools by hand.
const token = cfg.token ?? tokenFromFile(env.MESH_INFLUXDB_TOKEN_FILE) ?? env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN_FILE");
const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
return new InfluxDBClient(url, token, org);
}
+35 -29
View File
@@ -13,7 +13,7 @@
"port": 8086,
"protocol": "tcp",
"from": "mesh",
"why": "queries and writes, over http"
"why": "queries, writes and the web UI, over http; a name is a route grant"
}
],
"resources": [
@@ -26,46 +26,45 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/influxdb-module",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/influxdb-module/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD=${secret:admin}\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${secret:admin-token}\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
"mode": "0700",
"place": "."
},
{
"id": "data",
"type": "directory",
"path": "/services/influxdb/data",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "config",
"type": "directory",
"path": "/services/influxdb/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
},
{
"id": "server",
"type": "container",
"name": "influxdb",
"image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa",
"env-file": [
"/var/lib/influxdb-module/server.env"
"${dir:state}/server.env"
],
"ports": [
"8086"
],
"volumes": [
"/services/influxdb/data:/var/lib/influxdb2",
"/services/influxdb/config:/etc/influxdb2"
],
"secrets-in-environment": "the image honours DOCKER_INFLUXDB_INIT_PASSWORD_FILE and _ADMIN_TOKEN_FILE; convertible, awaiting a bed that proves it"
"${dir:data}:/var/lib/influxdb2",
"${dir:config}:/etc/influxdb2",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro"
]
},
{
"id": "runtime-config",
@@ -83,13 +82,13 @@
"volumes": [
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
"/services/influxdb/config:/var/lib/influxdb/config:ro"
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086",
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config"
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token"
},
"restart-on": [
"runtime-config"
@@ -97,6 +96,22 @@
"artifact": "runtime"
}
],
"requires": [
"route",
"secret"
],
"contributes": {
"route": {
"label": "influxdb",
"endpoint": "api"
}
},
"secrets": {
"secret": {
"admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret"
}
},
"build": {
"on": [
{
@@ -117,14 +132,5 @@
"from": "Dockerfile"
}
]
},
"requires": [
"secret"
],
"secrets": {
"secret": {
"admin": "/var/lib/influxdb-module/admin.secret",
"admin-token": "/var/lib/influxdb-module/admin-token.secret"
}
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build
# resolved away.
WORKDIR /app/modules/keycloak
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -27,4 +27,4 @@ COPY --from=build /app/modules/keycloak/dist /app/modules/keycloak/dist
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js,/app/modules/keycloak/dist/provisioner/index.js
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js
+2 -90
View File
@@ -12,45 +12,6 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/** A secret file's value, trailing newline trimmed; undefined when unset or unreadable. */
function secretFile(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").replace(/\n$/, "") || undefined; }
catch { return undefined; }
}
/** A client as the admin API represents it — only the fields this module reads or writes are typed;
* the rest travel through untouched, so an update never drops what somebody else set. */
export interface ClientRepresentation {
id?: string;
clientId: string;
name?: string;
enabled?: boolean;
protocol?: string;
publicClient?: boolean;
clientAuthenticatorType?: string;
secret?: string;
rootUrl?: string;
baseUrl?: string;
redirectUris?: string[];
webOrigins?: string[];
standardFlowEnabled?: boolean;
implicitFlowEnabled?: boolean;
directAccessGrantsEnabled?: boolean;
serviceAccountsEnabled?: boolean;
attributes?: Record<string, string>;
protocolMappers?: ProtocolMapperRepresentation[];
[other: string]: unknown;
}
export interface ProtocolMapperRepresentation {
id?: string;
name: string;
protocol: string;
protocolMapper: string;
config: Record<string, string>;
}
export class KeycloakClient {
readonly baseUrl: string;
readonly defaultRealm: string;
@@ -79,13 +40,8 @@ export class KeycloakClient {
const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE);
const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`;
const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin";
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
// secret, mounted read-only. The environment forms stay for a co-located server that has them.
const adminPass = cfg.password ?? secretFile(env.MESH_KEYCLOAK_PASSWORD_FILE)
?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
if (!adminPass) {
throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)");
}
const adminPass = cfg.password ?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD");
const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
return new KeycloakClient(url, adminUser, adminPass, realm);
}
@@ -203,50 +159,6 @@ export class KeycloakClient {
return client.id as string;
}
/** The one client with exactly this clientId, or undefined. The admin API's `clientId` filter is an
* exact match unless `search=true` is asked for. */
async findClient(realm: string, clientId: string): Promise<ClientRepresentation | undefined> {
const found = await this.request<ClientRepresentation[]>(
`/${realm}/clients?clientId=${encodeURIComponent(clientId)}`);
return found.find((c) => c.clientId === clientId);
}
async createClientFrom(realm: string, rep: ClientRepresentation): Promise<void> {
await this.request(`/${realm}/clients`, { method: "POST", body: JSON.stringify(rep) });
}
/** Replace a client's representation, addressed by its internal id. */
async updateClient(realm: string, id: string, rep: ClientRepresentation): Promise<void> {
await this.request(`/${realm}/clients/${id}`, { method: "PUT", body: JSON.stringify(rep) });
}
async deleteClientById(realm: string, id: string): Promise<void> {
await this.request(`/${realm}/clients/${id}`, { method: "DELETE" });
}
async clientSecretById(realm: string, id: string): Promise<string | undefined> {
const result = await this.request<{ value?: string }>(`/${realm}/clients/${id}/client-secret`);
return result.value;
}
async listClientMappers(realm: string, id: string): Promise<ProtocolMapperRepresentation[]> {
return this.request(`/${realm}/clients/${id}/protocol-mappers/models`);
}
async addClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
method: "POST",
body: JSON.stringify(mapper),
});
}
async updateClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
await this.request(`/${realm}/clients/${id}/protocol-mappers/models/${mapper.id}`, {
method: "PUT",
body: JSON.stringify(mapper),
});
}
async deleteClient(realm: string, clientId: string): Promise<void> {
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
}
+5 -44
View File
@@ -1,12 +1,6 @@
{
"module": "keycloak",
"version": "1",
"provides": [
{
"name": "oidc-client",
"scope": "mesh"
}
],
"requires": [
"postgres-database",
"route"
@@ -47,19 +41,6 @@
"why": "anything the mesh runs that authenticates a person"
}
],
"serves": {
"oidc-client": {
"authorization-path": "/protocol/openid-connect/auth",
"token-path": "/protocol/openid-connect/token",
"userinfo-path": "/protocol/openid-connect/userinfo"
}
},
"receives": {
"oidc-client": "/var/lib/keycloak/grants/mesh.json"
},
"grants": {
"oidc-client": "/var/lib/keycloak/grants"
},
"own-secrets": {
"admin": "/var/lib/keycloak/admin.secret",
"broker": "/var/lib/mesh/keycloak/broker"
@@ -77,12 +58,6 @@
"path": "/var/lib/keycloak",
"mode": "0700"
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/keycloak/grants",
"mode": "0700"
},
{
"id": "admin-env",
"type": "file",
@@ -102,13 +77,6 @@
"type": "network",
"name": "keycloak"
},
{
"id": "hostname",
"type": "file",
"path": "/var/lib/keycloak/hostname.env",
"mode": "0644",
"content": "KC_HOSTNAME=https://${bound:route:name}\n"
},
{
"id": "server",
"type": "container",
@@ -122,20 +90,17 @@
"KC_DB": "postgres",
"KC_HTTP_ENABLED": "true",
"KC_HEALTH_ENABLED": "true",
"KC_HOSTNAME": "https://keycloak.novox.be",
"KC_PROXY_HEADERS": "xforwarded"
},
"env-file": [
"/var/lib/keycloak/admin.env",
"/var/lib/keycloak/database.env",
"/var/lib/keycloak/hostname.env"
"/var/lib/keycloak/database.env"
],
"ports": [
"8080"
],
"secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted",
"restart-on": [
"hostname"
]
"secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted"
},
{
"id": "runtime-config",
@@ -152,16 +117,12 @@
"network": "host",
"volumes": [
"/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro",
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro",
"/var/lib/keycloak/admin.secret:/run/secrets/admin:ro",
"/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro"
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "/var/lib/keycloak/grants/mesh.json"
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
-185
View File
@@ -1,185 +0,0 @@
// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per
// consumer, in the realm this module serves, under the name and secret the mesh gave both ends.
// The provisioner (provisioner/index.ts) is the sdk harness calling these; they are here, apart from
// it, so they can be exercised against a fake admin API without a broker or a contributions file.
//
// **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh
// derives the consumer's identity (`as`, e.g. `mesh_ace_grafana`) and hands it to both ends — the
// consumer names it as its client id through `${bound:oidc-client:as}` — and mints the secret, which
// this sets as the client's secret. Keycloak generates neither.
//
// **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries
// `callback` (a path, e.g. `/login/generic_oauth`) and the `label`/`endpoint` of the endpoint it is
// reached on; the mesh composes that endpoint's names into `name` (public) and `internal-name`
// (private network) exactly as it does for a route (novox/hq ADR 0056, 0138), so the redirect URI
// registered here is built from the same names the proxy serves the consumer under.
//
// **Only what the mesh made is touched.** A client this module creates carries the attribute
// `mesh.provisioned=true`, and its id starts with the mesh's own prefix. A client with the same id
// that lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
import type { ClientRepresentation, KeycloakClient, ProtocolMapperRepresentation } from "./client.js";
/** The attribute marking a client as the mesh's own work. */
export const MARK = "mesh.provisioned";
/** The mapper every mesh client carries: realm roles as a flat `roles` claim in the id token, the
* access token and userinfo — what a consumer maps its own roles from (grafana's role path reads
* `roles[*]`), and what the predecessor added to its hand-made clients by hand. */
export const ROLES_MAPPER: ProtocolMapperRepresentation = {
name: "realm roles",
protocol: "openid-connect",
protocolMapper: "oidc-usermodel-realm-role-mapper",
config: {
"claim.name": "roles",
"jsonType.label": "String",
multivalued: "true",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
},
};
/** One consumer, as the harness hands it over. */
export interface OidcGrant {
readonly as: string;
readonly password: string;
readonly values: Readonly<Record<string, unknown>>;
readonly consumer?: string;
}
/** The realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`. The issuer is
* the one value an assignment sets (it is also what consumers are served), so the realm is read
* out of it rather than set a second time where the two could disagree. */
export function realmOf(issuer: string): string {
let path: string;
try {
path = new URL(issuer).pathname;
} catch {
throw new Error(`the issuer ${JSON.stringify(issuer)} is not a URL`);
}
const m = /\/realms\/([^/]+)\/?$/.exec(path);
if (!m) throw new Error(`the issuer ${JSON.stringify(issuer)} does not end in /realms/<realm>`);
return decodeURIComponent(m[1]);
}
/** The redirect URIs a consumer's contribution asks for: its callback under each name the mesh
* composed for its endpoint. Refused when there is nothing to register — a client that accepts no
* redirect is a client nobody can log in through, and one that accepts any is worse. */
export function redirectsOf(values: Readonly<Record<string, unknown>>): { root: string; redirects: string[] } {
const callback = values.callback;
if (typeof callback !== "string" || !callback.startsWith("/")) {
throw new Error(`contributes no callback path (\`callback\`, starting with "/"): ${JSON.stringify(callback)}`);
}
const names: string[] = [];
for (const key of ["name", "internal-name"]) {
const n = values[key];
if (typeof n === "string" && n.trim() !== "" && !names.includes(n.trim())) names.push(n.trim());
}
if (names.length === 0) {
throw new Error("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on");
}
return { root: `https://${names[0]}`, redirects: names.map((n) => `https://${n}${callback}`) };
}
/** The fields the mesh owns on a client it made. Everything else on the client is left as found. */
function wanted(g: OidcGrant): ClientRepresentation {
const { root, redirects } = redirectsOf(g.values);
return {
clientId: g.as,
name: g.as,
description: `made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`,
enabled: true,
protocol: "openid-connect",
publicClient: false,
clientAuthenticatorType: "client-secret",
secret: g.password,
rootUrl: root,
baseUrl: root,
redirectUris: redirects,
standardFlowEnabled: true,
implicitFlowEnabled: false,
directAccessGrantsEnabled: false,
serviceAccountsEnabled: false,
};
}
function sameSet(a: readonly string[] | undefined, b: readonly string[]): boolean {
const x = [...(a ?? [])].sort();
const y = [...b].sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
function marked(c: ClientRepresentation): boolean {
return c.attributes?.[MARK] === "true";
}
export class OidcClients {
constructor(private readonly kc: KeycloakClient, readonly realm: string) {}
/** Create the consumer's client, or bring the mesh's existing one back to what the grant says.
* Returns whether it was newly created. Idempotent: applying the same grant twice changes nothing
* the second time beyond re-asserting it. */
async ensure(g: OidcGrant): Promise<"created" | "updated"> {
const want = wanted(g);
const found = await this.kc.findClient(this.realm, g.as);
if (found && !marked(found)) {
throw new Error(
`realm ${this.realm} already has a client ${g.as} the mesh did not make — left alone; ` +
`delete or rename it if the mesh should own that id`);
}
if (!found) {
await this.kc.createClientFrom(this.realm, {
...want,
attributes: { [MARK]: "true" },
protocolMappers: [ROLES_MAPPER],
});
return "created";
}
// Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a
// field the mesh does not own survives the update.
await this.kc.updateClient(this.realm, found.id!, {
...found,
...want,
attributes: { ...(found.attributes ?? {}), [MARK]: "true" },
});
await this.ensureMapper(found.id!);
return "updated";
}
private async ensureMapper(id: string): Promise<void> {
const mappers = await this.kc.listClientMappers(this.realm, id);
const have = mappers.find((m) => m.name === ROLES_MAPPER.name);
if (!have) {
await this.kc.addClientMapper(this.realm, id, ROLES_MAPPER);
return;
}
const drifted =
have.protocolMapper !== ROLES_MAPPER.protocolMapper ||
Object.entries(ROLES_MAPPER.config).some(([k, v]) => have.config?.[k] !== v);
if (drifted) {
await this.kc.updateClientMapper(this.realm, id, { ...ROLES_MAPPER, id: have.id });
}
}
/** Whether Keycloak still holds this consumer's client exactly as the grant says: present, the
* mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only. */
async holds(g: OidcGrant): Promise<boolean> {
const want = wanted(g);
const found = await this.kc.findClient(this.realm, g.as);
if (!found || !marked(found) || found.enabled === false || found.publicClient) return false;
if (!sameSet(found.redirectUris, want.redirectUris!)) return false;
const mappers = await this.kc.listClientMappers(this.realm, found.id!);
if (!mappers.some((m) => m.name === ROLES_MAPPER.name)) return false;
return (await this.kc.clientSecretById(this.realm, found.id!)) === g.password;
}
/** Withdraw a consumer's client — only one the mesh made. Returns what happened, for the log. */
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
const found = await this.kc.findClient(this.realm, as);
if (!found) return "absent";
if (!marked(found)) return "not ours";
await this.kc.deleteClientById(this.realm, found.id!);
return "removed";
}
}
+2 -6
View File
@@ -1,15 +1,11 @@
{
"name": "@novox/module-keycloak",
"version": "0.1.0",
"description": "keycloak — identity and access; provides the mesh oidc-client interface. Its admin API client, provisioner, tools and events live here (novox/hq ADR 0039).",
"description": "keycloak — identity and access. Its admin API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
-73
View File
@@ -1,73 +0,0 @@
// keycloak's provisioner — the adapter that makes keycloak a provider of the mesh `oidc-client`
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
// sdk harness's; this writes only the per-service half: how Keycloak creates, checks and removes a
// consumer's client (novox/hq ADR 0039/0040/0048). What a client is, and which ones are the mesh's,
// is in ../oidc.ts.
//
// The `oidc-client` interface: a consumer logs people in through the realm this module serves, as
// the confidential client `as` with the secret the mesh minted, and is redirected back to the
// callback it contributed under the names the mesh composed for its endpoint. What it is served —
// the issuer and the endpoint paths under it — is in the manifest's `serves`, settled with the
// assignment's settings.
//
// **The realm is read out of the issuer**, the one value an assignment sets (settings reach both the
// served facts and this module's config.json): a realm set in one place and an issuer in another
// would let the consumer be told one realm while its client is made in another.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { readFileSync } from "node:fs";
import { KeycloakClient } from "../client.js";
import { OidcClients, realmOf } from "../oidc.js";
/** The issuer this assignment serves, from the settings-merged config the mesh delivers. */
function issuer(): string {
const file = process.env.MESH_KEYCLOAK_CONFIG_FILE;
let cfg: Record<string, unknown> = {};
if (file) {
try {
cfg = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
} catch {
// Absent or unreadable: fall through to the environment, and refuse below if that is empty too.
}
}
const said = typeof cfg.issuer === "string" ? cfg.issuer : process.env.MESH_KEYCLOAK_ISSUER;
if (!said) throw new Error("no issuer — the module's config.json carries none and MESH_KEYCLOAK_ISSUER is unset");
return said;
}
const clients = new OidcClients(KeycloakClient.fromEnv(), realmOf(issuer()));
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
async function announce(type: string, body: Record<string, string>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:oidc-client] emit ${type} failed: ${err}`);
}
}
runProvisioner("oidc-client", {
async create(p: Provision): Promise<void> {
const done = await clients.ensure(p);
if (done === "created") {
console.log(`[provisioner:oidc-client] created client ${p.as} in realm ${clients.realm}`);
await announce("client.created", { realm: clients.realm, clientId: p.as, consumer: p.consumer ?? "" });
}
},
async remove(p: { as: string }): Promise<void> {
const done = await clients.remove(p.as);
if (done === "not ours") {
console.error(`[provisioner:oidc-client] ${p.as}: a client of that id exists that the mesh did not make — left alone`);
} else if (done === "removed") {
console.log(`[provisioner:oidc-client] removed client ${p.as} from realm ${clients.realm}`);
}
},
// Asked every minute by the harness: whether Keycloak still holds this consumer's client exactly as
// the mesh gave it, so a client deleted or edited behind the mesh's back is made again (hq issue 120).
async holds(p: Provision): Promise<boolean> {
return clients.holds(p);
},
});
-239
View File
@@ -1,239 +0,0 @@
// What holds keycloak to the `oidc-client` provision (oidc.ts): one confidential client per consumer,
// under the id and secret the mesh gave, redirecting only to the consumer's own callback under the
// names the mesh composed; made once and brought back on every apply; and a client the mesh did not
// make — same id or not — never adopted, changed or deleted.
//
// Keycloak is a fake: the admin routes the module touches, answering with the status codes and the
// shapes Keycloak gives. Run against the compiled module (npm test builds first), the way the runtime
// loads it.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { KeycloakClient } from "../dist/client.js";
import { MARK, OidcClients, ROLES_MAPPER, realmOf, redirectsOf } from "../dist/oidc.js";
type Client = Record<string, any>;
/** The realm's clients, by internal id, and what the fake was asked. */
const realm = "Novox";
const clients = new Map<string, Client>();
const calls: string[] = [];
function body(req: IncomingMessage): Promise<any> {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
});
}
function send(res: ServerResponse, status: number, value?: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(value === undefined ? "" : JSON.stringify(value));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://fake");
calls.push(`${req.method} ${url.pathname}`);
if (url.pathname === "/realms/master/protocol/openid-connect/token") {
return send(res, 200, { access_token: "t", expires_in: 300 });
}
const base = `/admin/realms/${realm}/clients`;
if (!url.pathname.startsWith(base)) return send(res, 404, { error: "Realm not found." });
const rest = url.pathname.slice(base.length).split("/").filter(Boolean);
if (rest.length === 0 && req.method === "GET") {
const want = url.searchParams.get("clientId");
return send(res, 200, [...clients.values()].filter((c) => !want || c.clientId === want));
}
if (rest.length === 0 && req.method === "POST") {
const rep = await body(req);
if ([...clients.values()].some((c) => c.clientId === rep.clientId)) {
return send(res, 409, { errorMessage: `Client ${rep.clientId} already exists` });
}
const id = randomUUID();
const mappers = (rep.protocolMappers ?? []).map((m: Client) => ({ ...m, id: randomUUID() }));
clients.set(id, { ...rep, id, protocolMappers: mappers });
return send(res, 201);
}
const c = clients.get(rest[0]);
if (!c) return send(res, 404, { error: "Could not find client" });
if (rest.length === 1 && req.method === "PUT") {
// Keycloak ignores protocolMappers on a client update: they have their own endpoints.
const rep = await body(req);
clients.set(c.id, { ...rep, id: c.id, protocolMappers: c.protocolMappers });
return send(res, 204);
}
if (rest.length === 1 && req.method === "DELETE") {
clients.delete(c.id);
return send(res, 204);
}
if (rest[1] === "client-secret" && req.method === "GET") {
return send(res, 200, { type: "secret", value: c.secret });
}
if (rest[1] === "protocol-mappers") {
if (req.method === "GET") return send(res, 200, c.protocolMappers ?? []);
if (req.method === "POST") {
c.protocolMappers = [...(c.protocolMappers ?? []), { ...(await body(req)), id: randomUUID() }];
return send(res, 201);
}
if (req.method === "PUT") {
const m = await body(req);
c.protocolMappers = c.protocolMappers.map((x: Client) => (x.id === rest[4] ? m : x));
return send(res, 204);
}
}
send(res, 405);
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
after(() => server.close());
const port = (server.address() as { port: number }).port;
const oidc = new OidcClients(new KeycloakClient(`http://127.0.0.1:${port}`, "admin", "pw"), realm);
/** Grafana on ace, as the mesh hands it to the provisioner. */
function grafana(secret = "s3cret", values: Record<string, unknown> = {}) {
return {
as: "mesh_ace_grafana",
password: secret,
consumer: "ace",
values: {
label: "grafana", endpoint: "web", port: 20010, callback: "/login/generic_oauth",
name: "grafana.zurag.be", "internal-name": "grafana.ace.internal", ...values,
},
};
}
function only(clientId: string): Client {
const found = [...clients.values()].filter((c) => c.clientId === clientId);
assert.equal(found.length, 1, `exactly one client ${clientId}, found ${found.length}`);
return found[0];
}
test("the realm is read out of the issuer, and an issuer that names none is refused", () => {
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox"), "Novox");
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox/"), "Novox");
assert.equal(realmOf("http://127.0.0.1:18500/realms/master"), "master");
assert.throws(() => realmOf("https://keycloak.novox.be"), /realms/);
assert.throws(() => realmOf("keycloak"), /not a URL/);
});
test("the redirect is the consumer's callback under every name the mesh composed for it", () => {
assert.deepEqual(redirectsOf(grafana().values), {
root: "https://grafana.zurag.be",
redirects: ["https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"],
});
// A route reaching only the private network has only the internal name, and that is enough.
assert.deepEqual(redirectsOf({ callback: "/cb", "internal-name": "x.ace.internal" }).redirects,
["https://x.ace.internal/cb"]);
assert.throws(() => redirectsOf({ name: "grafana.zurag.be" }), /callback/);
assert.throws(() => redirectsOf({ name: "grafana.zurag.be", callback: "login" }), /callback/);
assert.throws(() => redirectsOf({ callback: "/cb" }), /label/);
});
test("a consumer is given one confidential client, under its id and the mesh's secret", async () => {
clients.clear();
assert.equal(await oidc.ensure(grafana()), "created");
const c = only("mesh_ace_grafana");
assert.equal(c.publicClient, false);
assert.equal(c.clientAuthenticatorType, "client-secret");
assert.equal(c.secret, "s3cret");
assert.equal(c.enabled, true);
assert.equal(c.standardFlowEnabled, true);
assert.equal(c.directAccessGrantsEnabled, false);
assert.equal(c.implicitFlowEnabled, false);
assert.deepEqual(c.redirectUris, [
"https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
assert.equal(c.attributes[MARK], "true");
assert.deepEqual(c.protocolMappers.map((m: Client) => m.name), [ROLES_MAPPER.name]);
assert.equal(await oidc.holds(grafana()), true);
});
test("applying the same grant again makes no second client", async () => {
clients.clear();
await oidc.ensure(grafana());
assert.equal(await oidc.ensure(grafana()), "updated");
assert.equal(await oidc.ensure(grafana()), "updated");
only("mesh_ace_grafana");
assert.equal(only("mesh_ace_grafana").protocolMappers.length, 1, "the roles mapper is not added twice");
});
test("a new secret or a moved name is applied in place, and what the mesh does not own survives", async () => {
clients.clear();
await oidc.ensure(grafana());
const id = only("mesh_ace_grafana").id;
// Something the mesh does not own, set on the client after it was made.
clients.get(id)!.consentRequired = true;
clients.get(id)!.attributes["post.logout.redirect.uris"] = "+";
assert.equal(await oidc.holds(grafana("rotated")), false, "a rotated secret is not held until applied");
await oidc.ensure(grafana("rotated", { name: "dash.zurag.be" }));
const c = only("mesh_ace_grafana");
assert.equal(c.id, id, "updated, not replaced");
assert.equal(c.secret, "rotated");
assert.deepEqual(c.redirectUris, [
"https://dash.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
assert.equal(c.rootUrl, "https://dash.zurag.be");
assert.equal(c.consentRequired, true);
assert.equal(c.attributes["post.logout.redirect.uris"], "+");
assert.equal(c.attributes[MARK], "true");
assert.equal(await oidc.holds(grafana("rotated", { name: "dash.zurag.be" })), true);
});
test("a client lost or edited behind the mesh's back is not held, and is made whole again", async () => {
clients.clear();
await oidc.ensure(grafana());
const c = only("mesh_ace_grafana");
c.redirectUris = ["*"];
assert.equal(await oidc.holds(grafana()), false, "a widened redirect is not what the mesh gave");
await oidc.ensure(grafana());
assert.equal(await oidc.holds(grafana()), true);
only("mesh_ace_grafana").protocolMappers = [];
assert.equal(await oidc.holds(grafana()), false, "a client without its roles mapper is not held");
await oidc.ensure(grafana());
assert.equal(await oidc.holds(grafana()), true);
clients.clear();
assert.equal(await oidc.holds(grafana()), false);
});
test("a client of the same id the mesh did not make is refused, and left exactly as it was", async () => {
clients.clear();
clients.set("theirs", { id: "theirs", clientId: "mesh_ace_grafana", secret: "their-secret", redirectUris: ["*"] });
const before = JSON.stringify(clients.get("theirs"));
const writes = calls.length;
await assert.rejects(oidc.ensure(grafana()), /did not make/);
assert.equal(JSON.stringify(clients.get("theirs")), before);
assert.ok(calls.slice(writes).every((c) => c.startsWith("GET") || c.startsWith("POST /realms/master")),
`only reads were made: ${calls.slice(writes).join(", ")}`);
assert.equal(await oidc.holds(grafana()), false);
assert.equal(await oidc.remove("mesh_ace_grafana"), "not ours");
assert.ok(clients.has("theirs"), "a client the mesh did not make is never deleted");
});
test("the predecessor's hand-made client is never touched: the mesh's has its own id", async () => {
clients.clear();
clients.set("hal", { id: "hal", clientId: "grafana", secret: "old", redirectUris: ["https://grafana.zurag.be/*"] });
await oidc.ensure(grafana());
assert.equal(clients.get("hal")!.secret, "old");
only("mesh_ace_grafana");
assert.equal(await oidc.remove("grafana"), "not ours");
assert.ok(clients.has("hal"));
});
test("a withdrawn consumer's client is removed, and an absent one is not an error", async () => {
clients.clear();
await oidc.ensure(grafana());
assert.equal(await oidc.remove("mesh_ace_grafana"), "removed");
assert.equal([...clients.values()].length, 0);
assert.equal(await oidc.remove("mesh_ace_grafana"), "absent");
});
test("a contribution with no callback makes no client at all", async () => {
clients.clear();
await assert.rejects(oidc.ensure({ ...grafana(), values: { name: "grafana.zurag.be" } }), /callback/);
assert.equal(clients.size, 0);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "oidc.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts"]
}