From 3b03fcd8f74dc9791b536eee07e1b48cee784835 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 00:27:37 +0200 Subject: [PATCH] Providers create the credential the mesh minted, sealing nothing (ADR 0053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redis, postgres and minio adapters drop generatePassword + the returned credential: each creates the resource under the login the mesh derived (`as`) with the password the mesh minted (`p.password`). minio's client gains a secret-key argument so it sets the mesh's secret rather than generating one. umami (analytics) is re-pointed at the new contract too; its siteId return is a data-provision concern ADR 0053 scopes out. Proven: mesh-lab provider-uses-mesh-credential green — redis creates the consumer's login with the mesh's password, the consumer authenticates (PONG), no seal key set. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/minio/client.ts | 15 +++--- modules/minio/provisioner/index.ts | 64 +++++++++++--------------- modules/postgres/provisioner/index.ts | 66 ++++++++++----------------- modules/redis/provisioner/index.ts | 59 ++++++++++-------------- modules/umami/provisioner/index.ts | 42 +++++++++-------- 5 files changed, 107 insertions(+), 139 deletions(-) diff --git a/modules/minio/client.ts b/modules/minio/client.ts index 5a0f29d..4086b58 100644 --- a/modules/minio/client.ts +++ b/modules/minio/client.ts @@ -10,7 +10,7 @@ // creation the MinIO admin REST API guards behind an encrypted payload `fetch` cannot form. // This mirrors hal's MinIOClient/MinIOAdmin split, folded into one client the module builds from env. -import { createHash, createHmac, randomBytes } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import { execFile } from "node:child_process"; import { readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -205,14 +205,15 @@ export class MinioClient { // --- admin plane (mc CLI) ------------------------------------------------ /** - * Create a service account scoped to one bucket and return its credential. The MinIO admin REST - * API encrypts this request with a key derived (Argon2) from the root secret, which node built-ins - * cannot reproduce — so, as hal did, the module drives the `mc` CLI, which the provisioner image - * bundles. + * Create a service account scoped to one bucket, under a given access key and secret key, and + * return the pair. The secret key is the mesh's — the mesh mints one password per consumer and + * hands a copy to both ends (novox/hq ADR 0053), so minio sets that as the secret rather than + * generating one the consumer could never learn. The MinIO admin REST API encrypts this request + * with a key derived (Argon2) from the root secret, which node built-ins cannot reproduce — so, as + * hal did, the module drives the `mc` CLI, which the runtime image bundles. */ - async createAccessKey(bucket: string, accessKey: string): Promise { + async createAccessKey(bucket: string, accessKey: string, secretKey: string): Promise { await this.ensureAlias(); - const secretKey = randomBytes(20).toString("hex"); const policyPath = join(this.mcConfigDir, `policy-${accessKey}.json`); writeFileSync(policyPath, bucketPolicy(bucket), { mode: 0o600 }); try { diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index a69a37d..dd5957b 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -1,72 +1,62 @@ // minio's provisioner — the adapter that makes minio a provider of the mesh `s3-bucket` interface -// (the name in module.json's `provides`). The reconcile loop, sealing and grant-file handling are the -// sdk harness's; this writes only the per-service half: how minio creates and removes a consumer's -// bucket and its scoped access key (novox/hq ADR 0044/0045). +// (the name in module.json's `provides`). 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 minio +// creates and removes a consumer's bucket and its scoped access key (novox/hq ADR 0044/0045/0053). // -// The `s3-bucket` interface: a consumer receives `{ endpoint, bucket, accessKey, secretKey, region }` -// — an S3 endpoint and a credential confined to its own bucket. It depends on `s3-bucket`, not on -// minio, so any S3-compatible provider could serve it. +// The `s3-bucket` interface: a consumer connects to an S3 endpoint with an access key confined to +// its own bucket. It depends on `s3-bucket`, not on minio, so any S3-compatible provider could serve +// it. // -// The bucket and access-key id are derived deterministically from the consumer's identity, because -// the harness hands `remove` only that identity (no stored values) — so teardown recomputes exactly -// what creation minted, with nothing to persist. The emits fire here, at the real provisioning -// points (novox/hq ADR 0046/0047); the module's events entrypoint (../index.ts) consumes them. +// **The access key and its secret are the mesh's, not the provisioner's (ADR 0053).** The mesh +// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio +// creates the service account under exactly that access key with exactly that secret — a credential +// the provisioner invented is one the consumer could never present. The bucket is derived from the +// login, so teardown recomputes it with nothing to persist. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; -import { MinioClient, accessKeyFor, bucketFor } from "../client.js"; +import { MinioClient, bucketFor } from "../client.js"; const minio = MinioClient.fromEnv(); runProvisioner("s3-bucket", { - async create(grant: Grant): Promise { - const bucket = bucketFor(grant.consumer); - const accessKeyId = accessKeyFor(grant.consumer); + async create(p: Provision): Promise { + const bucket = bucketFor(p.as); + const accessKeyId = p.as; if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket); - // Re-mint the scoped key idempotently: drop any prior one under this id, then add fresh. + // Re-mint the scoped key idempotently: drop any prior one under this id, then add it back with + // the mesh's secret. try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } - const key = await minio.createAccessKey(bucket, accessKeyId); + await minio.createAccessKey(bucket, accessKeyId, p.password); await announce("module.minio.bucket.created", { bucket, - consumer: grant.consumer, - node: grant.node, - accessKey: key.accessKey, // the secret is never put on the bus — only the credential file carries it + consumer: p.consumer ?? "", + accessKey: accessKeyId, endpoint: minio.baseUrl, }); - - return { - fields: { - endpoint: minio.baseUrl, - bucket, - accessKey: key.accessKey, - secretKey: key.secretKey, - region: minio.region, - }, - }; }, - async remove(grant: Grant): Promise { - const bucket = bucketFor(grant.consumer); - const accessKeyId = accessKeyFor(grant.consumer); + async remove(p: { as: string }): Promise { + const bucket = bucketFor(p.as); // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if // empty; a bucket that still holds objects is left for an operator rather than erroring on every // reconcile tick — access is already gone, and silently deleting a consumer's data would be worse. - try { await minio.removeAccessKey(accessKeyId); } catch { /* already gone */ } + try { await minio.removeAccessKey(p.as); } catch { /* already gone */ } try { await minio.removeBucket(bucket); } catch (err) { console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); } - await announce("module.minio.bucket.removed", { bucket, consumer: grant.consumer, node: grant.node }); + await announce("module.minio.bucket.removed", { bucket, accessKey: p.as }); }, }); -/** Emit best-effort: with no broker bound (a provisioner is not yet a runtime — novox/hq ADR 0052) - * the event is logged and dropped, never allowed to throw back and fail a bucket that was made. */ +/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a + * bucket that was made. */ async function announce(type: string, body: unknown): Promise { try { await emit(type, body); diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts index 3368b38..b3a4303 100644 --- a/modules/postgres/provisioner/index.ts +++ b/modules/postgres/provisioner/index.ts @@ -1,33 +1,25 @@ // postgres's provisioner — the adapter that makes postgres a provider of the mesh -// `postgres-database` interface. The watching, sealing and grant-file handling are the sdk -// harness's; this writes only the per-service half: how postgres creates and removes a consumer's -// database + owning role (novox/hq ADR 0044/0045). +// `postgres-database` interface. The reconcile loop, the contributions file, and reading the mesh's +// minted password are the sdk harness's; this writes only the per-service half: how postgres creates +// and removes a consumer's database + owning role (novox/hq ADR 0044/0045/0053). // -// The `postgres-database` interface: a consumer receives `{ host, port, database, user, password }` -// and connects to a database only it owns. +// The `postgres-database` interface: a consumer connects to a database it alone owns, as `as` with +// the password the mesh minted. // -// Identity (the database and role names) is derived from `grant.consumer` alone — never from -// `grant.values` — because on removal the harness hands the adapter a grant carrying only the -// consumer. Deriving from the consumer keeps create and remove naming the same resource. +// **The role name and password are the mesh's, not the provisioner's (ADR 0053).** The mesh derives +// the login and hands it to both ends, and mints the password. postgres creates a role and a +// same-named database under exactly that login — a name the consumer cannot learn is a database it +// cannot reach. // -// The credential is composed here and returned; the DDL runs through PostgresClient.query(), which -// is the module's one pending boundary (see client.ts). Until that boundary is backed, create() -// surfaces the TODO honestly rather than sealing a credential for a database that was never made. +// The DDL runs through PostgresClient.query(), which is the module's one pending boundary (see +// client.ts). -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; -import { PostgresClient, generatePassword } from "../client.js"; +import { PostgresClient } from "../client.js"; const postgres = PostgresClient.fromEnv(); -/** A stable postgres identifier for a consumer: lowercase [a-z0-9_], never starting with a digit. */ -function identity(consumer: string): string { - let safe = consumer.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/^_+|_+$/g, ""); - if (safe === "" ) safe = "consumer"; - if (/^[0-9]/.test(safe)) safe = "_" + safe; - return safe.slice(0, 63); // postgres identifier limit -} - /** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ async function announce(type: string, body: Record): Promise { try { @@ -38,27 +30,19 @@ async function announce(type: string, body: Record): Promise { - const database = identity(grant.consumer); - const user = database; - const password = generatePassword(); - await postgres.createDatabaseAndRole(database, user, password); - await announce("module.postgres.database.provisioned", { consumer: grant.consumer, database, user }); - return { - fields: { - host: postgres.host, - port: String(postgres.port), - database, - user, - password, - }, - }; + async create(p: Provision): Promise { + // Database and owning role share the consumer's login, so the consumer owns exactly its own. + const database = p.as; + await postgres.createDatabaseAndRole(database, p.as, p.password); + await announce("module.postgres.database.provisioned", { + consumer: p.consumer ?? "", + database, + user: p.as, + }); }, - async remove(grant: Grant): Promise { - const database = identity(grant.consumer); - const user = database; - await postgres.dropDatabaseAndRole(database, user); - await announce("module.postgres.database.deprovisioned", { consumer: grant.consumer, database }); + async remove(p: { as: string }): Promise { + await postgres.dropDatabaseAndRole(p.as, p.as); + await announce("module.postgres.database.deprovisioned", { database: p.as }); }, }); diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts index f8d7df7..0ad363b 100644 --- a/modules/redis/provisioner/index.ts +++ b/modules/redis/provisioner/index.ts @@ -1,27 +1,23 @@ // redis's provisioner — the adapter that makes redis a provider of the mesh `redis-cache` -// interface. The watching, sealing and grant-file handling are the sdk harness's; this writes only -// the per-service half: how redis creates and removes a per-consumer cache (novox/hq ADR 0044/0045). +// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are +// the sdk harness's; this writes only the per-service half: how redis creates and removes a +// per-consumer cache (novox/hq ADR 0044/0045/0053). // -// The `redis-cache` interface: a consumer receives `{ host, port, username, password, -// keyspacePrefix }` and stores its keys under `:*`, isolated from every other -// consumer by an ACL user scoped to exactly that prefix. +// The `redis-cache` interface: a consumer connects as `as` with the password the mesh minted, and +// stores its keys under `:*`, isolated from every other consumer by an ACL user scoped to +// exactly that prefix. // -// Identity (the ACL username and keyspace) is derived from `grant.consumer` alone — never from -// `grant.values` — because on removal the harness hands the adapter a grant carrying only the -// consumer. Deriving from the consumer keeps create and remove naming the same resource. +// **The login and password are the mesh's, not the provisioner's (ADR 0053).** The mesh derives the +// login and hands it to both ends so they agree, and mints the password and delivers a copy to each. +// redis creates exactly that login with exactly that password — a name or password the provisioner +// invented is one the consumer could never present. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; -import { RedisClient, generatePassword } from "../client.js"; +import { RedisClient } from "../client.js"; const redis = RedisClient.fromEnv(); -/** A stable, ACL-safe identity for a consumer: only [A-Za-z0-9_.-], never empty. */ -function identity(consumer: string): string { - const safe = consumer.replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^_+|_+$/g, ""); - return safe || "consumer"; -} - /** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ async function announce(type: string, body: Record): Promise { try { @@ -32,26 +28,19 @@ async function announce(type: string, body: Record): Promise { - const username = identity(grant.consumer); - const keyspacePrefix = username; - const password = generatePassword(); - await redis.createAclUser(username, password, keyspacePrefix); - await announce("module.redis.cache.provisioned", { consumer: grant.consumer, username, keyspacePrefix }); - return { - fields: { - host: redis.host, - port: String(redis.port), - username, - password, - keyspacePrefix, - }, - }; + async create(p: Provision): Promise { + // The keyspace is scoped to the consumer's own login, so one cannot read another's keys. + const keyspacePrefix = p.as; + await redis.createAclUser(p.as, p.password, keyspacePrefix); + await announce("module.redis.cache.provisioned", { + consumer: p.consumer ?? "", + username: p.as, + keyspacePrefix, + }); }, - async remove(grant: Grant): Promise { - const username = identity(grant.consumer); - await redis.deleteAclUser(username); - await announce("module.redis.cache.deprovisioned", { consumer: grant.consumer, username }); + async remove(p: { as: string }): Promise { + await redis.deleteAclUser(p.as); + await announce("module.redis.cache.deprovisioned", { username: p.as }); }, }); diff --git a/modules/umami/provisioner/index.ts b/modules/umami/provisioner/index.ts index 33f6f5f..f9ba5c2 100644 --- a/modules/umami/provisioner/index.ts +++ b/modules/umami/provisioner/index.ts @@ -1,35 +1,39 @@ // umami's provisioner — the adapter that makes umami a provider of the mesh `analytics` interface. -// The watching, sealing and grant-file handling are the sdk harness's; this writes only the -// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045). +// The reconcile loop and the contributions file are the sdk harness's; this writes only the +// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045/0053). // // The `analytics` interface: a consumer contributes `{ domain }` (the site it wants tracked) and -// receives `{ siteId, snippet, dashboard }`. umami adapts its own API to that contract, so a -// consumer depends on `analytics`, not on umami. +// receives `{ siteId, snippet, dashboard }`. +// +// **A note on scope (ADR 0053).** ADR 0053 corrects *credential* provisions: the mesh mints a secret +// and the provider creates a login with it. Analytics is not that shape — it mints no secret the +// consumer authenticates with; what the consumer needs back is data umami *generates* (the siteId). +// The credential-provisioner contract returns nothing, so the siteId does not travel back to the +// consumer here. That return path — for a provider that generates data rather than being handed a +// secret — is a separate concern and is not solved by this decision. umami still reconciles its +// sites off the mesh's contributions (it keys on the login the mesh derived), which is what this +// keeps working. -import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { UmamiClient } from "../client.js"; const umami = UmamiClient.fromEnv(); runProvisioner("analytics", { - async create(grant: Grant): Promise { - const domain = grant.values.domain ?? grant.consumer; - const name = grant.values.name ?? domain; + async create(p: Provision): Promise { + const domain = String(p.values.domain ?? p.as); + const name = String(p.values.name ?? domain); const token = await umami.getToken(); - const site = (await umami.findWebsite(token, domain)) ?? (await umami.createWebsite(token, domain, name)); - return { - fields: { - siteId: site.id, - snippet: umami.snippet(site.id), - dashboard: umami.dashboard(site.id), - }, - }; + // Idempotent: only create the site if it is not already there. + if (!(await umami.findWebsite(token, domain))) { + await umami.createWebsite(token, domain, name); + } }, - async remove(grant: Grant): Promise { - const domain = grant.values.domain ?? grant.consumer; + async remove(p: { as: string }): Promise { const token = await umami.getToken(); - const site = await umami.findWebsite(token, domain); + // Keyed on the mesh-derived login, the one identity the harness carries into removal. + const site = await umami.findWebsite(token, p.as); if (site) await umami.deleteWebsite(token, site.id); }, });