Prove a bucket is provisioned the way a database is
Seven assertions against a real store, the important one being that a consumer cannot reach another consumer's bucket — isolation here is a policy somebody wrote rather than a boundary the product has. The revocation test stages its own precondition. The first version asserted a key left by an earlier test, and the rotation test had already revoked it two tests early: the behaviour was correct and the test was measuring residue. Its precondition assertion is what caught that, rather than it passing green having verified nothing.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* The last step of a credential, against a real object store.
|
||||
*
|
||||
* The mesh generates a secret, seals it to the machine that must accept it, and discards the
|
||||
* plaintext — so it cannot tell the store to start accepting it. Something on that machine reads
|
||||
* what the host wrote and makes it true. This is the step where a secret either becomes a working
|
||||
* key or does not.
|
||||
*
|
||||
* **Phase 1.1 of the work breakdown**, and the finding that shaped it: the control plane
|
||||
* special-cases nothing. `provides`, `requires`, `contributes` and `grants` are name-agnostic, so
|
||||
* asking for a bucket needed no change to the mesh at all — only a provider that answers. What is
|
||||
* proven here is that half.
|
||||
*
|
||||
* **And the half a database does not have.** One PostgreSQL server holds separate databases, and
|
||||
* a role that cannot reach another's is a boundary the product enforces. One object store holds
|
||||
* everybody's buckets behind one endpoint, so a consumer being unable to reach another's is a
|
||||
* policy somebody wrote — which means it is a thing that can be written wrongly, and therefore a
|
||||
* thing to assert rather than assume.
|
||||
*/
|
||||
|
||||
import { test, after, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { loadScenario } from "../../src/declaration/parse.ts";
|
||||
import { raise } from "../../src/lifecycle/raise.ts";
|
||||
import { destroy, exec } from "../../src/lifecycle/operate.ts";
|
||||
import { labIsUsable, destroyAll } from "./harness.ts";
|
||||
import { incus } from "../../src/incus/client.ts";
|
||||
import { machineName } from "../../src/lifecycle/names.ts";
|
||||
|
||||
const capability = await labIsUsable();
|
||||
const provisioner = process.env["MESH_LAB_OBJECTSTORE_PROVISIONER"] ?? "";
|
||||
const skip = !capability.usable
|
||||
? `lab not usable: ${capability.why}`
|
||||
: !provisioner
|
||||
? "set MESH_LAB_OBJECTSTORE_PROVISIONER to a built provisioner " +
|
||||
"(mesh-control: go build ./examples/objectstore-provisioner)"
|
||||
: false;
|
||||
|
||||
const SCENARIO = "an-object-store";
|
||||
const MACHINE = "anchor";
|
||||
const GRANTS = "/var/lib/objectstore/grants";
|
||||
const ROOT_USER = "meshroot";
|
||||
const ROOT_PASSWORD = "meshroot-super-secret";
|
||||
const ROOT_PASSWORD_FILE = "/var/lib/objectstore/root.secret";
|
||||
const ENDPOINT = "http://127.0.0.1:9000";
|
||||
|
||||
let instanceId = "";
|
||||
/** The store's image, by digest, from the registry the scenario raised. */
|
||||
let storeImage = "";
|
||||
|
||||
function shellQuote(s: string): string {
|
||||
return `'${s.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function on(command: string): Promise<{ out: string; ok: boolean }> {
|
||||
const { stdout } = await exec(instanceId, MACHINE, [
|
||||
"sh", "-c", `${command} 2>&1; echo "__exit=$?"`,
|
||||
]);
|
||||
const marker = stdout.lastIndexOf("__exit=");
|
||||
const status = Number(stdout.slice(marker + 7).trim());
|
||||
return { out: stdout.slice(0, marker), ok: status === 0 };
|
||||
}
|
||||
|
||||
/** The same, refusing to continue past a failure nobody would otherwise see. */
|
||||
async function must(command: string): Promise<string> {
|
||||
const { out, ok } = await on(command);
|
||||
if (!ok) throw new Error(`${command}\n${out}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `mc` on the machine, against the store as root. */
|
||||
async function admin(args: string): Promise<{ out: string; ok: boolean }> {
|
||||
return on(`mc --config-dir /tmp/root-mc ${args}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write what the host would have written from a declaration: the manifest of who asked, and one
|
||||
* file per consumer holding its secret alone.
|
||||
*
|
||||
* Written here rather than by running the host, because what is under test is the step *after*
|
||||
* the host — and that the host writes these exact shapes is asserted in its own suite.
|
||||
*/
|
||||
async function meshWrote(
|
||||
consumers: { node: string; module: string; bucket: string; secret: string }[],
|
||||
): Promise<void> {
|
||||
const manifest = {
|
||||
contributions: consumers.length,
|
||||
requirement: "s3-bucket",
|
||||
generated: "by the mesh",
|
||||
given: consumers.map((c) => ({
|
||||
from: c.module,
|
||||
node: c.node,
|
||||
secret: `${GRANTS}/${c.node}.secret`,
|
||||
values: { bucket: c.bucket },
|
||||
})),
|
||||
};
|
||||
await must(`mkdir -p ${GRANTS}`);
|
||||
await must(`printf %s ${shellQuote(JSON.stringify(manifest))} > ${GRANTS}/mesh.json`);
|
||||
// Every credential file rewritten from nothing, so a removed consumer's does not linger and
|
||||
// make the revocation test pass for a reason that is not the one being tested.
|
||||
await must(`find ${GRANTS} -name '*.secret' -delete`);
|
||||
for (const c of consumers) {
|
||||
await must(`printf %s ${shellQuote(c.secret)} > ${GRANTS}/${c.node}.secret`);
|
||||
await must(`chmod 600 ${GRANTS}/${c.node}.secret`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The provisioner, as the module shipping the store would run it. */
|
||||
async function provision(): Promise<{ out: string; ok: boolean }> {
|
||||
return on(
|
||||
`GRANTS=${GRANTS} ` +
|
||||
`MESH_OBJECTSTORE_URL=${ENDPOINT} ` +
|
||||
`MESH_OBJECTSTORE_ROOT_USER=${ROOT_USER} ` +
|
||||
`MESH_OBJECTSTORE_ROOT_PASSWORD_FILE=${ROOT_PASSWORD_FILE} ` +
|
||||
`/usr/local/bin/mesh-provision-objectstore`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this key write to and read from this bucket?
|
||||
*
|
||||
* As the consumer, with its own `mc` configuration directory — never the root one. A check made
|
||||
* with the root alias still in scope would pass for any key at all, which is the object-store
|
||||
* shape of the mistake the database suite records: two of its tests once passed without verifying
|
||||
* a password, because they ran where PostgreSQL trusts the caller.
|
||||
*/
|
||||
async function canUse(key: string, secret: string, bucket: string): Promise<{ ok: boolean; out: string }> {
|
||||
const dir = `/tmp/as-${key}`;
|
||||
const { out, ok } = await on(
|
||||
`rm -rf ${dir} && mc --config-dir ${dir} alias set probe ${ENDPOINT} ${shellQuote(key)} ${shellQuote(secret)} && ` +
|
||||
`echo hello > /tmp/probe.txt && ` +
|
||||
`mc --config-dir ${dir} cp /tmp/probe.txt probe/${bucket}/probe.txt && ` +
|
||||
`mc --config-dir ${dir} cat probe/${bucket}/probe.txt`,
|
||||
);
|
||||
return { ok: ok && out.includes("hello"), out };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
const scenario = loadScenario(`scenarios/${SCENARIO}.yml`);
|
||||
const instance = await raise(scenario, {});
|
||||
instanceId = instance.instanceId;
|
||||
|
||||
// From the registry the scenario raised, by digest. There is no route to a public registry from
|
||||
// a documentation range, which is the point of the lab having its own.
|
||||
const store = instance.images.find((r) => r.includes("minio/minio"));
|
||||
const client = instance.images.find((r) => r.includes("minio/mc"));
|
||||
assert.ok(store, `the scenario stocked no store image: ${instance.images.join(", ")}`);
|
||||
assert.ok(client, `the scenario stocked no client image: ${instance.images.join(", ")}`);
|
||||
storeImage = store;
|
||||
|
||||
// The client, taken out of the vendor's own image onto the machine. The provisioner drives it,
|
||||
// so it has to be here — and taking it from the stocked image is what keeps this test off any
|
||||
// public network.
|
||||
await must(`docker create --name mc-source ${client}`);
|
||||
await must(`docker cp mc-source:/usr/bin/mc /usr/local/bin/mc && chmod 755 /usr/local/bin/mc`);
|
||||
await must(`docker rm mc-source`);
|
||||
|
||||
await must(`mkdir -p ${GRANTS}`);
|
||||
await must(`printf %s ${shellQuote(ROOT_PASSWORD)} > ${ROOT_PASSWORD_FILE} && chmod 600 ${ROOT_PASSWORD_FILE}`);
|
||||
|
||||
await must(
|
||||
`docker run -d --name mesh-store ` +
|
||||
`-e MINIO_ROOT_USER=${ROOT_USER} -e MINIO_ROOT_PASSWORD=${shellQuote(ROOT_PASSWORD)} ` +
|
||||
`-p 127.0.0.1:9000:9000 ${storeImage} server /data`,
|
||||
);
|
||||
|
||||
// Ready over the endpoint the provisioner will use, not by the container being up. A store that
|
||||
// is starting answers the port and refuses every operation, which is indistinguishable from a
|
||||
// wrong credential if it is not waited for.
|
||||
let ready = false;
|
||||
for (let i = 0; i < 90 && !ready; i++) {
|
||||
({ ok: ready } = await on(
|
||||
`mc --config-dir /tmp/root-mc alias set root ${ENDPOINT} ${ROOT_USER} ${shellQuote(ROOT_PASSWORD)}`,
|
||||
));
|
||||
if (!ready) await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
assert.ok(ready, "the store never became ready");
|
||||
|
||||
await incus([
|
||||
"file", "push", provisioner,
|
||||
`${machineName(instanceId, MACHINE)}/usr/local/bin/mesh-provision-objectstore`,
|
||||
"--mode", "0755",
|
||||
], 180_000);
|
||||
}, { timeout: 1_200_000 });
|
||||
|
||||
after(async () => {
|
||||
if (instanceId) await destroy(instanceId);
|
||||
await destroyAll(`${SCENARIO}-`);
|
||||
}, { timeout: 600_000 });
|
||||
|
||||
test("a secret the mesh generated becomes a key that works", { skip, timeout: 300_000 }, async () => {
|
||||
await meshWrote([
|
||||
{ node: "workstation", module: "photos", bucket: "photos", secret: "first-secret-aaaaaaaa" },
|
||||
]);
|
||||
const { out, ok } = await provision();
|
||||
assert.ok(ok, out);
|
||||
|
||||
const listed = await admin(`admin user list root --json`);
|
||||
assert.ok(listed.out.includes("mesh_workstation"), `no key was made for the consumer:\n${listed.out}`);
|
||||
|
||||
const used = await canUse("mesh_workstation", "first-secret-aaaaaaaa", "photos");
|
||||
assert.ok(used.ok, `the consumer cannot use the bucket the mesh gave it:\n${used.out}`);
|
||||
});
|
||||
|
||||
test("a consumer cannot reach another consumer's bucket", { skip, timeout: 300_000 }, async () => {
|
||||
// **The assertion this whole scenario exists for.** One store holds every bucket behind one
|
||||
// endpoint, so isolation is a policy rather than a property, and a policy granting
|
||||
// `arn:aws:s3:::*` would pass every other test in this file.
|
||||
await meshWrote([
|
||||
{ node: "workstation", module: "photos", bucket: "photos", secret: "first-secret-aaaaaaaa" },
|
||||
{ node: "laptop", module: "invoices", bucket: "invoices", secret: "second-secret-bbbbbbbb" },
|
||||
]);
|
||||
const { out, ok } = await provision();
|
||||
assert.ok(ok, out);
|
||||
|
||||
const own = await canUse("mesh_laptop", "second-secret-bbbbbbbb", "invoices");
|
||||
assert.ok(own.ok, `a consumer cannot use its own bucket:\n${own.out}`);
|
||||
|
||||
const other = await canUse("mesh_laptop", "second-secret-bbbbbbbb", "photos");
|
||||
assert.ok(!other.ok, `a consumer reached another consumer's bucket:\n${other.out}`);
|
||||
});
|
||||
|
||||
test("rotating the secret makes the new one work and the old one stop", { skip, timeout: 300_000 }, async () => {
|
||||
// The failure this guards is a provisioner that only ever creates: the mesh replaces the file,
|
||||
// the user exists, nothing happens, and a rotation reports success while changing nothing.
|
||||
await meshWrote([
|
||||
{ node: "workstation", module: "photos", bucket: "photos", secret: "rotated-secret-cccccccc" },
|
||||
]);
|
||||
const { out, ok } = await provision();
|
||||
assert.ok(ok, out);
|
||||
|
||||
const now = await canUse("mesh_workstation", "rotated-secret-cccccccc", "photos");
|
||||
assert.ok(now.ok, `the rotated secret does not work:\n${now.out}`);
|
||||
|
||||
const before = await canUse("mesh_workstation", "first-secret-aaaaaaaa", "photos");
|
||||
assert.ok(!before.ok, "the secret that was rotated away still works");
|
||||
});
|
||||
|
||||
test("a consumer that goes away loses its key", { skip, timeout: 300_000 }, async () => {
|
||||
// The half usually missing. Nothing reports a key that outlives its consumer, and it keeps
|
||||
// working for as long as nobody looks.
|
||||
//
|
||||
// **Stages its own precondition rather than inheriting one.** The first version asserted that
|
||||
// `mesh_laptop` was present, having been left by an earlier test — and by then the rotation
|
||||
// test had already rewritten the manifest without it, so revocation had happened for the right
|
||||
// reason two tests too early. The behaviour was correct and the test was measuring residue.
|
||||
await meshWrote([
|
||||
{ node: "workstation", module: "photos", bucket: "photos", secret: "rotated-secret-cccccccc" },
|
||||
{ node: "laptop", module: "invoices", bucket: "invoices", secret: "second-secret-bbbbbbbb" },
|
||||
]);
|
||||
const staged = await provision();
|
||||
assert.ok(staged.ok, staged.out);
|
||||
const present = await admin(`admin user list root --json`);
|
||||
assert.ok(present.out.includes("mesh_laptop"), `the consumer to be removed was never made:\n${present.out}`);
|
||||
|
||||
await meshWrote([
|
||||
{ node: "workstation", module: "photos", bucket: "photos", secret: "rotated-secret-cccccccc" },
|
||||
]);
|
||||
const { out, ok } = await provision();
|
||||
assert.ok(ok, out);
|
||||
|
||||
const after = await admin(`admin user list root --json`);
|
||||
assert.ok(!after.out.includes("mesh_laptop"), `a key nobody asks for survived:\n${after.out}`);
|
||||
const still = await canUse("mesh_laptop", "second-secret-bbbbbbbb", "invoices");
|
||||
assert.ok(!still.ok, "a revoked key still works");
|
||||
});
|
||||
|
||||
test("a key nobody here made is left alone", { skip, timeout: 300_000 }, async () => {
|
||||
// A provisioner that removed every key it did not recognise would be one nobody could safely
|
||||
// run against a store that predates it (novox/hq 04-ISSUES/010).
|
||||
await must(
|
||||
`mc --config-dir /tmp/root-mc admin user add root somebody-elses-key somebody-elses-secret`,
|
||||
);
|
||||
const { out, ok } = await provision();
|
||||
assert.ok(ok, out);
|
||||
|
||||
const listed = await admin(`admin user list root --json`);
|
||||
assert.ok(listed.out.includes("somebody-elses-key"),
|
||||
`a key this provisioner did not make was removed:\n${listed.out}`);
|
||||
});
|
||||
|
||||
test("a manifest naming a credential that was never written is refused", { skip, timeout: 300_000 }, async () => {
|
||||
// Refused rather than creating a user with no secret — a login nothing can use, which nothing
|
||||
// would report until something tried to connect.
|
||||
await meshWrote([
|
||||
{ node: "workstation", module: "photos", bucket: "photos", secret: "rotated-secret-cccccccc" },
|
||||
]);
|
||||
await must(`rm -f ${GRANTS}/workstation.secret`);
|
||||
|
||||
const { out, ok } = await provision();
|
||||
assert.ok(!ok, `it carried on without the credential:\n${out}`);
|
||||
assert.match(out, /workstation's credential/);
|
||||
});
|
||||
|
||||
test("a bucket name that would not work is refused by name", { skip, timeout: 300_000 }, async () => {
|
||||
// The refusal names the consumer that asked. The store would refuse it too, as an error inside
|
||||
// a provisioner log with nothing saying whose manifest caused it.
|
||||
await meshWrote([
|
||||
{ node: "workstation", module: "photos", bucket: "Photos_2026", secret: "rotated-secret-cccccccc" },
|
||||
]);
|
||||
const { out, ok } = await provision();
|
||||
assert.ok(!ok, `an unusable bucket name was accepted:\n${out}`);
|
||||
assert.match(out, /workstation asked for a bucket named/);
|
||||
});
|
||||
Reference in New Issue
Block a user