From fe0ed3b74ea2c8b3673c896605c42622eea923d9 Mon Sep 17 00:00:00 2001 From: jochens Date: Tue, 29 Sep 2026 23:42:18 +0200 Subject: [PATCH 1/2] influxdb: place its directories, hand secrets over as files, name its UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- modules/influxdb/client.ts | 13 ++++++-- modules/influxdb/module.json | 64 ++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts index 120dfa2..245060e 100644 --- a/modules/influxdb/client.ts +++ b/modules/influxdb/client.ts @@ -24,6 +24,13 @@ function meshConfig(file?: string): Record { 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); } diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index a1c48ac..d955f9c 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -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" - } } } From 323ef9ec7ec1fba9e999b7ce99d256d42db237a5 Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 12:55:38 +0200 Subject: [PATCH 2/2] influxdb: provide influxdb-api, one mesh-made v1 credential per consumer 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. --- modules/influxdb/Dockerfile | 4 +- modules/influxdb/client.ts | 108 ++++++++++- modules/influxdb/grants.ts | 186 +++++++++++++++++++ modules/influxdb/module.json | 33 +++- modules/influxdb/package.json | 7 +- modules/influxdb/provisioner/index.ts | 54 ++++++ modules/influxdb/test/grants.test.ts | 246 ++++++++++++++++++++++++++ modules/influxdb/tsconfig.json | 7 +- 8 files changed, 637 insertions(+), 8 deletions(-) create mode 100644 modules/influxdb/grants.ts create mode 100644 modules/influxdb/provisioner/index.ts create mode 100644 modules/influxdb/test/grants.test.ts diff --git a/modules/influxdb/Dockerfile b/modules/influxdb/Dockerfile index 341d8ce..fb4b123 100644 --- a/modules/influxdb/Dockerfile +++ b/modules/influxdb/Dockerfile @@ -13,7 +13,7 @@ ARG RUNTIME_BASE FROM ${BUILD_BASE} AS build WORKDIR /app/modules/influxdb COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \ +RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} @@ -21,4 +21,4 @@ COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist # Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a # provider's provisioner runs its reconcile loop in the same process, with the broker connected — # the convention novox/hq issues 060/061 settled. -ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js +ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts index 245060e..0a3f8ab 100644 --- a/modules/influxdb/client.ts +++ b/modules/influxdb/client.ts @@ -17,6 +17,25 @@ export interface InfluxBucket { retentionSeconds?: number; } +/** One permission of an authorization, as InfluxDB represents it: an action on a resource type, + * in one org, optionally narrowed to one resource by id (no id = every resource of that type). */ +export interface InfluxPermission { + action: "read" | "write"; + resource: { type: string; orgID?: string; id?: string; name?: string; org?: string }; +} + +/** A v1-compatibility ("legacy") authorization: a username (InfluxDB calls it `token`) and a + * password the caller chooses, scoped by permissions. The one credential InfluxDB 2.x lets a + * caller set to a value it did not generate — which is what a mesh-minted password needs. */ +export interface LegacyAuthorization { + id: string; + token: string; + orgID: string; + status?: "active" | "inactive"; + description?: string; + permissions: InfluxPermission[]; +} + /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; @@ -37,7 +56,7 @@ export class InfluxDBClient { constructor( url: string, private readonly token: string, - private readonly org: string, + readonly org: string, ) { this.baseUrl = url.replace(/\/$/, ""); } @@ -70,6 +89,93 @@ export class InfluxDBClient { return res; } + /** Like request, but the answer is returned whatever its status, for the caller to read. */ + private async raw(path: string, init?: RequestInit): Promise { + return fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { Authorization: `Token ${this.token}`, ...(init?.headers ?? {}) }, + }); + } + + private async send(path: string, method: string, body?: unknown): Promise { + return this.request(path, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } + + /** The id of the org of this name, or undefined when there is none. */ + async orgID(name: string): Promise { + const res = await this.raw(`/api/v2/orgs?org=${encodeURIComponent(name)}`); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`InfluxDB API /api/v2/orgs: ${res.status} ${await res.text()}`); + const body = (await res.json()) as { orgs?: { id: string; name: string }[] }; + return body.orgs?.find((o) => o.name === name)?.id; + } + + /** The bucket of exactly this name in the org, or undefined. */ + async findBucket(orgID: string, name: string): Promise { + const res = await this.raw(`/api/v2/buckets?orgID=${encodeURIComponent(orgID)}&name=${encodeURIComponent(name)}`); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`InfluxDB API /api/v2/buckets: ${res.status} ${await res.text()}`); + const body = (await res.json()) as { buckets?: { id: string; name: string; orgID?: string }[] }; + const b = body.buckets?.find((x) => x.name === name); + return b ? { id: b.id, name: b.name, orgID: b.orgID } : undefined; + } + + /** Create a bucket that keeps its data for ever — retention is the operator's choice, never the mesh's. */ + async createBucket(orgID: string, name: string, description: string): Promise { + const b = (await (await this.send("/api/v2/buckets", "POST", { + orgID, name, description, retentionRules: [], + })).json()) as { id: string; name: string; orgID?: string }; + return { id: b.id, name: b.name, orgID: b.orgID }; + } + + /** The v1 authorization whose username is exactly this, or undefined. */ + async findLegacy(username: string): Promise { + const path = `/private/legacy/authorizations?token=${encodeURIComponent(username)}`; + const res = await this.raw(path); + // InfluxDB answers a filter matching nothing with 404, not an empty list. + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`); + const body = (await res.json()) as { authorizations?: LegacyAuthorization[] }; + return body.authorizations?.find((a) => a.token === username); + } + + async createLegacy(a: Omit): Promise { + return (await (await this.send("/private/legacy/authorizations", "POST", a)).json()) as LegacyAuthorization; + } + + /** Set a v1 authorization's password. InfluxDB keeps only a hash of it, so it can be set, never read. */ + async setLegacyPassword(id: string, password: string): Promise { + await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}/password`, "POST", { password }); + } + + async updateLegacy(id: string, patch: { status?: "active" | "inactive"; description?: string }): Promise { + await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "PATCH", patch); + } + + async deleteLegacy(id: string): Promise { + await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "DELETE"); + } + + /** + * Whether this username and password sign in on the v1 API — the consumer's own view. Asked with + * a statement that reads nothing (`SHOW DATABASES` lists only what the credential may read), sent + * with Basic auth so the password is never in a URL. 401 is a wrong password or no such user; + * anything else that is not a server error means InfluxDB knew who was asking. + */ + async legacySignsIn(username: string, password: string): Promise { + const res = await fetch(`${this.baseUrl}/query?q=${encodeURIComponent("SHOW DATABASES")}`, { + headers: { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` }, + }); + await res.arrayBuffer(); + if (res.status === 401) return false; + if (res.status >= 500) throw new Error(`InfluxDB v1 /query: ${res.status}`); + return true; + } + /** Server health — the one endpoint that needs no token, but we send it anyway. */ async health(): Promise { return (await (await this.request("/health")).json()) as InfluxHealth; diff --git a/modules/influxdb/grants.ts b/modules/influxdb/grants.ts new file mode 100644 index 0000000..8d10c49 --- /dev/null +++ b/modules/influxdb/grants.ts @@ -0,0 +1,186 @@ +// What the `influxdb-api` provision means in InfluxDB: one v1-compatibility authorization per +// consumer, in the org this module serves, under the username and password the mesh gave both ends, +// allowed exactly the access the consumer contributed. 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 +// InfluxDB without a broker or a contributions file. +// +// **Why a v1 authorization and not a v2 API token.** The mesh mints the consumer's password and +// hands it to both ends (novox/hq ADR 0048); the provider sets it, and never hands one back. An +// InfluxDB 2.x API token is generated by the server — `POST /api/v2/authorizations` ignores a token +// the caller sends — so a token could only ever be the operator's to accept, one per pair, by hand. +// A v1 authorization is a username and a password the caller chooses (8–72 characters; the mesh +// mints 40), stored hashed, and it reads and writes through InfluxQL (`/query`) and line protocol +// (`/write`), which every bucket answers under its own name as a database (InfluxDB maps each +// bucket to a database of the same name by itself). That is what grafana's InfluxDB data source +// speaks, and what Node-RED's influxdb nodes speak in their 1.x mode — so the mesh can make every +// consumer's credential, rotate it and withdraw it, with no person in the loop. +// +// **What a consumer contributes.** `access`: "read" (the default), "write" or "read-write". +// `buckets`: the buckets it may use, by name. A reader that names none may read every bucket of the +// org — a dashboard is pointed at data, it does not own it. A writer must name its buckets: writing +// everywhere, the org's system buckets included, is never what a consumer means. A named bucket +// that does not exist is created, keeping its data for ever; the mesh never deletes a bucket. +// +// **Only what the mesh made is touched.** An authorization this module creates is named with the +// mesh's identity prefix and its description starts with MARK. One with the same username that +// lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted. +// Every other authorization, token, user and bucket in the instance is left exactly as it was. + +import type { InfluxDBClient, InfluxPermission, LegacyAuthorization } from "./client.js"; + +/** How a description marks an authorization as the mesh's own work. */ +export const MARK = "[mesh]"; + +/** The prefix the mesh gives every consumer identity (novox/hq ADR 0049). */ +const IDENTITY_PREFIX = "mesh_"; + +/** One consumer, as the harness hands it over. */ +export interface ApiGrant { + readonly as: string; + readonly password: string; + readonly values: Readonly>; + readonly consumer?: string; +} + +export type Access = "read" | "write" | "read-write"; + +/** What a contribution asks for, checked. Refused when it cannot be served as asked. */ +export function askedFor(values: Readonly>): { access: Access; buckets: string[] } { + const access = values.access ?? "read"; + if (access !== "read" && access !== "write" && access !== "read-write") { + throw new Error(`contributes an access of ${JSON.stringify(access)} — it is "read", "write" or "read-write"`); + } + const raw = values.buckets ?? []; + if (!Array.isArray(raw) || raw.some((b) => typeof b !== "string" || b.trim() === "")) { + throw new Error(`contributes buckets of ${JSON.stringify(raw)} — a list of bucket names`); + } + const buckets = [...new Set((raw as string[]).map((b) => b.trim()))].sort(); + if (access !== "read" && buckets.length === 0) { + throw new Error(`asks to write and names no bucket (\`buckets\`) — a writer names what it writes to`); + } + if (buckets.some((b) => b.startsWith("_"))) { + throw new Error(`names a system bucket (${buckets.filter((b) => b.startsWith("_")).join(", ")}) — those are InfluxDB's own`); + } + return { access: access as Access, buckets }; +} + +/** The permissions a grant resolves to, given each named bucket's id. */ +export function permissionsFor(orgID: string, access: Access, bucketIDs: string[]): InfluxPermission[] { + const actions: ("read" | "write")[] = access === "read-write" ? ["read", "write"] : [access]; + const out: InfluxPermission[] = []; + for (const action of actions) { + if (bucketIDs.length === 0) { + out.push({ action, resource: { type: "buckets", orgID } }); + continue; + } + for (const id of bucketIDs) out.push({ action, resource: { type: "buckets", orgID, id } }); + } + return out; +} + +/** A permission as a comparable string: what InfluxDB answers carries names and links besides. */ +function key(p: InfluxPermission): string { + return `${p.action}:${p.resource.type}:${p.resource.orgID ?? ""}:${p.resource.id ?? "*"}`; +} + +function samePermissions(a: readonly InfluxPermission[], b: readonly InfluxPermission[]): boolean { + const x = a.map(key).sort(); + const y = b.map(key).sort(); + return x.length === y.length && x.every((v, i) => v === y[i]); +} + +export function marked(a: Pick): boolean { + return a.token.startsWith(IDENTITY_PREFIX) && (a.description ?? "").startsWith(MARK); +} + +function describe(g: ApiGrant): string { + return `${MARK} made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`; +} + +export class ApiGrants { + constructor(private readonly influx: InfluxDBClient, readonly org: string) {} + + private async orgID(): Promise { + const id = await this.influx.orgID(this.org); + if (!id) throw new Error(`InfluxDB has no org ${JSON.stringify(this.org)} — the org this module serves must exist`); + return id; + } + + /** The ids of the named buckets, creating any that are missing when `create` says so. Undefined + * when one is missing and may not be created (a read-only question). */ + private async bucketIDs(orgID: string, names: string[], create: ApiGrant | undefined): Promise { + const ids: string[] = []; + for (const name of names) { + let b = await this.influx.findBucket(orgID, name); + if (!b) { + if (!create) return undefined; + b = await this.influx.createBucket(orgID, name, `${MARK} made by the mesh for ${create.as}; the mesh never deletes it`); + } + ids.push(b.id); + } + return ids.sort(); + } + + /** Create the consumer's authorization, or bring the mesh's existing one back to what the grant + * says. Idempotent: a second apply of the same grant changes nothing beyond re-asserting the + * password, which InfluxDB can be told but never asked. */ + async ensure(g: ApiGrant): Promise<"created" | "updated" | "unchanged"> { + if (!g.as.startsWith(IDENTITY_PREFIX)) { + throw new Error(`${g.as} is not a mesh identity — the mesh names every consumer ${IDENTITY_PREFIX}_`); + } + const { access, buckets } = askedFor(g.values); + const orgID = await this.orgID(); + const found = await this.influx.findLegacy(g.as); + if (found && !marked(found)) { + throw new Error( + `InfluxDB already has a v1 authorization ${g.as} the mesh did not make — left alone; ` + + `delete it if the mesh should own that name`); + } + const want = permissionsFor(orgID, access, (await this.bucketIDs(orgID, buckets, g))!); + + if (found && found.orgID === orgID && samePermissions(found.permissions, want)) { + // Only what differs is written. The password cannot be read back, so it is tried instead. + let changed = false; + if (found.status === "inactive") { + await this.influx.updateLegacy(found.id, { status: "active" }); + changed = true; + } + if (!(await this.influx.legacySignsIn(g.as, g.password))) { + await this.influx.setLegacyPassword(found.id, g.password); + changed = true; + } + return changed ? "updated" : "unchanged"; + } + // InfluxDB cannot change an authorization's permissions in place, so the mesh's own is made + // again. Only ever one the mesh made: a foreign one was refused above. + if (found) await this.influx.deleteLegacy(found.id); + const made = await this.influx.createLegacy({ + token: g.as, orgID, status: "active", description: describe(g), permissions: want, + }); + await this.influx.setLegacyPassword(made.id, g.password); + return found ? "updated" : "created"; + } + + /** Whether InfluxDB still holds this consumer's authorization exactly as the grant says: present, + * the mesh's, active, allowed what was asked and nothing more, and signing in with the mesh's + * password. Reads only — a missing bucket is "not held", never created here. */ + async holds(g: ApiGrant): Promise { + const { access, buckets } = askedFor(g.values); + const orgID = await this.influx.orgID(this.org); + if (!orgID) return false; + const found = await this.influx.findLegacy(g.as); + if (!found || !marked(found) || found.status === "inactive" || found.orgID !== orgID) return false; + const ids = await this.bucketIDs(orgID, buckets, undefined); + if (!ids || !samePermissions(found.permissions, permissionsFor(orgID, access, ids))) return false; + return this.influx.legacySignsIn(g.as, g.password); + } + + /** Withdraw a consumer's authorization — only one the mesh made. Its buckets and their data stay. */ + async remove(as: string): Promise<"removed" | "absent" | "not ours"> { + const found = await this.influx.findLegacy(as); + if (!found) return "absent"; + if (!marked(found)) return "not ours"; + await this.influx.deleteLegacy(found.id); + return "removed"; + } +} diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index d955f9c..75fb2a7 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -1,6 +1,12 @@ { "module": "influxdb", "version": "1", + "provides": [ + { + "name": "influxdb-api", + "scope": "mesh" + } + ], "capabilities": [ "container-runtime" ], @@ -13,9 +19,23 @@ "port": 8086, "protocol": "tcp", "from": "mesh", - "why": "queries, writes and the web UI, over http; a name is a route grant" + "why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant" } ], + "serves": { + "influxdb-api": { + "scheme": "http", + "port": 8086, + "org": "mesh", + "bucket": "default" + } + }, + "receives": { + "influxdb-api": "${dir:grants}/mesh.json" + }, + "grants": { + "influxdb-api": "${dir:grants}" + }, "resources": [ { "id": "mesh-state", @@ -41,6 +61,11 @@ "mode": "0700", "owner": "1000:1000" }, + { + "id": "grants", + "type": "directory", + "mode": "0700" + }, { "id": "server-env", "type": "file", @@ -82,13 +107,15 @@ "volumes": [ "/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro", "/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro", - "${dir:state}/admin-token.secret:/run/secrets/admin-token:ro" + "${dir:state}/admin-token.secret:/run/secrets/admin-token:ro", + "${dir:grants}:${dir:grants}:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}", "MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json", - "MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token" + "MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token", + "MESH_RECEIVES": "${dir:grants}/mesh.json" }, "restart-on": [ "runtime-config" diff --git a/modules/influxdb/package.json b/modules/influxdb/package.json index 50f3dfb..8232f51 100644 --- a/modules/influxdb/package.json +++ b/modules/influxdb/package.json @@ -1,9 +1,14 @@ { "name": "@novox/module-influxdb", "version": "0.1.0", - "description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0039).", + "description": "influxdb — time-series database; provides the mesh influxdb-api interface. Its API client, provisioner and tools live here (novox/hq ADR 0039).", "type": "module", "private": true, + "scripts": { + "build": "tsc client.ts grants.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "typecheck": "tsc -p tsconfig.json", + "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" + }, "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, diff --git a/modules/influxdb/provisioner/index.ts b/modules/influxdb/provisioner/index.ts new file mode 100644 index 0000000..efedbb9 --- /dev/null +++ b/modules/influxdb/provisioner/index.ts @@ -0,0 +1,54 @@ +// influxdb's provisioner — the adapter that makes influxdb a provider of the mesh `influxdb-api` +// 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 InfluxDB creates, checks and removes a +// consumer's credential (novox/hq ADR 0039/0040/0048). What that credential is, and why it is a v1 +// authorization, is in ../grants.ts. +// +// The `influxdb-api` interface: a consumer reaches `${bound:influxdb-api:scheme}://…:at:…:port`, +// signs in as `${bound:influxdb-api:as}` with the password the mesh minted for the pair, and reads +// or writes the org's buckets as databases of the same name — `${bound:influxdb-api:bucket}` being +// the one this instance serves by default. The org and the default bucket are the assignment's +// settings, which reach both what is served and this module's config.json, so the org a consumer is +// told and the org its credential is made in cannot disagree. + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { InfluxDBClient } from "../client.js"; +import { ApiGrants } from "../grants.js"; + +let grants: ApiGrants | undefined; +try { + const influx = InfluxDBClient.fromEnv(); + grants = new ApiGrants(influx, influx.org); +} catch (err) { + // No admin token: nothing can be provisioned, and the tools loaded beside this must still serve. + console.error(`[provisioner:influxdb-api] not started: ${err instanceof Error ? err.message : err}`); +} + +if (grants) serve(grants); + +function serve(grants: ApiGrants): void { + runProvisioner("influxdb-api", { + async create(p: Provision): Promise { + const done = await grants.ensure(p); + if (done !== "unchanged") { + console.log(`[provisioner:influxdb-api] ${done} v1 authorization ${p.as} in org ${grants.org}`); + } + }, + + async remove(p: { as: string }): Promise { + const done = await grants.remove(p.as); + if (done === "not ours") { + console.error(`[provisioner:influxdb-api] ${p.as}: an authorization of that name exists that the mesh did not make — left alone`); + } else if (done === "removed") { + console.log(`[provisioner:influxdb-api] removed v1 authorization ${p.as}; its buckets and their data stay`); + } + }, + + // Asked every minute by the harness: whether InfluxDB still holds this consumer's authorization + // exactly as the mesh gave it, so one deleted, disabled or re-passworded behind the mesh's back is + // made whole again (hq issue 120). + async holds(p: Provision): Promise { + return grants.holds(p); + }, + }); +} diff --git a/modules/influxdb/test/grants.test.ts b/modules/influxdb/test/grants.test.ts new file mode 100644 index 0000000..7b7b78c --- /dev/null +++ b/modules/influxdb/test/grants.test.ts @@ -0,0 +1,246 @@ +// What holds influxdb to the `influxdb-api` provision (grants.ts): one v1 authorization per consumer, +// under the username and password the mesh gave, allowed only what the consumer contributed; made +// once and brought back on every apply; buckets created when missing and never deleted; and an +// authorization the mesh did not make — same name or not — never adopted, changed or deleted. +// +// InfluxDB is a fake: the routes the module touches, answering with the status codes and shapes +// InfluxDB 2.9 gives (a filter matching nothing is a 404, a password outside 8–72 characters a 400, +// an inactive authorization or a wrong password a 401 on /query). Run against the compiled module +// (npm test builds first), the way the runtime loads it. + +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { InfluxDBClient } from "../dist/client.js"; +import { ApiGrants, MARK, askedFor, marked } from "../dist/grants.js"; + +type Rec = Record; + +const ADMIN = "operator-token"; +const orgs = new Map([["zurag", "org1"]]); +let buckets: Rec[] = []; +let auths: Rec[] = []; +let calls: string[] = []; +let seq = 0; + +function body(req: IncomingMessage): Promise { + 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"); + const p = url.pathname; + calls.push(`${req.method} ${p}`); + if (p === "/query") { + const basic = (req.headers.authorization ?? "").replace(/^Basic /, ""); + const [u, pw] = Buffer.from(basic, "base64").toString().split(":"); + const a = auths.find((x) => x.token === u); + if (!a || a.status !== "active" || a.password === undefined || a.password !== pw) { + return send(res, 401, { code: "unauthorized", message: "Unauthorized" }); + } + return send(res, 200, { results: [{ statement_id: 0 }] }); + } + if (req.headers.authorization !== `Token ${ADMIN}`) return send(res, 401, { code: "unauthorized" }); + if (p === "/api/v2/orgs") { + const id = orgs.get(url.searchParams.get("org") ?? ""); + if (!id) return send(res, 404, { code: "not found", message: "organization name not found" }); + return send(res, 200, { orgs: [{ id, name: url.searchParams.get("org") }] }); + } + if (p === "/api/v2/buckets" && req.method === "GET") { + const found = buckets.filter((b) => b.orgID === url.searchParams.get("orgID") && b.name === url.searchParams.get("name")); + if (found.length === 0) return send(res, 404, { code: "not found", message: "bucket not found" }); + return send(res, 200, { buckets: found }); + } + if (p === "/api/v2/buckets" && req.method === "POST") { + const b = { ...(await body(req)), id: `b${++seq}` }; + buckets.push(b); + return send(res, 201, b); + } + if (p === "/private/legacy/authorizations" && req.method === "GET") { + const found = auths.filter((a) => a.token === url.searchParams.get("token")); + if (found.length === 0) return send(res, 404, { code: "not found", message: "authorization not found" }); + // Never answers with the password: InfluxDB keeps only its hash. + return send(res, 200, { authorizations: found.map(({ password, ...a }) => ({ ...a, links: {} })) }); + } + if (p === "/private/legacy/authorizations" && req.method === "POST") { + const a = await body(req); + if (auths.some((x) => x.token === a.token)) return send(res, 409, { code: "conflict", message: "token already exists" }); + const made = { ...a, id: `a${++seq}`, status: a.status ?? "active" }; + auths.push(made); + return send(res, 201, made); + } + const m = /^\/private\/legacy\/authorizations\/([^/]+)(\/password)?$/.exec(p); + const a = m && auths.find((x) => x.id === m[1]); + if (!a) return send(res, 404, { code: "not found" }); + if (m![2] && req.method === "POST") { + const { password } = await body(req); + if (typeof password !== "string" || password.length < 8 || password.length > 72) { + return send(res, 400, { code: "invalid", message: "passwords must be between 8 and 72 characters long" }); + } + a.password = password; + return send(res, 204); + } + if (req.method === "PATCH") { + Object.assign(a, await body(req)); + return send(res, 200, a); + } + if (req.method === "DELETE") { + auths = auths.filter((x) => x !== a); + return send(res, 204); + } + send(res, 405); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +after(() => server.close()); +const port = (server.address() as { port: number }).port; + +const grants = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "zurag"), "zurag"); + +const PW = "mesh-minted-password-of-forty-characters"; + +/** Grafana on ace, as the mesh hands it to the provisioner. */ +function grafana(password = PW, values: Record = { access: "read" }) { + return { as: "mesh_ace_grafana", password, consumer: "ace", values }; +} +/** Node-RED on ace: writes one bucket. */ +function nodered(password = PW, values: Record = { access: "write", buckets: ["zurag"] }) { + return { as: "mesh_ace_nodered", password, consumer: "ace", values }; +} + +function only(token: string): Rec { + const found = auths.filter((a) => a.token === token); + assert.equal(found.length, 1, `exactly one authorization ${token}, found ${found.length}`); + return found[0]; +} + +function perms(a: Rec): string[] { + return a.permissions.map((p: Rec) => `${p.action}:${p.resource.type}:${p.resource.id ?? "*"}`).sort(); +} + +beforeEach(() => { + buckets = [{ id: "zb", orgID: "org1", name: "zurag" }]; + auths = []; + calls = []; +}); + +test("what a contribution may ask for, and what is refused", () => { + assert.deepEqual(askedFor({}), { access: "read", buckets: [] }); + assert.deepEqual(askedFor({ access: "read-write", buckets: ["b", "a", "a"] }), { access: "read-write", buckets: ["a", "b"] }); + assert.throws(() => askedFor({ access: "admin" }), /access/); + assert.throws(() => askedFor({ access: "write" }), /names no bucket/); + assert.throws(() => askedFor({ buckets: "zurag" }), /list of bucket names/); + assert.throws(() => askedFor({ access: "write", buckets: ["_monitoring"] }), /system bucket/); +}); + +test("a reader is given one authorization, reading every bucket of the org, under the mesh's password", async () => { + assert.equal(await grants.ensure(grafana()), "created"); + const a = only("mesh_ace_grafana"); + assert.equal(a.orgID, "org1"); + assert.equal(a.status, "active"); + assert.ok(a.description.startsWith(MARK)); + assert.deepEqual(perms(a), ["read:buckets:*"]); + assert.equal(a.password, PW); + assert.equal(await grants.holds(grafana()), true); +}); + +test("a writer is allowed its own buckets only, and a missing one is made — never deleted", async () => { + assert.equal(await grants.ensure(nodered(PW, { access: "write", buckets: ["zurag", "printer"] })), "created"); + const made = buckets.find((b) => b.name === "printer"); + assert.ok(made, "the missing bucket was created"); + assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice"); + assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]); + assert.equal(await grants.remove("mesh_ace_nodered"), "removed"); + assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data"); +}); + +test("applying the same grant again writes nothing", async () => { + await grants.ensure(grafana()); + calls = []; + assert.equal(await grants.ensure(grafana()), "unchanged"); + assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`); + only("mesh_ace_grafana"); +}); + +test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => { + await grants.ensure(nodered()); + const id = only("mesh_ace_nodered").id; + assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false); + assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated"); + assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced"); + assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true); + + await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] })); + assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]); + assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true); +}); + +test("an authorization disabled, re-passworded or deleted behind the mesh's back is not held, and is made whole", async () => { + await grants.ensure(grafana()); + only("mesh_ace_grafana").status = "inactive"; + assert.equal(await grants.holds(grafana()), false); + assert.equal(await grants.ensure(grafana()), "updated"); + assert.equal(await grants.holds(grafana()), true); + + only("mesh_ace_grafana").password = "somebody-else-set-this"; + assert.equal(await grants.holds(grafana()), false); + await grants.ensure(grafana()); + assert.equal(await grants.holds(grafana()), true); + + auths = []; + assert.equal(await grants.holds(grafana()), false); + assert.equal(await grants.ensure(grafana()), "created"); +}); + +test("holds only reads, and a bucket gone missing is not held rather than made", async () => { + await grants.ensure(nodered()); + buckets = []; + calls = []; + assert.equal(await grants.holds(nodered()), false); + assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`); + assert.equal(buckets.length, 0); +}); + +test("an authorization of the same name the mesh did not make is refused, and left exactly as it was", async () => { + auths = [{ id: "theirs", token: "mesh_ace_grafana", orgID: "org1", status: "active", description: "hand-made", + permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }]; + const before = JSON.stringify(auths); + await assert.rejects(grants.ensure(grafana()), /did not make/); + assert.equal(JSON.stringify(auths), before); + assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`); + assert.equal(await grants.holds(grafana()), false); + assert.equal(await grants.remove("mesh_ace_grafana"), "not ours"); + assert.equal(auths.length, 1, "never deleted"); +}); + +test("the predecessor's own v1 users and tokens are never touched", async () => { + auths = [{ id: "hal", token: "grafana", orgID: "org1", status: "active", description: "", + permissions: [{ action: "read", resource: { type: "buckets", orgID: "org1" } }], password: "old-password" }]; + await grants.ensure(grafana()); + assert.equal(auths.find((a) => a.id === "hal")!.password, "old-password"); + assert.equal(await grants.remove("grafana"), "not ours"); + assert.equal(marked({ token: "grafana", description: `${MARK} x` }), false, "the mark needs the mesh's name too"); +}); + +test("an org the instance does not have, or a non-mesh name, makes nothing", async () => { + const elsewhere = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "nope"), "nope"); + await assert.rejects(elsewhere.ensure(grafana()), /no org "nope"/); + await assert.rejects(grants.ensure({ ...grafana(), as: "grafana" }), /not a mesh identity/); + assert.equal(auths.length, 0); +}); + +test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => { + await grants.ensure(grafana()); + assert.equal(await grants.remove("mesh_ace_grafana"), "removed"); + assert.equal(auths.length, 0); + assert.equal(await grants.remove("mesh_ace_grafana"), "absent"); +}); diff --git a/modules/influxdb/tsconfig.json b/modules/influxdb/tsconfig.json index 426d382..b5ddd2e 100644 --- a/modules/influxdb/tsconfig.json +++ b/modules/influxdb/tsconfig.json @@ -8,5 +8,10 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "tools/index.ts"] + "include": [ + "client.ts", + "grants.ts", + "provisioner/index.ts", + "tools/index.ts" + ] }