Make a link key per bucket, limited to get and put of its objects and kept as minio's own secret, so links are not signed by the root account (issue 1594)
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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"));
|
||||
});
|
||||
Reference in New Issue
Block a user