247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
// 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<string, any>;
|
||
|
||
const ADMIN = "operator-token";
|
||
const orgs = new Map<string, string>([["zurag", "org1"]]);
|
||
let buckets: Rec[] = [];
|
||
let auths: Rec[] = [];
|
||
let calls: string[] = [];
|
||
let seq = 0;
|
||
|
||
function body(req: IncomingMessage): Promise<any> {
|
||
return new Promise((resolve) => {
|
||
let raw = "";
|
||
req.on("data", (c) => (raw += c));
|
||
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
|
||
});
|
||
}
|
||
|
||
function send(res: ServerResponse, status: number, value?: unknown): void {
|
||
res.writeHead(status, { "Content-Type": "application/json" });
|
||
res.end(value === undefined ? "" : JSON.stringify(value));
|
||
}
|
||
|
||
const server = createServer(async (req, res) => {
|
||
const url = new URL(req.url!, "http://fake");
|
||
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<void>((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 node-a, as the mesh hands it to the provisioner. */
|
||
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
|
||
return { as: "mesh_node-a_grafana", password, consumer: "node-a", values };
|
||
}
|
||
/** Node-RED on node-a: writes one bucket. */
|
||
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
|
||
return { as: "mesh_node-a_nodered", password, consumer: "node-a", 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_node-a_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_node-a_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
|
||
assert.equal(await grants.remove("mesh_node-a_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_node-a_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_node-a_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_node-a_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_node-a_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_node-a_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_node-a_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_node-a_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_node-a_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_node-a_grafana"), "removed");
|
||
assert.equal(auths.length, 0);
|
||
assert.equal(await grants.remove("mesh_node-a_grafana"), "absent");
|
||
});
|