From 323ef9ec7ec1fba9e999b7ce99d256d42db237a5 Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 12:55:38 +0200 Subject: [PATCH] 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" + ] }