import assert from "node:assert/strict"; import { test } from "node:test"; import { mkdtempSync, readFileSync, statSync, existsSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; // Issue 1594: a link key per bucket (novox/hq ADR 0468 ยง8). The fake admin below stands for minio's service // accounts; the real client's policies come from client.ts's bucketPolicy, which the tests import. import { bucketPolicy } from "../client.ts"; import { ensureLinkKey, linkKeyId, linkSecretPath } from "../links.ts"; import { handlers } from "../provisioner/handlers.ts"; class FakeAdmin { baseUrl = "http://127.0.0.1:9000"; buckets = new Set(); keys = new Map(); created: string[] = []; async bucketExists(b: string) { return this.buckets.has(b); } async createBucket(b: string) { this.buckets.add(b); } async canReachAs() { return true; } async accessKeyExists(id: string) { return this.keys.has(id); } async removeAccessKey(id: string) { if (!this.keys.delete(id)) throw new Error("no such key"); } async createAccessKey(bucket: string, id: string, secret: string, policy: string = bucketPolicy(bucket)) { this.keys.set(id, { bucket, secret, policy }); this.created.push(id); return { accessKey: id, secretKey: secret }; } } function setup() { const minio = new FakeAdmin(); const dir = join(mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "minio-links-")), "links"); const events: Array<{ type: string; body: unknown }> = []; const h = handlers({ minio, linksDir: dir, announce: async (type, body) => { events.push({ type, body }); } }); return { minio, dir, events, h }; } const provision = { as: "brother-key", password: "consumer-secret", consumer: "brother", derived: { bucket: "brother" } } as any; test("creating a consumer's bucket makes a link key allowed only get and put of the bucket's objects, its secret in links/.secret at 0600", async () => { // 1594/AC1 const { minio, dir, h } = setup(); await h.create(provision); const link = minio.keys.get(linkKeyId("brother")); assert.ok(link, "a link key exists for the bucket"); const policy = JSON.parse(link.policy); assert.equal(policy.Statement.length, 1); assert.deepEqual(policy.Statement[0].Action, ["s3:GetObject", "s3:PutObject"]); assert.deepEqual(policy.Statement[0].Resource, ["arn:aws:s3:::brother/*"]); const path = linkSecretPath(dir, "brother"); assert.equal(statSync(path).mode & 0o777, 0o600); assert.equal(readFileSync(path, "utf8").trim(), link.secret); assert.ok(link.secret.length >= 32); }); test("buckets that existed before get a link key at start, and a second start makes none twice", async () => { // 1594/AC2 const { minio, dir } = setup(); for (const b of ["brother", "mesh-mail", "nextcloud", "invoicing"]) minio.buckets.add(b); for (const b of minio.buckets) await ensureLinkKey(minio, dir, b); assert.equal(minio.keys.size, 4); const first = [...minio.created]; const secret = readFileSync(linkSecretPath(dir, "nextcloud"), "utf8"); for (const b of minio.buckets) await ensureLinkKey(minio, dir, b); assert.deepEqual(minio.created, first, "no key made a second time"); assert.equal(readFileSync(linkSecretPath(dir, "nextcloud"), "utf8"), secret, "the secret is kept"); }); test("a link key lost in minio is made again under the kept secret", async () => { // 1594/AC2 const { minio, dir } = setup(); await ensureLinkKey(minio, dir, "brother"); const secret = minio.keys.get(linkKeyId("brother"))!.secret; minio.keys.clear(); await ensureLinkKey(minio, dir, "brother"); assert.equal(minio.keys.get(linkKeyId("brother"))!.secret, secret); }); test("removing a consumer revokes its bucket's link key and keeps the bucket", async () => { // 1594/AC3 const { minio, dir, h } = setup(); await h.create(provision); assert.ok(minio.keys.has(linkKeyId("brother"))); await h.remove({ as: "brother-key", derived: { bucket: "brother" } }); assert.ok(!minio.keys.has(linkKeyId("brother")), "link key revoked"); assert.ok(!minio.keys.has("brother-key"), "consumer key revoked"); assert.ok(minio.buckets.has("brother"), "bucket kept"); assert.ok(!existsSync(linkSecretPath(dir, "brother")), "secret file removed"); }); test("the consumer's own key keeps its s3:* policy, and the link key's secret is in no event", async () => { // 1594/AC4 const { minio, dir, events, h } = setup(); await h.create(provision); const own = minio.keys.get("brother-key")!; assert.equal(own.secret, "consumer-secret"); assert.equal(own.policy, bucketPolicy("brother")); assert.deepEqual(JSON.parse(own.policy).Statement[0].Action, ["s3:*"]); const linkSecret = minio.keys.get(linkKeyId("brother"))!.secret; await h.remove({ as: "brother-key", derived: { bucket: "brother" } }); assert.ok(events.length >= 2); for (const e of events) assert.ok(!JSON.stringify(e).includes(linkSecret), `${e.type} carries no link secret`); assert.deepEqual(readdirSync(dir), [], "nothing else is kept in the links directory"); }); test("a link key id fits an access key (20 characters) whatever the bucket name", () => { assert.equal(linkKeyId("a".repeat(63)).length, 20); assert.notEqual(linkKeyId("brother"), linkKeyId("mesh-mail")); }); test("the link key id is the one the object-store tool derives (cmd/object-store server_test.go holds the same literal)", () => { // 1516/AC1 assert.equal(linkKeyId("brother"), "link0b4bd77cec705cf5"); });