// 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"; } }