Files
mesh-catalog/modules/minio/links.ts
T

65 lines
3.2 KiB
TypeScript

// The link key — a second service account per bucket, beside the consumer's own key (novox/hq ADR 0468 §8,
// issue 1594). The object-store seat signs the links (issue 1516) with this key and never with the root
// account: its policy allows only s3:GetObject and s3:PutObject on the bucket's objects, so a link can read or
// write an object of that bucket and do nothing else. Its secret is minio's own: a file under the state
// directory (links/<bucket>.secret, 0600), never in a grant, never on the bus, never in an emitted event.
//
// This file imports nothing of the SDK, so its tests run without the toolchain's packages.
import { createHash, randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** What the link key needs of the admin client (MinioClient has all three). */
export interface LinkAdmin {
createAccessKey(bucket: string, accessKey: string, secretKey: string, policy?: string): Promise<unknown>;
removeAccessKey(accessKey: string): Promise<void>;
accessKeyExists(accessKey: string): Promise<boolean>;
}
/** The access key id of a bucket's link key: "link" and the first 16 hex digits of the SHA-256 of the bucket
* name (20 characters, the longest an access key may be; a bucket name may be 63). The signer of issue 1516
* derives the same id. */
export function linkKeyId(bucket: string): string {
return "link" + createHash("sha256").update(bucket, "utf8").digest("hex").slice(0, 16);
}
/** Only get and put of the bucket's objects: no list, no delete, no bucket-level action. */
export function linkPolicy(bucket: string): string {
return JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Action: ["s3:GetObject", "s3:PutObject"],
Resource: [`arn:aws:s3:::${bucket}/*`],
}],
});
}
export function linkSecretPath(dir: string, bucket: string): string {
return join(dir, `${bucket}.secret`);
}
/** Make the bucket's link key if it is not there: the secret is kept if its file exists (so a key lost in
* minio is made again under the same secret), made fresh otherwise. Idempotent: a key that exists is left. */
export async function ensureLinkKey(admin: LinkAdmin, dir: string, bucket: string): Promise<void> {
const id = linkKeyId(bucket);
const path = linkSecretPath(dir, bucket);
mkdirSync(dir, { recursive: true, mode: 0o700 });
let secret = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
if (secret !== "" && (await admin.accessKeyExists(id))) return;
if (secret === "") {
secret = randomBytes(32).toString("hex");
writeFileSync(path, secret + "\n", { mode: 0o600 });
chmodSync(path, 0o600);
}
try { await admin.removeAccessKey(id); } catch { /* none yet */ }
await admin.createAccessKey(bucket, id, secret, linkPolicy(bucket));
}
/** Revoke the bucket's link key; the bucket and the secret file's absence are left as they are. */
export async function revokeLinkKey(admin: LinkAdmin, dir: string, bucket: string): Promise<void> {
try { await admin.removeAccessKey(linkKeyId(bucket)); } catch { /* already gone */ }
try { unlinkSync(linkSecretPath(dir, bucket)); } catch { /* none */ }
}