// 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::`: 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; }