173 lines
8.0 KiB
TypeScript
173 lines
8.0 KiB
TypeScript
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<string>();
|
|
keys = new Map<string, { bucket: string; secret: string; policy: string }>();
|
|
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/<bucket>.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");
|
|
});
|
|
|
|
// Issue 1766: a link key's secret is minio's length, and no mc failure carries an argument's secret.
|
|
|
|
import { chmodSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { MinioClient } from "../client.ts";
|
|
|
|
test("a link key's secret is 40 characters, and a stored 64-character secret is replaced", async () => {
|
|
// 1766/AC1
|
|
const { minio, dir } = setup();
|
|
await ensureLinkKey(minio, dir, "fresh");
|
|
assert.equal(minio.keys.get(linkKeyId("fresh"))!.secret.length, 40);
|
|
assert.match(minio.keys.get(linkKeyId("fresh"))!.secret, /^[0-9a-f]{40}$/);
|
|
|
|
mkdirSync(dir, { recursive: true });
|
|
const old = "ab".repeat(32);
|
|
writeFileSync(linkSecretPath(dir, "old"), old + "\n");
|
|
await ensureLinkKey(minio, dir, "old");
|
|
const made = minio.keys.get(linkKeyId("old"))!.secret;
|
|
assert.equal(made.length, 40);
|
|
assert.notEqual(made, old);
|
|
assert.equal(readFileSync(linkSecretPath(dir, "old"), "utf8").trim(), made);
|
|
});
|
|
|
|
function failingMc(): { client: MinioClient; root: string } {
|
|
const d = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "minio-mc-"));
|
|
const bin = join(d, "mc");
|
|
// Says its arguments and its alias on stderr, then fails, the way a refused command does.
|
|
writeFileSync(bin, '#!/bin/sh\necho "args: $*" >&2\necho "env: MC_HOST_mesh=$MC_HOST_mesh" >&2\nexit 1\n');
|
|
chmodSync(bin, 0o755);
|
|
const root = "RootPass-s3cret/ok";
|
|
const client = new MinioClient({ endpoint: "http://127.0.0.1:9000", rootUser: "root", rootPassword: root, region: "us-east-1", mcBin: bin, mcConfigDir: d });
|
|
return { client, root };
|
|
}
|
|
|
|
test("an mc failure's message holds no --secret-key value and no MC_HOST value", async () => {
|
|
// 1766/AC2
|
|
const { client, root } = failingMc();
|
|
const secret = "SuperSecretValue0123456789";
|
|
const err: any = await client.createAccessKey("b", "ACCESSKEYID1234567", secret).then(() => null, (e) => e);
|
|
assert.ok(err, "the failure is raised");
|
|
for (const text of [String(err.message), String(err.stack ?? ""), JSON.stringify(Object.getOwnPropertyNames(err).map((k) => err[k]))]) {
|
|
assert.ok(!text.includes(secret), "no secret key");
|
|
assert.ok(!text.includes("ACCESSKEYID1234567"), "no access key");
|
|
assert.ok(!text.includes(root) && !text.includes(encodeURIComponent(root)), "no root password");
|
|
assert.ok(!text.includes("root:"), "no MC_HOST value");
|
|
}
|
|
assert.match(err.message, /\[redacted\]/);
|
|
});
|
|
|
|
test("a consumer key's create passes the minted secret unchanged", async () => {
|
|
// 1766/AC3
|
|
const s = setup();
|
|
await s.h.create(provision);
|
|
assert.equal(s.minio.keys.get("brother-key")!.secret, "consumer-secret");
|
|
});
|