SDK: per-key tool serving (ADR 0052) and the provider contract (ADR 0053) #2
+4
-36
@@ -1,42 +1,10 @@
|
|||||||
// Small, stable primitives every module's code may need. No behaviour here changes when a module
|
// Small, stable primitives every module's code may need. No behaviour here changes when a module
|
||||||
// changes; that is the whole point of it living in the sdk.
|
// changes; that is the whole point of it living in the sdk.
|
||||||
|
|
||||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
|
||||||
|
|
||||||
// --- sealing ---
|
|
||||||
//
|
//
|
||||||
// A secret is sealed to a key so a copy of it at rest is not a working credential. AES-256-GCM;
|
// There was a symmetric seal()/unseal() here, for a provider to seal a credential to a key before
|
||||||
// the key is derived from a per-node passphrase the host holds. The host unseals on the machine;
|
// writing it. It is gone: a provider is handed the credential the mesh minted and seals nothing
|
||||||
// nothing else does (novox/hq ADR 0043's link is the boundary — the sdk only carries the mechanism).
|
// (novox/hq ADR 0053), the mesh's own secret delivery is asymmetric and belongs to the host, and
|
||||||
|
// nothing else called it. The primitive left with the provisioner that was its only caller.
|
||||||
const MAGIC = "msk1"; // versions the sealed format, so it can change without silent misreads
|
|
||||||
|
|
||||||
/** Seal plaintext to a passphrase. Returns `msk1:<salt>:<iv>:<tag>:<ciphertext>`, base64 parts. */
|
|
||||||
export function seal(plaintext: string, passphrase: string): string {
|
|
||||||
const salt = randomBytes(16);
|
|
||||||
const iv = randomBytes(12);
|
|
||||||
const key = scryptSync(passphrase, salt, 32);
|
|
||||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
||||||
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
||||||
const tag = cipher.getAuthTag();
|
|
||||||
return [MAGIC, b64(salt), b64(iv), b64(tag), b64(enc)].join(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unseal what seal produced. Throws — loudly — on any tamper or wrong key. */
|
|
||||||
export function unseal(sealed: string, passphrase: string): string {
|
|
||||||
const parts = sealed.split(":");
|
|
||||||
if (parts.length !== 5 || parts[0] !== MAGIC) {
|
|
||||||
throw new Error("not a sealed value this version understands");
|
|
||||||
}
|
|
||||||
const [, salt, iv, tag, enc] = parts.map((p, i) => (i === 0 ? Buffer.alloc(0) : ub64(p)));
|
|
||||||
const key = scryptSync(passphrase, salt, 32);
|
|
||||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
||||||
decipher.setAuthTag(tag);
|
|
||||||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
const b64 = (b: Buffer): string => b.toString("base64url");
|
|
||||||
const ub64 = (s: string): Buffer => Buffer.from(s, "base64url");
|
|
||||||
|
|
||||||
// --- semver ---
|
// --- semver ---
|
||||||
//
|
//
|
||||||
|
|||||||
+99
-76
@@ -1,74 +1,109 @@
|
|||||||
// The reconcile harness every provider shares. The loop below is identical for postgres, redis,
|
// The reconcile harness every provider shares. The loop below is identical for postgres, redis,
|
||||||
// minio and umami: read the grants the control plane has written, bring each requested resource to
|
// minio and umami: read the contributions the mesh delivered, bring each consumer's resource into
|
||||||
// existence through the provider's adapter, seal and hand back the credential, and remove what is
|
// being through the provider's adapter under the login and password the mesh minted, and withdraw
|
||||||
// no longer requested. A module writes ONLY the adapter — the per-service half — which is why this
|
// what the mesh no longer asks for. A module writes ONLY the adapter — the per-service half — which
|
||||||
// lives in the sdk and the adapter lives in the module (novox/hq ADR 0044).
|
// is why this lives in the sdk and the adapter lives in the module (novox/hq ADR 0044).
|
||||||
|
//
|
||||||
|
// **A provider creates the credential the mesh minted, and seals nothing (novox/hq ADR 0053).** The
|
||||||
|
// control plane mints one password per consumer and seals it to this node; the host unseals it into
|
||||||
|
// the file the contribution names. The provider does not generate a password, does not seal one, and
|
||||||
|
// does not hand one back — the consumer already receives its copy through the mesh's own channel.
|
||||||
|
// This is why the old $MESH_SEAL_KEY, the symmetric seal, and the *.grant.json / *.credential files
|
||||||
|
// are gone: they described a second, contradictory credential path the mesh does not have.
|
||||||
|
|
||||||
import { readdir, readFile, writeFile, rm } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
|
||||||
import type { Grant, Credential } from "../contracts/index.js";
|
|
||||||
import { seal } from "../primitives/index.js";
|
|
||||||
|
|
||||||
/** What a provider implements — the only per-service code. It is handed a Grant and creates (or
|
/** One consumer's resource to bring into being — everything the mesh derived and delivered. */
|
||||||
* removes) the resource in its own software, returning the credential a consumer receives. */
|
export interface Provision {
|
||||||
|
/** The login the mesh derived and gave the consumer to present. The provider creates exactly
|
||||||
|
* this name — a name the consumer cannot learn is a name it cannot authenticate with. */
|
||||||
|
readonly as: string;
|
||||||
|
/** The password the mesh minted for this consumer, read from the file the mesh sealed to this
|
||||||
|
* node and the host unsealed. The provider sets it; it never invents one. */
|
||||||
|
readonly password: string;
|
||||||
|
/** What the consumer contributed, per the interface's spec keys (e.g. `{ name: "umami" }`). */
|
||||||
|
readonly values: Readonly<Record<string, unknown>>;
|
||||||
|
/** Where the consumer is, for a provider that must reach back to it. Usually unused. */
|
||||||
|
readonly at?: string;
|
||||||
|
/** The consumer node, for logging and lifecycle events. */
|
||||||
|
readonly consumer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a provider implements — the only per-service code. It is handed the login and password the
|
||||||
|
* mesh made and brings the resource into being under them, or withdraws it. It returns nothing:
|
||||||
|
* the credential is the mesh's, already delivered to the consumer. */
|
||||||
export interface Adapter {
|
export interface Adapter {
|
||||||
create(grant: Grant): Promise<Credential>;
|
create(p: Provision): Promise<void>;
|
||||||
remove(grant: Grant): Promise<void>;
|
remove(p: { readonly as: string }): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProvisionerOptions {
|
export interface ProvisionerOptions {
|
||||||
/** Directory the control plane writes grant requests into and the harness writes credentials to.
|
/** The contributions file the mesh writes for this resource (the module's `receives` path).
|
||||||
* Defaults to $GRANTS. */
|
* Defaults to $MESH_RECEIVES. */
|
||||||
grants?: string;
|
receives?: string;
|
||||||
/** Passphrase a credential is sealed to before it is written. Defaults to $MESH_SEAL_KEY. */
|
|
||||||
sealKey?: string;
|
|
||||||
/** Reconcile interval in ms. Defaults to 5000. */
|
/** Reconcile interval in ms. Defaults to 5000. */
|
||||||
everyMs?: number;
|
everyMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Grant, Credential };
|
/** One entry in the mesh's contributions file: a consumer the provider must serve. */
|
||||||
|
interface Contribution {
|
||||||
|
readonly as: string;
|
||||||
|
readonly secret: string;
|
||||||
|
readonly node?: string;
|
||||||
|
readonly at?: string;
|
||||||
|
readonly values?: Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the reconcile loop for one provided resource. Returns a stop function. Never throws for a
|
* Run the reconcile loop for one provided resource. Returns a stop function. Never throws for a
|
||||||
* single bad grant — it logs and keeps converging, because one consumer's failure must not stop
|
* single bad contribution — it logs and keeps converging, because one consumer's failure must not
|
||||||
* the others' provisioning.
|
* stop the others' provisioning.
|
||||||
*/
|
*/
|
||||||
export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): () => void {
|
export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): () => void {
|
||||||
const dir = opts.grants ?? envOrThrow("GRANTS");
|
const receives = opts.receives ?? envOrThrow("MESH_RECEIVES");
|
||||||
const sealKey = opts.sealKey ?? envOrThrow("MESH_SEAL_KEY");
|
|
||||||
const everyMs = opts.everyMs ?? 5000;
|
const everyMs = opts.everyMs ?? 5000;
|
||||||
|
|
||||||
const applied = new Map<string, string>(); // consumer -> hash of the grant last applied
|
const applied = new Map<string, string>(); // login (`as`) -> hash of what was last applied
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
||||||
async function reconcile(): Promise<void> {
|
async function reconcile(): Promise<void> {
|
||||||
const requested = await readGrants(dir, resource);
|
const given = await readContributions(receives, resource);
|
||||||
const wantById = new Map(requested.map((g) => [g.consumer, g]));
|
const wantByAs = new Map(given.map((g) => [g.as, g]));
|
||||||
|
|
||||||
// Create or update anything requested whose grant has changed.
|
// Create or update every consumer whose login, password or values changed.
|
||||||
for (const grant of requested) {
|
for (const g of given) {
|
||||||
const h = hash(grant);
|
let password: string;
|
||||||
if (applied.get(grant.consumer) === h) continue;
|
|
||||||
try {
|
try {
|
||||||
const cred = await adapter.create(grant);
|
// The file the mesh sealed to this node, unsealed by the host into plaintext. Trailing
|
||||||
await writeSealedCredential(dir, grant, cred, sealKey);
|
// newline trimmed: a sealed value is exactly the secret, and a host writing a file may add
|
||||||
applied.set(grant.consumer, h);
|
// one.
|
||||||
|
password = (await readFile(g.secret, "utf8")).replace(/\n$/, "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[provisioner:${resource}] ${grant.consumer}: create failed, will retry: ${err}`);
|
// The contribution names a secret the host has not written yet — a normal race on the
|
||||||
|
// first pass. Skipped, retried on the next tick, never fatal.
|
||||||
|
console.error(`[provisioner:${resource}] ${g.as}: secret not readable yet (${g.secret}): ${err}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const h = hash(g.as, password, g.values ?? {});
|
||||||
|
if (applied.get(g.as) === h) continue;
|
||||||
|
try {
|
||||||
|
await adapter.create({ as: g.as, password, values: g.values ?? {}, at: g.at, consumer: g.node });
|
||||||
|
applied.set(g.as, h);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[provisioner:${resource}] ${g.as}: create failed, will retry: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove anything applied that is no longer requested. Only the harness's own outputs are
|
// Withdraw every login the provider made that the mesh no longer asks for. The mesh drops a
|
||||||
// touched — the credential file it wrote — never anything it did not create.
|
// consumer from the file when it goes away; that is how a login is withdrawn rather than kept
|
||||||
for (const consumer of [...applied.keys()]) {
|
// working for ever. Only logins this harness created are touched.
|
||||||
if (wantById.has(consumer)) continue;
|
for (const as of [...applied.keys()]) {
|
||||||
const grant = lastGrant(dir, resource, consumer);
|
if (wantByAs.has(as)) continue;
|
||||||
try {
|
try {
|
||||||
await adapter.remove(grant);
|
await adapter.remove({ as });
|
||||||
await rm(credentialPath(dir, resource, consumer), { force: true });
|
applied.delete(as);
|
||||||
applied.delete(consumer);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[provisioner:${resource}] ${consumer}: remove failed, will retry: ${err}`);
|
console.error(`[provisioner:${resource}] ${as}: remove failed, will retry: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,47 +124,35 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- grant/credential files ---
|
/**
|
||||||
|
* Read the mesh's contributions file for one resource. Absent or empty means "no consumer asks for
|
||||||
async function readGrants(dir: string, resource: string): Promise<Grant[]> {
|
* this" — the file is always written, so a provider can tell that from "the mesh never wrote it".
|
||||||
let names: string[];
|
*/
|
||||||
|
async function readContributions(path: string, resource: string): Promise<Contribution[]> {
|
||||||
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
names = await readdir(dir);
|
raw = await readFile(path, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
|
return []; // not written yet, or nobody serves anything here — nothing to converge to
|
||||||
|
}
|
||||||
|
let doc: { requirement?: string; given?: Contribution[] };
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(raw) as { requirement?: string; given?: Contribution[] };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[provisioner:${resource}] contributions file is not JSON (${path}): ${err}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const out: Grant[] = [];
|
if (doc.requirement && doc.requirement !== resource) {
|
||||||
for (const name of names) {
|
console.error(`[provisioner:${resource}] ${path} is for ${doc.requirement}, not ${resource}`);
|
||||||
if (!name.endsWith(".grant.json")) continue;
|
return [];
|
||||||
try {
|
|
||||||
const raw = await readFile(join(dir, name), "utf8");
|
|
||||||
const g = JSON.parse(raw) as Grant;
|
|
||||||
if (g.resource === resource) out.push(g);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[provisioner:${resource}] unreadable grant ${name}: ${err}`);
|
|
||||||
}
|
}
|
||||||
}
|
// A contribution with no `as` is not a credential grant (a module offering something on its own
|
||||||
return out;
|
// machine) — the provisioner has nothing to create for it.
|
||||||
|
return (doc.given ?? []).filter((g) => g.as && g.secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
function credentialPath(dir: string, resource: string, consumer: string): string {
|
function hash(as: string, password: string, values: Readonly<Record<string, unknown>>): string {
|
||||||
return join(dir, `${consumer}.${resource}.credential`);
|
return JSON.stringify([as, password, values]);
|
||||||
}
|
|
||||||
|
|
||||||
async function writeSealedCredential(dir: string, grant: Grant, cred: Credential, key: string): Promise<void> {
|
|
||||||
const body = JSON.stringify({ resource: grant.resource, consumer: grant.consumer, fields: cred.fields });
|
|
||||||
await writeFile(credentialPath(dir, grant.resource, grant.consumer), seal(body, key), { mode: 0o600 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function lastGrant(dir: string, resource: string, consumer: string): Grant {
|
|
||||||
// For removal the harness needs a Grant to hand the adapter; the identity is enough to act on.
|
|
||||||
return { resource, consumer, node: "", values: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
|
||||||
|
|
||||||
function hash(g: Grant): string {
|
|
||||||
return JSON.stringify([g.resource, g.consumer, g.node, g.values]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function envOrThrow(name: string): string {
|
function envOrThrow(name: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user