Every lifecycle event that ends a copy's authority (unbind, emergency revoke, slot removal or expiry, role change, fire, collision, shadowing, archive, delete and recreate) now closes admissions, drains in-flight uses, stops runs with a confirmed ack, removes the slot and revokes the Gitea token through the org admin slot. The binding moves through a durable revoking state, so a restart resumes the same op and never resurrects authority; an uncertain outcome stays fenced.
417 lines
26 KiB
JavaScript
417 lines
26 KiB
JavaScript
// Copy credential bindings, slices CB0 and CB1 (#1873; design
|
|
// docs/plans/2026-11-11_copy-credential-binding-design.md sections 3, 5 and 7).
|
|
//
|
|
// An organization binds a service to one concrete copy (a root agent's copy or a project agent's
|
|
// copy) through a record under the organization file's top-level `copyBindings`, keyed by the
|
|
// copy's instance name. A record holds identities, known scope names, project ids and integers:
|
|
// never a token, a token fragment or digest, a path or an environment name.
|
|
//
|
|
// { copyId, orgIncarnation, source: { level, name, project }, definition: { ref, rev, sha256 },
|
|
// generation, state, services: { gitea?: { slot, scopes, resources, rotateBy?, grantId?,
|
|
// grantGeneration? }, vikunja?: {...} }, decision, tracker? }
|
|
//
|
|
// copyId is 128 random bits minted on the first approval for that copy; it is kept, with the
|
|
// generation counting up, through every later change and the unbound tombstone. orgIncarnation is
|
|
// the registry's (org-registry.mjs): a record copied from another organization, or from a removed
|
|
// organization with the same id, never matches. `source` is the copy's level, name and project, so
|
|
// a dotted-name collision or a reused source name never inherits a record. `definition` pins the
|
|
// copy's definition identity: an org definition by its rev and the hash of its whole entry
|
|
// (composition, instructions and personality), a role template by the hash readTemplate takes over
|
|
// its file and contract.
|
|
//
|
|
// States: `pending` is approved and not verified. Only a verifier (slice CB2) may turn a record
|
|
// `bound`, and only `bound` resolves. `unbound` is the tombstone an unbind leaves.
|
|
//
|
|
// CB2 (grant metadata): a service the verifier proved carries `rotateBy` (the owner's rotation day),
|
|
// `grantId` (the service's opaque grant id, `gitea:<user id>:<token id>`: ids the service assigned,
|
|
// never token bytes) and `grantGeneration` (counts every verified grant of that service; it stays
|
|
// when a new issue strips the other two). The verifier's commit writes `bound` only when every
|
|
// service holds a grant, and no grant id appears twice across the organization's copies. Launch
|
|
// admission, which re-proves each grant, is CB3.
|
|
//
|
|
// CB4 (revocation and lifecycle, design section 6): `revoking` is the durable pending operation a
|
|
// revoke, an unbind of a granted record, a slot loss, an expiry, an upstream revoke, a role change,
|
|
// a fire, a shadow, a collision, an archive or a delete leaves before any stop or remote call. It
|
|
// keeps its services (and grant ids), so a restart knows what to revoke and the boot sweep keeps the
|
|
// slot until the job ends, and it never resolves. `revoke` is { id, cause, at, failed?, remote? } while
|
|
// revoking: `failed` lists the steps (stop, slot, remote) whose outcome was not definite, `remote` the
|
|
// services whose outcome already was, and the record stays fenced (credential-revoke-failed) until a
|
|
// retry finishes them. The unbound tombstone
|
|
// it ends in carries { id, cause, at, done: true, remote: { service: outcome } }, where an outcome
|
|
// is revoked, absent, not-managed (no admin slot to revoke with) or not-attempted. Never a token.
|
|
//
|
|
// Everything here is pure: no file is read, no secret, no slot.
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { refuse } from "./errors.mjs";
|
|
import { GITEA_SCOPE_CATEGORIES, HOST_SERVICE, ID_PATTERN, SERVICES, VIKUNJA_GRANTABLE } from "./vocabulary.mjs";
|
|
import { ORG_PREFIX } from "./definitions.mjs";
|
|
import { canonicalJson, isPlainObject, sha256 } from "./util.mjs";
|
|
|
|
export const COPY_ID = /^[0-9a-f]{32}$/;
|
|
export const INCARNATION = /^[0-9a-f]{32}$/;
|
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
|
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
|
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker", "revoke"];
|
|
const SERVICE_KEYS = ["slot", "scopes", "resources"];
|
|
const GRANT_KEYS = ["rotateBy", "grantId", "grantGeneration"];
|
|
const GRANT_ID = /^(gitea|vikunja):[1-9][0-9]{0,15}:[1-9][0-9]{0,15}$/;
|
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
const realDay = (value) => typeof value === "string" && DAY.test(value) && !Number.isNaN(Date.parse(value))
|
|
&& new Date(value).toISOString().slice(0, 10) === value;
|
|
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound", "revoking"]);
|
|
// CB4: why a record is revoking or tombstoned, and what the next launch or call answers for it.
|
|
export const REVOKE_CAUSES = Object.freeze(["revoke", "unbind", "unavailable", "expired", "upstream", "role-changed", "retire", "shadowed", "collision", "archive", "delete"]);
|
|
export const REVOKE_STEPS = Object.freeze(["stop", "slot", "remote"]);
|
|
export const REMOTE_OUTCOMES = Object.freeze(["revoked", "absent", "not-managed", "not-attempted"]);
|
|
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
// The tombstone codes the resolver answers for a copy that is still there; the copy codes come from
|
|
// the copy itself, and an archive or delete tombstone answers credential-unbound.
|
|
const TOMBSTONE_CODES = ["credential-revoked", "credential-expired", "credential-unavailable", "credential-role-changed"];
|
|
// The refusal a tombstone's cause answers at the next launch or call (design section 6).
|
|
export const CAUSE_CODES = Object.freeze({
|
|
revoke: "credential-revoked", upstream: "credential-revoked", unbind: "credential-unbound", unavailable: "credential-unavailable",
|
|
expired: "credential-expired", "role-changed": "credential-role-changed", retire: "copy-unavailable", shadowed: "copy-unavailable",
|
|
collision: "copy-refused", archive: "credential-unbound", delete: "credential-unbound",
|
|
});
|
|
// CB1 binds the core services (vocabulary SERVICES) only. A plugin service has no verifier design yet.
|
|
export const BINDABLE_SERVICES = SERVICES;
|
|
export const MAX_BINDINGS = 256;
|
|
|
|
export const newCopyId = () => randomBytes(16).toString("hex");
|
|
|
|
// CB2: the one service account a copy's binding may use, derived from the registry incarnation, the
|
|
// copyId and the service, so another organization, another copy and another service never share
|
|
// it. Gitea limits a login to 40 characters; this is 36. Not a secret and not token-derived.
|
|
export function copyBotLogin(incarnation, copyId, service) {
|
|
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || typeof copyId !== "string" || !COPY_ID.test(copyId)
|
|
|| !BINDABLE_SERVICES.includes(service)) return null;
|
|
return `mosaic-copy-${sha256(`copy-bot\0${incarnation}\0${copyId}\0${service}`).slice(0, 24)}`;
|
|
}
|
|
// Whether every service of a binding record holds a verified grant (CB2).
|
|
export const allGranted = (services) => isPlainObject(services) && Object.keys(services).length > 0
|
|
&& Object.values(services).every((v) => isPlainObject(v) && typeof v.grantId === "string");
|
|
|
|
// Fixed text for every binding refusal. `service` is a registered service label or null, never a
|
|
// submitted value.
|
|
const TEXT = {
|
|
"human-required": () => "Only the organization owner can approve credential binding.",
|
|
"credential-unbound": (s) => `credentials.${s} needs an owner-approved binding.`,
|
|
"credential-scope-exceeded": (s) => `credentials.${s}.scopes exceeds the approved role.`,
|
|
"credential-scope-unverified": (s) => `credentials.${s} permissions could not be verified.`,
|
|
"credential-identity-refused": (s) => `credentials.${s} is not this copy's own service account.`,
|
|
"credential-date": (s) => `credentials.${s} rotation date is invalid.`,
|
|
"credential-binding-changed": (s) => `credentials.${s} changed; read and approve again.`,
|
|
"credential-role-changed": (s) => `credentials.${s} no longer matches the approved definition; the owner must review the binding.`,
|
|
"copy-unavailable": () => "copy is no longer active in this organization.",
|
|
"copy-refused": () => "copy identity is ambiguous; the owner must resolve the definition collision.",
|
|
"credential-revoked": (s) => `credentials.${s} was revoked for this copy.`,
|
|
"credential-revoking": (s) => `credentials.${s} is being revoked for this copy.`,
|
|
"credential-unavailable": (s) => `credentials.${s} is not available for this copy.`,
|
|
"credential-expired": (s) => `credentials.${s} passed its rotation date.`,
|
|
"credential-revoke-failed": (s) => `credentials.${s} revocation is incomplete; the owner must retry cleanup.`,
|
|
};
|
|
const label = (service) => (BINDABLE_SERVICES.includes(service) ? service : "service");
|
|
export function bindingText(code, service = null) {
|
|
const text = TEXT[code];
|
|
return text ? text(label(service)) : code;
|
|
}
|
|
|
|
export class CopyBindingError extends Error {
|
|
constructor(code, service = null) {
|
|
super(bindingText(code, service));
|
|
this.name = "CopyBindingError";
|
|
this.code = code;
|
|
if (service !== null) this.service = label(service);
|
|
}
|
|
}
|
|
export const bindingFail = (code, service = null) => { throw new CopyBindingError(code, service); };
|
|
|
|
// ---- Definition pin ----
|
|
|
|
// The identity a binding pins for a copy's definition. `definitions` is the merged root and org
|
|
// definition map as the boot snapshot holds it; `readTemplate` reads a role template (definitions.mjs).
|
|
// An org definition: its rev and the hash of its canonical entry. A template: rev null and the
|
|
// template hash. A version 1 template has no contract and cannot be pinned: null.
|
|
export function definitionPin(ref, { definitions = {}, rolesDir, readTemplate }) {
|
|
if (typeof ref !== "string") return null;
|
|
if (ref.startsWith(ORG_PREFIX)) {
|
|
const name = ref.slice(ORG_PREFIX.length);
|
|
const entry = Object.hasOwn(definitions, name) ? definitions[name] : null;
|
|
if (!isPlainObject(entry) || !Number.isSafeInteger(entry.rev) || entry.rev < 1) return null;
|
|
return { ref, rev: entry.rev, sha256: sha256(canonicalJson(entry)) };
|
|
}
|
|
if (!ID_PATTERN.test(ref) || typeof readTemplate !== "function") return null;
|
|
try {
|
|
return { ref, rev: null, sha256: readTemplate(rolesDir, ref).sha256 };
|
|
} catch (error) {
|
|
if (error?.name === "BusinessError") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
export const samePin = (a, b) => isPlainObject(a) && isPlainObject(b) && a.ref === b.ref && a.rev === b.rev && a.sha256 === b.sha256;
|
|
|
|
// ---- Scope semantics ----
|
|
|
|
// Gitea: {category: "read"|"write"}, one level per category. admin and all are never granted.
|
|
function giteaLevels(scopes) {
|
|
if (!Array.isArray(scopes)) return null;
|
|
const out = {};
|
|
for (const scope of scopes) {
|
|
const m = typeof scope === "string" ? GITEA_SCOPE.exec(scope) : null;
|
|
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2]) || Object.hasOwn(out, m[2])) return null;
|
|
out[m[2]] = m[1];
|
|
}
|
|
return out;
|
|
}
|
|
const RANK = { read: 1, write: 2 };
|
|
// Vikunja: the set of "group verb" pairs, each one VIKUNJA_GRANTABLE lists.
|
|
function vikunjaPairs(scopes) {
|
|
if (!isPlainObject(scopes)) return null;
|
|
const out = new Set();
|
|
for (const [group, verbs] of Object.entries(scopes)) {
|
|
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group) || !Array.isArray(verbs) || verbs.length === 0) return null;
|
|
for (const verb of verbs) {
|
|
if (typeof verb !== "string" || !VIKUNJA_GRANTABLE[group].includes(verb) || out.has(`${group} ${verb}`)) return null;
|
|
out.add(`${group} ${verb}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
export function validScopes(service, scopes) {
|
|
if (service === "gitea") { const l = giteaLevels(scopes); return l !== null && Object.keys(l).length > 0; }
|
|
if (service === "vikunja") { const p = vikunjaPairs(scopes); return p !== null && p.size > 0; }
|
|
return false;
|
|
}
|
|
|
|
// Whether `approved` is within `role` for the service, compared by the service's semantics: Gitea
|
|
// read is narrower than write in one category and categories are independent; Vikunja compares
|
|
// each group and verb pair. A missing category or group is no permission. Never a string compare.
|
|
export function scopesWithin(service, approved, role) {
|
|
if (service === "gitea") {
|
|
const a = giteaLevels(approved), r = giteaLevels(role);
|
|
if (!a || !r) return false;
|
|
return Object.entries(a).every(([category, level]) => Object.hasOwn(r, category) && RANK[level] <= RANK[r[category]]);
|
|
}
|
|
if (service === "vikunja") {
|
|
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
|
|
if (!a || !r) return false;
|
|
return [...a].every((pair) => r.has(pair));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// effective(V) = role-declared(V) intersect approved(V), as scopes of the service's own shape.
|
|
// Null when the intersection is empty.
|
|
export function intersectScopes(service, approved, role) {
|
|
if (service === "gitea") {
|
|
const a = giteaLevels(approved), r = giteaLevels(role);
|
|
if (!a || !r) return null;
|
|
const out = Object.keys(a).filter((c) => Object.hasOwn(r, c)).sort()
|
|
.map((c) => `${RANK[a[c]] <= RANK[r[c]] ? a[c] : r[c]}:${c}`);
|
|
return out.length ? out : null;
|
|
}
|
|
if (service === "vikunja") {
|
|
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
|
|
if (!a || !r) return null;
|
|
const out = {};
|
|
for (const pair of [...a].filter((p) => r.has(p)).sort()) {
|
|
const [group, verb] = pair.split(" ");
|
|
(out[group] ??= []).push(verb);
|
|
}
|
|
return Object.keys(out).length ? out : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The role's declared scopes per bindable service. A host service is bound per host elsewhere and
|
|
// is not a copy binding; a service outside the core pair cannot be bound in CB1.
|
|
export function roleServices(role) {
|
|
const out = {};
|
|
for (const c of role?.credentials ?? []) {
|
|
if (c.service === HOST_SERVICE) continue;
|
|
out[c.service] = c.scopes;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- Record schema ----
|
|
|
|
// The positional refusal for record `i` (1-based): it never names a submitted key or value.
|
|
const at = (i, field) => `copyBindings entry ${i} ${field} field is invalid`;
|
|
|
|
// Checks the whole `copyBindings` value of an organization document. `trackers` is the list of
|
|
// {bot, botId} the document's instances and its tracker sync already use; a copy's tracker bot is
|
|
// unique against them and against other copies. Throws a BusinessError with a positional message.
|
|
export function validateCopyBindings(value, { trackers = [] } = {}) {
|
|
if (value === undefined) return;
|
|
if (!isPlainObject(value)) refuse("copyBindings field is invalid");
|
|
const keys = Object.keys(value);
|
|
if (keys.length > MAX_BINDINGS) refuse("copyBindings field is invalid");
|
|
const bots = new Set(trackers.map((t) => t.bot)), botIds = new Set(trackers.map((t) => t.botId)), copyIds = new Set(), grants = new Set();
|
|
keys.forEach((instance, n) => {
|
|
const i = n + 1, r = value[instance];
|
|
if (!ID_PATTERN.test(instance)) refuse(`copyBindings entry ${i} name is invalid`);
|
|
if (!isPlainObject(r)) refuse(`copyBindings entry ${i} is invalid`);
|
|
const unknown = Object.keys(r).some((k) => !RECORD_KEYS.includes(k));
|
|
if (unknown) refuse(`copyBindings entry ${i} has an unknown field`);
|
|
if (typeof r.copyId !== "string" || !COPY_ID.test(r.copyId)) refuse(at(i, "copyId"));
|
|
if (copyIds.has(r.copyId)) refuse(at(i, "copyId"));
|
|
copyIds.add(r.copyId);
|
|
if (typeof r.orgIncarnation !== "string" || !INCARNATION.test(r.orgIncarnation)) refuse(at(i, "orgIncarnation"));
|
|
const s = r.source;
|
|
if (!isPlainObject(s) || Object.keys(s).sort().join(",") !== "level,name,project"
|
|
|| !["root", "project"].includes(s.level) || typeof s.name !== "string" || !ID_PATTERN.test(s.name)
|
|
|| (s.level === "root" ? s.project !== null : typeof s.project !== "string" || !ID_PATTERN.test(s.project))
|
|
|| (s.level === "root" ? s.name : `${s.name}.${s.project}`) !== instance) refuse(at(i, "source"));
|
|
const d = r.definition;
|
|
if (!isPlainObject(d) || Object.keys(d).sort().join(",") !== "ref,rev,sha256" || typeof d.ref !== "string"
|
|
|| typeof d.sha256 !== "string" || !SHA256.test(d.sha256)
|
|
|| (d.ref.startsWith(ORG_PREFIX)
|
|
? !ID_PATTERN.test(d.ref.slice(ORG_PREFIX.length)) || !Number.isSafeInteger(d.rev) || d.rev < 1
|
|
: !ID_PATTERN.test(d.ref) || d.rev !== null)) refuse(at(i, "definition"));
|
|
if (!Number.isSafeInteger(r.generation) || r.generation < 1) refuse(at(i, "generation"));
|
|
if (!BINDING_STATES.includes(r.state)) refuse(at(i, "state"));
|
|
if (typeof r.decision !== "string" || !UUID.test(r.decision)) refuse(at(i, "decision"));
|
|
if (!isPlainObject(r.services)) refuse(at(i, "services"));
|
|
const services = Object.keys(r.services);
|
|
// CB4: a revoking record keeps what it is revoking, which may be nothing (an older tombstone
|
|
// whose slot a shadow, an archive or a delete cleans).
|
|
if (r.state !== "revoking" && (r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
|
|
checkRevoke(r, i);
|
|
for (const service of services) {
|
|
const v = r.services[service];
|
|
const extra = isPlainObject(v) ? Object.keys(v).filter((k) => !SERVICE_KEYS.includes(k)) : [];
|
|
if (!BINDABLE_SERVICES.includes(service) || !isPlainObject(v) || SERVICE_KEYS.some((k) => !Object.hasOwn(v, k))
|
|
|| extra.some((k) => !GRANT_KEYS.includes(k)) || v.slot !== "self" || !validScopes(service, v.scopes)) refuse(at(i, "services"));
|
|
// CB2: rotateBy and grantId come together; a grant id needs its generation; one grant id per organization.
|
|
if ((v.rotateBy === undefined) !== (v.grantId === undefined) || (v.grantId !== undefined && v.grantGeneration === undefined)
|
|
|| (v.rotateBy !== undefined && !realDay(v.rotateBy))
|
|
|| (v.grantId !== undefined && (typeof v.grantId !== "string" || !GRANT_ID.test(v.grantId) || v.grantId.split(":")[0] !== service))
|
|
|| (v.grantGeneration !== undefined && (!Number.isSafeInteger(v.grantGeneration) || v.grantGeneration < 1))) refuse(at(i, "services"));
|
|
if (v.grantId !== undefined) {
|
|
if (grants.has(v.grantId)) refuse(`copyBindings entry ${i} services field uses a grant another binding uses`);
|
|
grants.add(v.grantId);
|
|
}
|
|
const p = v.resources;
|
|
if (!isPlainObject(p) || Object.keys(p).join(",") !== "projects" || !Array.isArray(p.projects) || p.projects.length === 0
|
|
|| p.projects.some((x) => typeof x !== "string" || !ID_PATTERN.test(x)) || new Set(p.projects).size !== p.projects.length
|
|
|| (s.level === "project" && (p.projects.length !== 1 || p.projects[0] !== s.project))) refuse(at(i, "services"));
|
|
}
|
|
if (r.tracker !== undefined) {
|
|
const t = r.tracker;
|
|
if (!services.includes("vikunja") || !isPlainObject(t) || Object.keys(t).sort().join(",") !== "bot,botId"
|
|
|| typeof t.bot !== "string" || !BOT_NAME.test(t.bot) || !Number.isSafeInteger(t.botId) || t.botId < 1) refuse(at(i, "tracker"));
|
|
if (bots.has(t.bot) || botIds.has(t.botId)) refuse(`copyBindings entry ${i} tracker field uses a bot another instance uses`);
|
|
bots.add(t.bot);
|
|
botIds.add(t.botId);
|
|
}
|
|
});
|
|
}
|
|
|
|
// CB4: the `revoke` field. Required while revoking, optional on the unbound tombstone (its done
|
|
// form), absent otherwise.
|
|
function checkRevoke(r, i) {
|
|
const v = r.revoke;
|
|
if (v === undefined) {
|
|
if (r.state === "revoking") refuse(at(i, "revoke"));
|
|
return;
|
|
}
|
|
if (!isPlainObject(v) || !["revoking", "unbound"].includes(r.state) || typeof v.id !== "string" || !UUID.test(v.id)
|
|
|| !REVOKE_CAUSES.includes(v.cause) || typeof v.at !== "string" || !ISO.test(v.at) || Number.isNaN(Date.parse(v.at))) refuse(at(i, "revoke"));
|
|
const keys = Object.keys(v);
|
|
if (r.state === "revoking") {
|
|
if (keys.some((k) => !["id", "cause", "at", "failed", "remote"].includes(k))) refuse(at(i, "revoke"));
|
|
if (v.failed !== undefined && (!Array.isArray(v.failed) || v.failed.length === 0 || new Set(v.failed).size !== v.failed.length
|
|
|| v.failed.some((x) => !REVOKE_STEPS.includes(x)))) refuse(at(i, "revoke"));
|
|
// The definite remote outcomes an incomplete pass already had, so a retry never calls a revoked
|
|
// grant again or records its 404 as `absent`.
|
|
if (v.remote !== undefined && (!isPlainObject(v.remote) || Object.keys(v.remote).length === 0
|
|
|| Object.entries(v.remote).some(([s, o]) => !BINDABLE_SERVICES.includes(s) || !REMOTE_OUTCOMES.includes(o)))) refuse(at(i, "revoke"));
|
|
return;
|
|
}
|
|
if (keys.sort().join(",") !== "at,cause,done,id,remote" || v.done !== true || !isPlainObject(v.remote)
|
|
|| Object.entries(v.remote).some(([s, o]) => !BINDABLE_SERVICES.includes(s) || !REMOTE_OUTCOMES.includes(o))) refuse(at(i, "revoke"));
|
|
}
|
|
|
|
// ---- Resolver (CB0) ----
|
|
|
|
// What a copy's binding grants in this organization, from plain inputs only:
|
|
// orgId, incarnation the organization and its live registry incarnation (null: none)
|
|
// snapshot the boot snapshot materializeCopies made for orgId
|
|
// instance the copy's instance name
|
|
// record copyBindings[instance] from the organization document, or undefined
|
|
// pin definitionPin of the copy's current definition (null: cannot pin)
|
|
// role the copy's current resolved role ({credentials: [...]})
|
|
// projects the project ids that admit this copy now: for a root copy, the projects
|
|
// whose union holds it (an excluded or refusing project is left out), not
|
|
// the organization's whole project list
|
|
// Returns { ok: true, copyId, generation, services: { V: { scopes, projects } } } when every
|
|
// service the role needs is bound, or { ok: false, code, service } with the first refusal. Only a
|
|
// `bound` record resolves; pending, partial, tombstoned, foreign and stale records never do.
|
|
export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, record, pin, role, projects = [] }) {
|
|
const no = (code, service = null) => ({ ok: false, code, service: service === null ? null : label(service) });
|
|
if (!snapshot || snapshot.org !== orgId) return no("copy-unavailable");
|
|
const copy = Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
|
// An org instance of the same name shadows the copy, and a removed or excluded source makes none.
|
|
if (!copy) return no("copy-unavailable");
|
|
if (copy.state === "refused") return no(copy.claims ? "copy-refused" : "copy-unavailable");
|
|
const needed = roleServices(role);
|
|
const first = Object.keys(needed).sort()[0] ?? null;
|
|
if (record === undefined || record === null) return no("credential-unbound", first);
|
|
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || record.orgIncarnation !== incarnation) return no("credential-unbound", first);
|
|
const source = { level: copy.level, name: copy.name, project: copy.project };
|
|
if (canonicalJson(record.source) !== canonicalJson(source)) return no("credential-unbound", first);
|
|
// CB4: a revoke in progress or one whose outcome is not definite never resolves; a finished one
|
|
// answers its cause's code (design section 6) until a new binding.
|
|
if (record.state === "revoking") return no(record.revoke?.failed ? "credential-revoke-failed" : "credential-revoking", first);
|
|
if (record.state === "unbound" && record.revoke?.done === true && TOMBSTONE_CODES.includes(CAUSE_CODES[record.revoke.cause]))
|
|
return no(CAUSE_CODES[record.revoke.cause], first);
|
|
if (!pin || !samePin(record.definition, pin) || record.definition.ref !== copy.definition) return no("credential-role-changed", first);
|
|
if (record.state !== "bound") return no("credential-unbound", first);
|
|
const approved = record.services ?? {};
|
|
for (const service of Object.keys(approved).sort()) {
|
|
if (!Object.hasOwn(needed, service)) return no("credential-scope-exceeded", service);
|
|
if (!scopesWithin(service, approved[service].scopes, needed[service])) return no("credential-scope-exceeded", service);
|
|
}
|
|
const admitted = new Set(copy.level === "project" ? (projects.includes(copy.project) ? [copy.project] : []) : projects);
|
|
const services = {};
|
|
for (const service of Object.keys(needed).sort()) {
|
|
if (!BINDABLE_SERVICES.includes(service) || !Object.hasOwn(approved, service)) return no("credential-unbound", service);
|
|
const scopes = intersectScopes(service, approved[service].scopes, needed[service]);
|
|
const reach = approved[service].resources.projects.filter((p) => admitted.has(p)).sort();
|
|
if (!scopes || reach.length === 0) return no("credential-unbound", service);
|
|
services[service] = { scopes, projects: reach };
|
|
}
|
|
if (!Object.keys(services).length) return no("credential-unbound", first);
|
|
return { ok: true, copyId: record.copyId, generation: record.generation, services };
|
|
}
|
|
|
|
// ---- Proposal checks (CB1) ----
|
|
|
|
// The binding payload a chief proposes: { service: { scopes, projects } } for a bind, or null for
|
|
// an unbind. Each service must be one the copy's role declares, its scopes within the role and its
|
|
// projects admitted for the copy (`projects`: for a root copy, the projects whose union holds it).
|
|
// Never trims a broader request. Returns the normalized services.
|
|
export function checkBindingRequest(services, { copy, role, projects }) {
|
|
if (services === null) return null;
|
|
if (!isPlainObject(services) || Object.keys(services).length === 0) refuse("binding services field is invalid");
|
|
const needed = roleServices(role);
|
|
const out = {};
|
|
for (const service of Object.keys(services).sort()) {
|
|
if (!BINDABLE_SERVICES.includes(service)) refuse("binding services field is invalid");
|
|
const v = services[service];
|
|
if (!isPlainObject(v) || Object.keys(v).sort().join(",") !== "projects,scopes") refuse("binding services field is invalid");
|
|
if (!validScopes(service, v.scopes)) refuse("binding scopes field is invalid");
|
|
if (!Object.hasOwn(needed, service) || !scopesWithin(service, v.scopes, needed[service])) bindingFail("credential-scope-exceeded", service);
|
|
if (!Array.isArray(v.projects) || v.projects.length === 0 || v.projects.some((p) => typeof p !== "string" || !ID_PATTERN.test(p))
|
|
|| new Set(v.projects).size !== v.projects.length) refuse("binding projects field is invalid");
|
|
const allowed = copy.level === "project" ? [copy.project] : projects;
|
|
if (v.projects.some((p) => !allowed.includes(p))) bindingFail("credential-scope-exceeded", service);
|
|
out[service] = { slot: "self", scopes: structuredClone(v.scopes), resources: { projects: [...v.projects].sort() } };
|
|
}
|
|
return out;
|
|
}
|