From ce0a8ad7d190bd96d29e834cd22b488fc1371dd9 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:07:29 -0500 Subject: [PATCH] feat(mosaic): add governed credential validation and grants --- docs/PRD.md | 53 ++ docs/credentials/GRANT-VALIDATE-CONTRACT.md | 196 ++++++ docs/scratchpads/1045-mosaic-cred.md | 79 +++ packages/mosaic/src/cli.ts | 5 + packages/mosaic/src/commands/cred.ts | 348 +++++++++++ .../src/credentials/audit-journal.dto.ts | 54 ++ .../src/credentials/audit-journal.spec.ts | 166 +++++ .../mosaic/src/credentials/audit-journal.ts | 256 ++++++++ .../credentials/credential-provider.dto.ts | 50 ++ .../src/credentials/credential-result.dto.ts | 77 +++ .../credential-validate-service.ts | 83 +++ .../credentials/delegated-credential.spec.ts | 74 +++ .../src/credentials/delegated-credential.ts | 94 +++ .../src/credentials/estate-registry.dto.ts | 14 + .../src/credentials/estate-registry.spec.ts | 94 +++ .../mosaic/src/credentials/estate-registry.ts | 142 +++++ .../credentials/file-credential-store.spec.ts | 101 +++ .../src/credentials/file-credential-store.ts | 92 +++ .../src/credentials/gitea-provider.spec.ts | 207 +++++++ .../mosaic/src/credentials/gitea-provider.ts | 573 ++++++++++++++++++ packages/mosaic/src/credentials/grant.dto.ts | 45 ++ packages/mosaic/src/credentials/grant.spec.ts | 182 ++++++ packages/mosaic/src/credentials/grant.ts | 163 +++++ .../mosaic/src/credentials/team-grant.spec.ts | 139 +++++ packages/mosaic/src/credentials/team-grant.ts | 194 ++++++ .../mosaic/src/credentials/validate.spec.ts | 345 +++++++++++ packages/mosaic/src/credentials/validate.ts | 477 +++++++++++++++ 27 files changed, 4303 insertions(+) create mode 100644 docs/credentials/GRANT-VALIDATE-CONTRACT.md create mode 100644 docs/scratchpads/1045-mosaic-cred.md create mode 100644 packages/mosaic/src/commands/cred.ts create mode 100644 packages/mosaic/src/credentials/audit-journal.dto.ts create mode 100644 packages/mosaic/src/credentials/audit-journal.spec.ts create mode 100644 packages/mosaic/src/credentials/audit-journal.ts create mode 100644 packages/mosaic/src/credentials/credential-provider.dto.ts create mode 100644 packages/mosaic/src/credentials/credential-result.dto.ts create mode 100644 packages/mosaic/src/credentials/credential-validate-service.ts create mode 100644 packages/mosaic/src/credentials/delegated-credential.spec.ts create mode 100644 packages/mosaic/src/credentials/delegated-credential.ts create mode 100644 packages/mosaic/src/credentials/estate-registry.dto.ts create mode 100644 packages/mosaic/src/credentials/estate-registry.spec.ts create mode 100644 packages/mosaic/src/credentials/estate-registry.ts create mode 100644 packages/mosaic/src/credentials/file-credential-store.spec.ts create mode 100644 packages/mosaic/src/credentials/file-credential-store.ts create mode 100644 packages/mosaic/src/credentials/gitea-provider.spec.ts create mode 100644 packages/mosaic/src/credentials/gitea-provider.ts create mode 100644 packages/mosaic/src/credentials/grant.dto.ts create mode 100644 packages/mosaic/src/credentials/grant.spec.ts create mode 100644 packages/mosaic/src/credentials/grant.ts create mode 100644 packages/mosaic/src/credentials/team-grant.spec.ts create mode 100644 packages/mosaic/src/credentials/team-grant.ts create mode 100644 packages/mosaic/src/credentials/validate.spec.ts create mode 100644 packages/mosaic/src/credentials/validate.ts diff --git a/docs/PRD.md b/docs/PRD.md index 77ccd609..ad4a8406 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -437,6 +437,59 @@ Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete --- +## Governed fleet credential lifecycle (`mosaic cred`, #1045) + +### Problem and objective + +Fleet credentials are issued, wired, resolved, granted, validated, rotated, and revoked through unrelated scripts and manual provider actions. The split has produced silent fallback to a human/shared principal, missing runtime identity, cross-estate login resolution, incomplete permission checks, and non-auditable grants. The objective is one mechanical, durable, systemic `mosaic cred` path that decides both what a fleet seat may do and which provider identity it acts as. + +### Scope + +Phase 1 governs the existing per-identity Gitea token store and Tea login registration. VaultWarden is explicitly out for the agent tier and is not a backend option in this workstream. Certificate-backed identity and short-lived broker-issued credentials remain later phases behind the same caller contract. + +### Normative requirements + +1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md). +2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority. +3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither, and SHALL read the provider `/user` object back through each path. A wrong-host or absent Tea login SHALL never fall back to a host default. +4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them. +5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow. +6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output. +7. `CRED-REQ-07`: Permission grants SHALL be accepted only after provider read-back of the named direct collaborator permission or, for team grants, organization membership, team membership, team-repository attachment, and subject effective permission. +8. `CRED-REQ-08`: `validate --repo` SHALL compute a side-effect-free write differential by result. One immutable credential resolution SHALL bind the declared subject's provider identity read-back, repository permission, and authenticated Git receive-pack advertisement. A distinct provider-confirmed read-only principal and an unauthenticated caller SHALL both be refused receive-pack in the same evaluation. Principal/handle disagreement SHALL be indeterminate, never refusal or success. The check SHALL create no ref or artifact and SHALL state that it does not prove a particular update will pass branch protection, hooks, races, or content policy. +9. `CRED-REQ-09`: All provider HTTP calls SHALL share one transport implementation for URL/host binding, TLS, User-Agent, content-type, JSON-shape validation, redaction, and bounded responses. A 2xx status alone SHALL never establish identity, scope, permission, grant, or revocation. +10. `CRED-REQ-10`: Operations SHALL return stable machine outcomes `ok`, `refused`, `error`, or `indeterminate`. Policy refusal, local operational failure, and incomplete/inconsistent evidence SHALL remain distinguishable. `provider-unavailable`, `identity-not-measured`, `identity-not-visible`, `identity-not-found`, and `credential-rejected` SHALL remain distinct diagnoses. Validation SHALL report capability from an in-scope probe separately from identity measurement. `/user` 401 is `credential-rejected`/refused; `/user` 403/404 plus successful in-scope capability is `identity-not-measured`, never a dead credential. A returned login mismatch is a binding refusal. No implemented operation may emit `identity-not-found`; that diagnosis requires a separately approved visibility-authorized inventory capability. Security callers SHALL fail closed on every outcome except `ok` without relabelling indeterminate evidence as a denial. +11. `CRED-REQ-11`: No command SHALL print a token, password, authorization header, fingerprint, partial secret, or secret-bearing provider body, including error paths. Secrets SHALL not appear in process argv. Phase-1 file storage SHALL remain private, symlink-safe, regular-file-only, test-overridable, and compatible with existing managed token consumers. +12. `CRED-REQ-12`: Every issue, provision, grant, rotate, revoke, and credential access SHALL be journaled with actor, subject, estate, host, repo/scope, operation, time, and non-secret provider evidence. The durable journal SHALL be opened and fsynced before the first mutation, append each mutation/read-back, and seal only after acceptance. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work. +13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the authoritative fleet environment source/projection so both identity axes survive restart. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names. +14. `CRED-REQ-14`: Rotate SHALL verify the new credential/provider identity before retiring the old credential. Revoke SHALL read back provider revocation/denial and preserve an auditable recovery record. A local file deletion or successful HTTP status is not revocation evidence. +15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback. +16. `CRED-REQ-16`: Provider claims SHALL record the estate, instance, endpoint, asserted content type, and decision-relevant object fields. Append-only provider status history SHALL be reduced to latest-per-context where current state is required. + +### Acceptance criteria + +1. `AC-CRED-01`: Red-first tests prove unset identity, missing token, wrong estate, wrong host, wrong Tea login, and out-of-estate identity produce the same structured refusal class/reason on git and API resolution, with no shared credential read and no provider mutation. +2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes register atomically, exact token scopes are read back from the provider token object, and rollback removes partial local registration. +3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope, repo grant, org membership, and team membership cases cannot return `ok`. +4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, provider outage, or unavailable exact scope returns `indeterminate`, never success or policy refusal. +5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back. +6. `AC-CRED-06`: Adversarial output/argv tests seed distinct secret values through success, refusal, provider-error, parser-error, rollback, rotate, and revoke paths and find zero secret/partial/fingerprint occurrences in stdout, stderr, logs, audit, and child argv. +7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, conflicting concurrent mutation, and production-store leakage into fixture tests. Existing canonical per-seat token consumers continue through the governed adapter. +8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the authoritative generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. +9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes. +10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing. +11. `AC-CRED-11`: Baseline typecheck/lint/format/tests, focused auth/permission abuse cases, independent code review, independent security review, and terminal-green HOMELAB Woodpecker CI pass on the exact reviewed head. +12. `AC-CRED-12`: Interim delivery to `next` is reported only as **believed-fixed, pending validation AND pending promotion to `main`**. Issues stay open until #1037 promotes the work and constitutional completion is independently verified. + +### Constraints and dependencies + +- C1 install-state-machine work merges first. This lane then re-takes base/head-bound measurements without redesigning or reworking code. +- MB-BRAIN-01 (#1051) consumes the grant/validate contract and may proceed against the published interface before implementation merge. +- The branch-model compatibility question for `next` remains escalated. No `done` claim, issue closure, or self-initiated promotion is permitted at the `next` checkpoint. +- `ASSUMPTION:` Phase-1 Gitea support is the only provider implementation in this slice; provider-neutral types preserve later adapters without pretending unimplemented providers are supported. + +--- + ## Architecture ### High-Level System Diagram diff --git a/docs/credentials/GRANT-VALIDATE-CONTRACT.md b/docs/credentials/GRANT-VALIDATE-CONTRACT.md new file mode 100644 index 00000000..ee45eb72 --- /dev/null +++ b/docs/credentials/GRANT-VALIDATE-CONTRACT.md @@ -0,0 +1,196 @@ +# `mosaic cred grant` / `validate` caller contract v1.5 + +**Status:** early binding contract for MC-CRED-01 and MB-BRAIN-01. v1.3's anonymous absence classifier was withdrawn as unsound for private users. v1.4 adopted subject-credential validation without admin visibility. v1.5 separates in-scope capability from identity measurement so correctly least-privileged tokens are not widened to service the instrument. This contract may evolve before implementation merge; incompatible changes require an explicit change notice. + +## Security model + +- Every call carries both `--estate` and `--host`. The configured estate-to-host mapping must match exactly. Host inference, host-adjacent fallback, and cross-estate resolution are forbidden. +- `` is always explicit. The CLI never substitutes a pane, roster, login, Unix user, or other plausible ambient identity. +- The identity token and the host-bound Tea login are one provisioning unit. Minting authority reads the principal back when the invariant is created and records that binding with the token registration. Runtime validation re-measures identity only when the token already holds `read:user`; it never widens scopes to make the instrument green. +- Grant authority is broker/delegated-provisioner material. It is never supplied as a CLI value, environment value, or bearer token readable by the requesting agent. The broker obtains it from its protected runtime credential channel. +- Commands never print token, password, authorization header, fingerprint, partial secret, or secret-bearing error text. Structured evidence contains provider object fields and endpoint metadata only. +- Every operation opens and fsyncs a durable journal before the first mutation. Journal/audit write failure is fatal. A grant is successful only after provider read-back and a sealed journal. + +## Commands + +```text +mosaic cred grant \ + --estate \ + --host \ + --repo \ + --permission \ + [--via ] \ + [--team ] \ + [--read-only-control ] \ + [--json] + +mosaic cred validate \ + --estate \ + --host \ + [--repo ] \ + [--require ] \ + [--read-only-control ] \ + [--json] +``` + +Rules: + +- `--via collaborator` is the default. It grants a direct repository permission and still reports the organization-membership layer. +- `--via team` requires `--team`; `--team` with collaborator mode is invalid. +- `validate --repo` reports two independent axes: capability from an in-scope repository probe, and identity binding from `/user` only when authorized. Capability may be `confirmed` while identity is `not-measured`; NOT-MEASURED is neither pass nor failure. +- Write validation requires a distinct known-read-only control identity, supplied explicitly or configured in the declared estate. The control identity and its read-only permission are read back from the provider on every invocation; the configured name alone is not evidence. +- `grant` invokes the same validation after mutation. HTTP 2xx and process exit status are never acceptance evidence. + +## Machine result + +`--json` writes exactly one non-secret JSON object to stdout. Human diagnostics go to stderr. Callers must decide from `outcome`, never by parsing prose. + +```json +{ + "schemaVersion": 1, + "operation": "grant", + "outcome": "ok", + "exitCode": 0, + "retryable": false, + "subject": { + "identity": "seat-name", + "estate": "estate-name", + "host": "git.example.invalid", + "repo": "owner/repo" + }, + "mutation": "applied", + "reason": { + "code": "grant-verified", + "message": "Grant matched all provider read-backs." + }, + "evidence": { + "providerIdentity": { + "login": "seat-name", + "endpoint": "GET /api/v1/user", + "contentType": "application/json" + }, + "tokenCapabilities": [], + "repositoryPermission": { + "requested": "write", + "effective": "write", + "endpoint": "GET /api/v1/repos/owner/repo", + "contentType": "application/json" + }, + "organizationMembership": { + "state": "present" + }, + "teamMembership": { + "state": "not-applicable" + }, + "writeDifferential": { + "state": "can-write", + "credentialBinding": "same-resolution", + "transportPrincipal": "seat-name", + "authenticatedReceivePack": "advertised", + "readOnlyControl": { + "identity": "read-only-control", + "providerPermission": "read", + "receivePack": "refused" + }, + "unauthenticatedReceivePack": "refused", + "artifactCreated": false, + "proves": "One immutable credential resolution authenticated both the subject identity read-back and write transport; a provider-confirmed read-only principal and an unauthenticated caller were both refused.", + "doesNotProve": "A particular ref update will pass branch protection, hooks, races, or content policy." + } + }, + "audit": { + "journalId": "opaque-id", + "state": "sealed" + } +} +``` + +Fields may be `null` only when their enclosing evidence state explains why. Missing decision-relevant fields make the result `indeterminate`, never `ok`. + +## Terminal classes + +| Outcome | Exit | Meaning | Mutation guarantee | Caller action | +| --------------- | ---: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `ok` | `0` | Requested property was established from provider objects and all required layers agree. | `validate`: `none`; `grant`: `applied` and read back. | Continue. | +| `refused` | `10` | A complete, authoritative policy/access decision denied the request. Examples: estate-host mismatch, missing explicit identity, provider identity mismatch, explicit permission denial, or cross-estate subject. | `none`; refusal occurs before mutation. | Treat as a stable denial. Do not retry without changing authority/configuration. | +| `error` | `20` | The command contract or local control failed before an access verdict. Examples: invalid arguments, malformed estate registry, insecure credential path, journal cannot be opened/fsynced, or internal invariant failure. | `none` unless `mutation` explicitly says `unknown`; `unknown` is never success. | Repair the tool/configuration. Do not reinterpret as access denial. | +| `indeterminate` | `30` | The requested security property could not be evaluated completely or evidence disagreed. Examples: provider unavailable, wrong content type/shape, stale or absent scope read-back, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. | + +Parsing/usage errors emitted by Commander remain exit `2` and do not produce a broker verdict. Callers should treat them as integration defects, not access decisions. + +## Refusal object + +A refusal is intentionally recognizable without prose: + +```json +{ + "schemaVersion": 1, + "operation": "validate", + "outcome": "refused", + "exitCode": 10, + "retryable": false, + "subject": { + "identity": "external-seat", + "estate": "homelab", + "host": "git.example.invalid", + "repo": "owner/repo" + }, + "mutation": "none", + "reason": { + "code": "no-token-for-identity", + "message": "The explicit identity has no credential in the declared estate." + }, + "evidence": { + "providerIdentity": null, + "tokenCapabilities": [], + "repositoryPermission": null, + "organizationMembership": null, + "teamMembership": null, + "writeDifferential": null + }, + "audit": { + "journalId": "opaque-id", + "state": "sealed" + } +} +``` + +The git credential helper and API resolver must map the same subject/estate/host failure to the same `reason.code` and terminal class. MB-BRAIN-01 may assert this parity. A caller does not need to know which resolver path was used. + +## Required reason codes + +Stable v1 codes: + +- refusal: `identity-required`, `estate-required`, `estate-host-mismatch`, `cross-estate-resolution`, `no-token-for-identity`, `tea-login-missing`, `tea-login-host-mismatch`, `provider-identity-mismatch`, `credential-rejected`, `permission-denied`, `organization-membership-required`, `team-membership-required` +- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant` +- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown` + +`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds. + +No anonymous or visibility-unprivileged 404 is admissible evidence of absence. `identity-not-found` requires, in the same invocation: (1) the visibility credential's own `/user` object read back as the configured authority with provider-admin visibility; (2) target lookup performed with that same authority; (3) a known-present PRIVATE control returning JSON 200 with matching login and `visibility=private`; and (4) a generated absent negative control returning JSON 404 under that same authority. Missing authority or any non-discriminating control yields `identity-not-visible`, never absence. A public positive control cannot certify private subjects. + +No currently implemented operation may emit `identity-not-found`: the required governed inventory capability was deliberately declined and runtime validation must not acquire standing admin visibility. For `validate`, `/user` 401 means `credential-rejected`; `/user` 403/404 triggers the in-scope capability probe and, when that succeeds, identity is `identity-not-measured`; JSON 200 with a mismatched login is a binding refusal. A future inventory operation must meet every precondition above and receive an explicit privilege decision before making `identity-not-found` reachable. + +Unknown future reason codes must still carry one of the four stable `outcome` values. + +## Side-effect-free write differential + +For Gitea v1, `validate --repo` resolves the subject credential exactly once into an immutable in-memory credential handle. The provider `/user` read-back, authenticated repository object, and Git smart-HTTP `git-receive-pack` advertisement all consume that same handle; callers may not perform independent lookups for those steps. The command also probes a separately resolved, provider-confirmed read-only control principal and repeats the request unauthenticated. + +`can-write` requires all of the following: + +1. provider `/user` login obtained with the subject credential handle equals ``; +2. authenticated repository object obtained with that same handle reports write-capable permission; +3. receive-pack obtained with that same handle returns the exact advertisement content type and protocol preamble; +4. the transport evidence records the same declared principal as the identity read-back; any handle/principal seam disagreement is `transport-principal-mismatch` and therefore `indeterminate`, never refused; +5. a distinct known-read-only credential resolves to its declared control identity, its provider repository object reports no write permission, and receive-pack is refused; +6. the unauthenticated control is refused and does not return a receive-pack advertisement; +7. estate, host, and repository in every request equal the declared subject. + +The read-only control varies the mechanism under accusation: principal selection. The unauthenticated arm remains as a separate control proving authentication is required; it cannot establish which principal authenticated the subject probe. A missing, write-capable, identity-mismatched, or otherwise invalid read-only control makes the result `indeterminate`. + +No ref is updated and no repository artifact is created. This proves that the declared subject credential—not merely some authenticated credential—can enter the write transport for that repository, while a provider-confirmed read-only principal and an unauthenticated caller cannot. It does not prove any specific branch update would survive branch protection, hooks, concurrent changes, or content policy. + +## Grant read-back + +A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant additionally requires provider-read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another. diff --git a/docs/scratchpads/1045-mosaic-cred.md b/docs/scratchpads/1045-mosaic-cred.md new file mode 100644 index 00000000..90104a4e --- /dev/null +++ b/docs/scratchpads/1045-mosaic-cred.md @@ -0,0 +1,79 @@ +# MC-CRED-01 / stack #1045 scratchpad + +Last updated: 2026-08-05 + +## Objective + +Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. Interim merge target is `next`; terminal status remains **believed-fixed, pending validation AND pending promotion to `main`**. + +## Requirements sources + +- Charter: `/home/hermes/agent-work/tl-mosaic/CHARTER-MC-CRED-01-be-coder-06.md` +- Stack issues: #1045, #1043, #1044, #1047, #1049, #1013, #1007; promotion #1037; consumer #1051 +- Remote spec: `jason.woltje/jarvis-brain` origin/main `b7687d51f4efe52e43dbcd6dc95b5554b3332957` +- Greenfield PRD v3 addenda: INV-B durable journal, INV-C visible failure diagnostics, INV-D supported fixture +- Binding doctrine: `/src/jarvis-brain/infra/fleet/FLEET-DOCTRINE.md` + +## Plan + +1. Publish grant/validate v1 caller contract for MB-BRAIN-01. +2. Add repo PRD requirements and preregister acceptance tests. +3. Implement explicit estate registry, secure current file-store adapter, durable operation journal/audit, provider transport, and terminal result types. +4. Implement `grant` and side-effect-free `validate`; then provision/wire/get/whoami/list/rotate/revoke/audit. +5. Make git and API resolver refusals identical and fail closed under fleet context. +6. Reconcile live HOMELAB seats through each subject credential's own `/user`; #1044 hold is lifted, and its fail-closed change carries the pre-registered mechanism evidence (resolver refusal marker, same-run marker positive control, confirmed-lane negative arm). +7. Run baseline/situational tests, independent code review and mandatory independent security review, CI on exact head, then open PR against `next` without closing issues or claiming completion. +8. After C1 merges first, rebase/refresh the base and re-take head-bound CI/provider measurements only. + +## Budget + +No explicit token cap supplied. Working cap: keep implementation in one package plus shipped framework resolver changes and required docs/tests; avoid unrelated wrapper defect fixes and VaultWarden redesign. Escalate only if a charter requirement is technically unsatisfiable. + +## Decisions + +- VaultWarden is out for the agent tier per the charter verdict; phase 1 governs the existing per-identity file store. +- Estate is explicit input and must match a configured host mapping; target host is never inferred from machine location. +- Grant authority and basic-auth provisioning material are delegated control-plane credentials, never caller bearer material and never CLI argument/output. +- `ok`, `refused`, `error`, and `indeterminate` are distinct machine outcomes. Security callers fail closed on all but `ok`, while retaining the semantic distinction. +- Gitea write-differential resolves the subject once and binds provider identity, repository permission, and receive-pack to the same in-memory credential handle. It adds a distinct provider-confirmed read-only-principal control plus the unauthenticated control, with no ref update. The live HOMELAB negative-control subject is `tl-mosaic`, verified read-only on `mosaicstack/stack`; code and contract remain principal-agnostic. + +## Progress + +- [x] Mode/intake/core guides/skills/doctrine loaded. +- [x] Spec repository READ confirmed under be-coder-06 from provider object. +- [x] Target-branch completion conflict raised; lead ruled work may proceed to PR/CI on `next` but not completion/closure. +- [x] Canonical remote PRD v3 addenda re-read at new head. +- [x] Required issues read via Mosaic wrapper. +- [x] Early grant/validate contract v1 published at `docs/credentials/GRANT-VALIDATE-CONTRACT.md`. +- [x] Contract v1.1 binds transport to the same resolved principal and adds a provider-confirmed read-only-principal control. +- [x] Contract v1.2 distinguishes provider outage, absent identity, and rejected credential. +- [x] Contract v1.3 positive-controlled anonymous visibility; subsequently withdrawn as unsound for private identities. +- [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path. +- [x] PRD update. +- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests. +- [ ] Implementation (validate core/CLI, direct/team grant core, and protected delegated-authority fd reader in progress; provider team adapter, CLI grant integration, remaining lifecycle commands, and separately sequenced fail-closed resolver evidence open). +- [ ] Independent code/security reviews. +- [ ] CI and provider evidence. + +## Tests and evidence + +Baseline after workspace build: package typecheck passed; Vitest 81/81 files and 1,514/1,514 tests passed. The package shell suite reached a pre-existing tracked #973 Bash 5.2 BASH_LINENO incompatibility and exited 97 before wake tests; this is baseline, not introduced by MC-CRED. + +Red-first evidence: +- principal-bound validate module absent → focused suite red; +- incremental v1.1 run: write-capable, identity-mismatched, and receive-pack-admitted read-only controls each returned `ok`, causing 3/13 tests to fail for the exact control defect; after the control checks, 13/13 passed; +- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed; +- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites. + +Current focused evidence after v1.4: provider + validate 23/23, durable correction journal 5/5, delegated credential fd 2/2, direct grant 2/2, team grant 1/1; package typecheck green. + +Live validation v1.4 (subject credential's own `/user`, no admin): population 13; CONFIRMED 8; CREDENTIAL-REJECTED 4 (`coder-mos1`, `coder-mos2`, `f10-coder`, `merge-gate`); MISMATCH 1 (`mos-admin` token authenticates as `Mos`); NOT-MEASURED 0. The four false v1.2 `identity-not-found` sealed journals remain immutable and are explicitly superseded by four sealed correction journals. Evidence: `/home/hermes/agent-work/be-coder-06/live-validation-v1.4/`. + +Write differential for be-coder-06 passed with the configured read-only control and unauthenticated arm. Unit evidence proves the control arm invalidates validation when write-capable, identity-mismatched, or receive-pack-admitted. + +## Risks/blockers + +- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts. +- Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope. +- #1044 hold is LIFTED: the four credentials are already rejected and fail-open preserves silent misattribution. Re-mint is separate fleet task #19. The fail-closed change still requires normal review/green plus mechanism evidence proving resolver refusal, a same-run marker-emission positive control, and unaffected confirmed lanes; tl-mosaic independently verifies. +- Branch model compatibility remains escalated above this lane. Do not claim completion at `next`. diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 958cb3d0..7483937a 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -14,6 +14,7 @@ import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerInteractionCommand } from './commands/interaction.js'; import { registerConfigCommand } from './commands/config.js'; +import { registerCredentialCommand } from './commands/cred.js'; import { registerFleetCommand } from './commands/fleet.js'; import { registerMissionCommand } from './commands/mission.js'; import { registerUninstallCommand } from './commands/uninstall.js'; @@ -371,6 +372,10 @@ registerInteractionCommand(program); registerFleetCommand(program); +// ─── credential governance ───────────────────────────────────────────── + +registerCredentialCommand(program); + // ─── config ──────────────────────────────────────────────────────────── registerConfigCommand(program); diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts new file mode 100644 index 00000000..5388e717 --- /dev/null +++ b/packages/mosaic/src/commands/cred.ts @@ -0,0 +1,348 @@ +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { Command } from 'commander'; +import { CredentialJournalError } from '../credentials/audit-journal.js'; +import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { + runCredentialReadValidation, + runCredentialValidation, +} from '../credentials/credential-validate-service.js'; +import type { GiteaWriteValidationRequestDto } from '../credentials/credential-provider.dto.js'; +import type { + CredentialValidationResultDto, + RepositoryPermission, +} from '../credentials/credential-result.dto.js'; +import { readDelegatedCredentialFromFd } from '../credentials/delegated-credential.js'; +import { grantDirectRepositoryPermission } from '../credentials/grant.js'; +import type { CredentialGrantResultDto } from '../credentials/grant.dto.js'; +import { grantTeamRepositoryPermission } from '../credentials/team-grant.js'; +import type { TeamGrantResult } from '../credentials/team-grant.js'; +import { + CredentialEstateRegistryError, + parseCredentialEstateRegistry, +} from '../credentials/estate-registry.js'; +import { + CredentialStoreError, + FileCredentialResolver, +} from '../credentials/file-credential-store.js'; +import { + GiteaCredentialProviderAdapter, + GiteaTeamGrantProviderAdapter, +} from '../credentials/gitea-provider.js'; + +interface CredentialValidateCommandOptions { + readonly estate: string; + readonly host: string; + readonly repo: string; + readonly require: string; + readonly readOnlyControl?: string; + readonly registry?: string; + readonly tokenDir?: string; + readonly stateDir?: string; + readonly mosaicHome?: string; + readonly actor?: string; + readonly json?: boolean; +} + +interface CredentialGrantCommandOptions { + readonly estate: string; + readonly host: string; + readonly repo: string; + readonly permission: string; + readonly via: string; + readonly team?: string; + readonly readOnlyControl?: string; + readonly registry?: string; + readonly tokenDir?: string; + readonly stateDir?: string; + readonly mosaicHome?: string; + readonly actor: string; + readonly authorityFd: string; + readonly json?: boolean; +} + +function defaultMosaicHome(options: { readonly mosaicHome?: string }): string { + return options.mosaicHome ?? join(homedir(), '.config', 'mosaic'); +} + +function readRegistrySource(path: string): string { + const snapshot = readRegularFileSecure(path, { + root: dirname(path), + maxBytes: 256 * 1024, + }); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content); + } catch { + throw new CredentialEstateRegistryError('invalid-json', 'estate registry was not valid UTF-8'); + } +} + +function errorResult( + request: GiteaWriteValidationRequestDto, + code: string, +): CredentialValidationResultDto { + return { + schemaVersion: 1, + operation: 'validate', + outcome: 'error', + exitCode: 20, + retryable: false, + subject: { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }, + mutation: 'none', + reason: { + code, + message: 'The local credential control failed before an access verdict was available.', + }, + evidence: { + providerIdentity: null, + repositoryPermission: null, + writeDifferential: null, + }, + audit: { journalId: null, state: 'not-started' }, + }; +} + +export async function executeCredentialValidate( + identity: string, + options: CredentialValidateCommandOptions, +): Promise { + const mosaicHome = defaultMosaicHome(options); + const registryPath = options.registry ?? join(mosaicHome, 'cred', 'estates.json'); + const tokenDirectory = + options.tokenDir ?? + process.env['MOSAIC_GITEA_TOKEN_DIR'] ?? + join(mosaicHome, 'secrets', 'gitea-tokens'); + const stateRoot = options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred'); + + let request: GiteaWriteValidationRequestDto = { + identity, + estate: options.estate, + host: options.host, + repo: options.repo, + readOnlyControlIdentity: options.readOnlyControl ?? '(unresolved)', + }; + + try { + if (options.require !== 'read' && options.require !== 'write') { + return errorResult(request, 'invalid-input'); + } + const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath)); + const hostConfig = registry.resolve(options.estate, options.host); + if (hostConfig === undefined) { + return { + ...errorResult(request, 'estate-host-mismatch'), + outcome: 'refused', + exitCode: 10, + reason: { + code: 'estate-host-mismatch', + message: 'The declared estate does not contain the declared host.', + }, + }; + } + request = { + ...request, + readOnlyControlIdentity: options.readOnlyControl ?? registry.readOnlyControl(options.estate), + }; + const resolver = new FileCredentialResolver(tokenDirectory, registry); + const provider = new GiteaCredentialProviderAdapter(hostConfig.apiBaseUrl, fetch); + const dependencies = { resolver, provider, estateRegistry: registry }; + const serviceOptions = { stateRoot, actor: options.actor ?? identity }; + if (options.require === 'read') { + return await runCredentialReadValidation(request, dependencies, serviceOptions); + } + return await runCredentialValidation(request, dependencies, serviceOptions); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) { + return errorResult(request, error.code); + } + if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) { + return errorResult(request, error.code); + } + return errorResult(request, 'internal-invariant'); + } +} + +function grantErrorResult( + identity: string, + options: CredentialGrantCommandOptions, + code: string, +): CredentialGrantResultDto { + return { + schemaVersion: 1, + operation: 'grant', + outcome: 'error', + exitCode: 20, + retryable: false, + subject: { identity, estate: options.estate, host: options.host, repo: options.repo }, + mutation: 'none', + reason: { + code, + message: 'The local grant control failed before an access verdict was available.', + }, + evidence: { + providerIdentity: null, + repositoryPermission: null, + writeDifferential: null, + collaboratorPermission: null, + organizationMembership: null, + }, + audit: { journalId: null, state: 'not-started' }, + }; +} + +export async function executeCredentialGrant( + identity: string, + options: CredentialGrantCommandOptions, +): Promise { + const mosaicHome = defaultMosaicHome(options); + const registryPath = options.registry ?? join(mosaicHome, 'cred', 'estates.json'); + const tokenDirectory = + options.tokenDir ?? + process.env['MOSAIC_GITEA_TOKEN_DIR'] ?? + join(mosaicHome, 'secrets', 'gitea-tokens'); + const stateRoot = options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred'); + if ( + !['read', 'write', 'admin'].includes(options.permission) || + !['collaborator', 'team'].includes(options.via) || + (options.via === 'team') !== (options.team !== undefined) + ) { + return grantErrorResult(identity, options, 'invalid-input'); + } + try { + const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath)); + const hostConfig = registry.resolve(options.estate, options.host); + if (hostConfig === undefined) { + const refused = grantErrorResult(identity, options, 'estate-host-mismatch'); + return { + ...refused, + outcome: 'refused', + exitCode: 10, + reason: { code: 'estate-host-mismatch', message: 'Estate and host do not match.' }, + }; + } + const fd = Number(options.authorityFd); + const authority = await readDelegatedCredentialFromFd( + fd, + options.actor, + options.estate, + options.host, + ); + const request = { + identity, + estate: options.estate, + host: options.host, + repo: options.repo, + permission: options.permission as RepositoryPermission, + readOnlyControlIdentity: options.readOnlyControl ?? registry.readOnlyControl(options.estate), + }; + const resolver = new FileCredentialResolver(tokenDirectory, registry); + const provider = new GiteaTeamGrantProviderAdapter(hostConfig.apiBaseUrl, fetch); + const dependencies = { resolver, provider, estateRegistry: registry }; + const serviceOptions = { stateRoot, actor: options.actor }; + if (options.via === 'team' && options.team !== undefined) { + return await grantTeamRepositoryPermission( + { ...request, team: options.team }, + authority, + provider, + dependencies, + serviceOptions, + ); + } + return await grantDirectRepositoryPermission( + request, + authority, + provider, + dependencies, + serviceOptions, + ); + } catch (error: unknown) { + if ( + error instanceof CredentialJournalError || + error instanceof CredentialEstateRegistryError || + error instanceof CredentialStoreError + ) { + return grantErrorResult(identity, options, error.code); + } + return grantErrorResult(identity, options, 'internal-invariant'); + } +} + +type PrintableCredentialResult = Pick< + CredentialValidationResultDto | CredentialGrantResultDto | TeamGrantResult, + 'operation' | 'outcome' | 'exitCode' | 'reason' +>; + +function printCredentialResult(result: PrintableCredentialResult, json: boolean): void { + if (json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + process.stdout.write( + `mosaic cred ${result.operation}: ${result.outcome} (${result.reason.code})\n`, + ); +} + +export function registerCredentialCommand(parent: Command): void { + const cred = parent + .command('cred') + .description('Govern fleet credential identity, scope, validation, rotation, and revocation') + .option('--mosaic-home ', 'Mosaic configuration root') + .configureHelp({ sortSubcommands: true }) + .action((): void => { + cred.outputHelp(); + }); + + cred + .command('grant ') + .description('Grant repository permission and accept only provider object read-back') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--repo ', 'Target repository') + .requiredOption('--permission ', 'Requested read, write, or admin permission') + .requiredOption('--actor ', 'Explicit delegated authority identity') + .requiredOption('--authority-fd ', 'Inherited protected credential fd number') + .option('--via ', 'Direct collaborator or team grant', 'collaborator') + .option('--team ', 'Exact team name for team grant') + .option('--read-only-control ', 'Known read-only negative-control identity') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed phase-1 token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialGrantCommandOptions): Promise => { + const inherited = cred.opts<{ mosaicHome?: string }>(); + const result = await executeCredentialGrant(identity, { + ...options, + ...(inherited.mosaicHome === undefined ? {} : { mosaicHome: inherited.mosaicHome }), + }); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('validate ') + .description('Read back identity, permission layers, and side-effect-free write differential') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--repo ', 'Target repository') + .option('--require ', 'Required effective permission', 'write') + .option('--read-only-control ', 'Known read-only negative-control identity') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed phase-1 token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--actor ', 'Explicit audit actor (defaults to subject)') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialValidateCommandOptions): Promise => { + const inherited = cred.opts<{ mosaicHome?: string }>(); + const result = await executeCredentialValidate(identity, { + ...options, + ...(inherited.mosaicHome === undefined ? {} : { mosaicHome: inherited.mosaicHome }), + }); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); +} diff --git a/packages/mosaic/src/credentials/audit-journal.dto.ts b/packages/mosaic/src/credentials/audit-journal.dto.ts new file mode 100644 index 00000000..71ffde27 --- /dev/null +++ b/packages/mosaic/src/credentials/audit-journal.dto.ts @@ -0,0 +1,54 @@ +export type CredentialJournalOperation = + | 'provision' + | 'wire' + | 'grant' + | 'get' + | 'validate' + | 'rotate' + | 'revoke'; + +export interface CredentialJournalContextDto { + readonly operation: CredentialJournalOperation; + readonly actor: string; + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: string | null; +} + +export interface CredentialProviderJournalEvidenceDto { + readonly endpoint: string; + readonly contentType: string; + readonly decision: string; +} + +export interface CredentialJournalCorrectionDto { + readonly supersedesJournalId: string; + readonly correctedByJournalId: string; + readonly previousReason: string; + readonly correctedReason: string; + readonly previousOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate'; + readonly correctedOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate'; +} + +export interface CredentialPopulationCorrectionDto { + readonly entries: readonly { + readonly identity: string; + readonly supersedesJournalIds: readonly string[]; + readonly settledByJournalId: string; + readonly capability: 'confirmed'; + readonly identityBinding: 'not-measured'; + readonly mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed'; + }[]; +} + +export interface CredentialJournalRuntimeOptionsDto { + readonly id?: string; + readonly now?: () => string; +} + +export interface CredentialJournalSummaryDto { + readonly id: string; + readonly state: 'open' | 'sealed'; + readonly path: string; +} diff --git a/packages/mosaic/src/credentials/audit-journal.spec.ts b/packages/mosaic/src/credentials/audit-journal.spec.ts new file mode 100644 index 00000000..fe8530df --- /dev/null +++ b/packages/mosaic/src/credentials/audit-journal.spec.ts @@ -0,0 +1,166 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CredentialAuditJournal, listCredentialJournals } from './audit-journal.js'; + +let cleanup: string | undefined; + +async function stateRoot(): Promise { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-journal-')); + return join(cleanup, 'state'); +} + +afterEach(async (): Promise => { + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +describe('credential durable audit journal', (): void => { + it('opens before mutation, appends provider evidence, and seals durably', async (): Promise => { + const root = await stateRoot(); + const journal = await CredentialAuditJournal.open( + root, + { + operation: 'grant', + actor: 'provisioner', + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + }, + { id: 'journal-id', now: (): string => '2026-08-05T00:00:00.000Z' }, + ); + + await journal.recordIntent('provider-grant'); + await journal.recordProviderEvidence({ + endpoint: 'GET /api/v1/repos/owner/repo', + contentType: 'application/json', + decision: 'permission-write', + }); + const sealedPath = await journal.seal('ok', 'grant-verified'); + + expect(sealedPath).toMatch(/journal-id\.sealed\.jsonl$/); + const records = (await readFile(sealedPath, 'utf8')).trim().split('\n'); + expect(records).toHaveLength(4); + expect(records[0]).toContain('"phase":"opened"'); + expect(records[1]).toContain('"phase":"intent"'); + expect(records[2]).toContain('"phase":"provider-evidence"'); + expect(records[3]).toContain('"phase":"sealed"'); + }); + + it('leaves an unsealed journal visible for recovery', async (): Promise => { + const root = await stateRoot(); + await CredentialAuditJournal.open(root, { + operation: 'rotate', + actor: 'provisioner', + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: null, + }); + + const journals = await listCredentialJournals(root); + + expect(journals).toHaveLength(1); + expect(journals[0]?.state).toBe('open'); + }); + + it('fails fatally when the durable journal root cannot be created', async (): Promise => { + const root = await stateRoot(); + await writeFile(root, 'not-a-directory', { mode: 0o600 }); + + await expect( + CredentialAuditJournal.open(root, { + operation: 'grant', + actor: 'provisioner', + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + }), + ).rejects.toThrow(/journal-unavailable/); + }); + + it('rejects secret-shaped evidence instead of writing it', async (): Promise => { + const root = await stateRoot(); + const journal = await CredentialAuditJournal.open(root, { + operation: 'validate', + actor: 'seat-name', + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + }); + + await expect( + journal.recordProviderEvidence({ + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + decision: 'seeded-secret-canary', + }), + ).rejects.toThrow(/unsafe-audit-value/); + const journals = await listCredentialJournals(root); + const source = await readFile(journals[0]?.path ?? '', 'utf8'); + expect(source).not.toContain('seeded-secret-canary'); + }); + + it('supersedes a false sealed classification without editing the original journal', async (): Promise => { + const root = await stateRoot(); + const journal = await CredentialAuditJournal.open( + root, + { + operation: 'validate', + actor: 'be-coder-06', + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + }, + { id: 'correction-1' }, + ); + await journal.recordIntent('classification-correction'); + await journal.recordCorrection({ + supersedesJournalId: 'old-sealed-id', + correctedByJournalId: 'new-validation-id', + previousReason: 'identity-not-found', + correctedReason: 'credential-rejected', + previousOutcome: 'indeterminate', + correctedOutcome: 'refused', + }); + const path = await journal.seal('indeterminate', 'credential-rejected'); + const source = await readFile(path, 'utf8'); + expect(source).toContain('"phase":"classification-correction"'); + expect(source).toContain('"supersedesJournalId":"old-sealed-id"'); + }); + + it('records one settled population correction across a classification chain', async (): Promise => { + const root = await stateRoot(); + const journal = await CredentialAuditJournal.open(root, { + operation: 'validate', + actor: 'be-coder-06', + identity: 'fleet-reconciliation', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + }); + await journal.recordIntent('classification-correction'); + await journal.recordPopulationCorrection({ + entries: [ + { + identity: 'seat-name', + supersedesJournalIds: ['v1-2-id', 'v1-4-id', 'v1-4-1-id'], + settledByJournalId: 'v1-5-id', + capability: 'confirmed', + identityBinding: 'not-measured', + mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed', + }, + ], + }); + const path = await journal.seal('ok', 'classification-corrected'); + const source = await readFile(path, 'utf8'); + expect(source).toContain('"phase":"population-classification-correction"'); + expect(source).toContain('"capability":"confirmed"'); + expect(source).toContain('"identityBinding":"not-measured"'); + }); +}); diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts new file mode 100644 index 00000000..ff5c592b --- /dev/null +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -0,0 +1,256 @@ +import { randomUUID } from 'node:crypto'; +import { open, readdir, rename } from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ensureManagedDirectory } from '../fleet/secure-file.js'; +import type { + CredentialJournalContextDto, + CredentialJournalCorrectionDto, + CredentialJournalRuntimeOptionsDto, + CredentialPopulationCorrectionDto, + CredentialJournalSummaryDto, + CredentialProviderJournalEvidenceDto, +} from './audit-journal.dto.js'; + +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const SAFE_ESTATE = /^[a-z0-9][a-z0-9-]*$/; +const SAFE_HOST = /^[a-z0-9][a-z0-9.-]*$/; +const SAFE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/; +const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/; +const SAFE_DECISIONS = new Set([ + 'provider-grant', + 'permission-read', + 'permission-write', + 'permission-admin', + 'identity-verified', + 'scope-verified', + 'grant-verified', + 'revoke-verified', + 'rotate-verified', + 'validation-requested', + 'validation-verified', + 'team-member-present', + 'team-repository-present', + 'organization-member-present', + 'classification-correction', +]); + +export class CredentialJournalError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Credential audit journal failed: code=${code} ${message}`); + this.name = 'CredentialJournalError'; + } +} + +function assertContext(context: CredentialJournalContextDto): void { + if ( + !SAFE_NAME.test(context.actor) || + !SAFE_NAME.test(context.identity) || + !SAFE_ESTATE.test(context.estate) || + !SAFE_HOST.test(context.host) || + (context.repo !== null && !SAFE_REPO.test(context.repo)) + ) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'journal context is outside the non-secret allowlist grammar', + ); + } +} + +function assertEvidence(evidence: CredentialProviderJournalEvidenceDto): void { + if ( + !SAFE_ENDPOINT.test(evidence.endpoint) || + !SAFE_CONTENT_TYPE.test(evidence.contentType) || + !SAFE_DECISIONS.has(evidence.decision) + ) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'provider evidence is outside the non-secret allowlist', + ); + } +} + +async function syncDirectory(path: string): Promise { + const directory = await open(path, 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + +export class CredentialAuditJournal { + private closed = false; + + private constructor( + private readonly handle: FileHandle, + private readonly openPath: string, + private readonly journalsDirectory: string, + private readonly id: string, + private readonly now: () => string, + ) {} + + static async open( + stateRoot: string, + context: CredentialJournalContextDto, + runtime: CredentialJournalRuntimeOptionsDto = {}, + ): Promise { + assertContext(context); + const id = runtime.id ?? randomUUID(); + if (!SAFE_NAME.test(id)) { + throw new CredentialJournalError('unsafe-audit-value', 'journal id is outside the grammar'); + } + const now = runtime.now ?? ((): string => new Date().toISOString()); + const journalsDirectory = join(stateRoot, 'journals'); + let handle: FileHandle | undefined; + try { + ensureManagedDirectory(stateRoot, journalsDirectory); + const openPath = join(journalsDirectory, `${id}.open.jsonl`); + handle = await open(openPath, 'wx', 0o600); + const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now); + await journal.append({ phase: 'opened', at: now(), context }); + await syncDirectory(journalsDirectory); + return journal; + } catch (error: unknown) { + if (handle !== undefined) await handle.close().catch((): void => undefined); + if (error instanceof CredentialJournalError) throw error; + throw new CredentialJournalError( + 'journal-unavailable', + 'durable journal could not be opened and fsynced', + ); + } + } + + private async append(record: object): Promise { + if (this.closed) { + throw new CredentialJournalError('journal-unavailable', 'journal is already closed'); + } + try { + await this.handle.write(`${JSON.stringify(record)}\n`); + await this.handle.sync(); + } catch { + throw new CredentialJournalError( + 'journal-unavailable', + 'durable journal append or fsync failed', + ); + } + } + + journalId(): string { + return this.id; + } + + async recordIntent(decision: string): Promise { + if (!SAFE_DECISIONS.has(decision)) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'intent decision is outside the non-secret allowlist', + ); + } + await this.append({ phase: 'intent', at: this.now(), decision }); + } + + async recordProviderEvidence(evidence: CredentialProviderJournalEvidenceDto): Promise { + assertEvidence(evidence); + await this.append({ phase: 'provider-evidence', at: this.now(), evidence }); + } + + async recordCorrection(correction: CredentialJournalCorrectionDto): Promise { + if ( + !SAFE_NAME.test(correction.supersedesJournalId) || + !SAFE_NAME.test(correction.correctedByJournalId) || + !SAFE_NAME.test(correction.previousReason) || + !SAFE_NAME.test(correction.correctedReason) || + (correction.previousOutcome === undefined) !== (correction.correctedOutcome === undefined) + ) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'classification correction is outside the non-secret grammar', + ); + } + await this.append({ phase: 'classification-correction', at: this.now(), correction }); + } + + async recordPopulationCorrection(correction: CredentialPopulationCorrectionDto): Promise { + if ( + correction.entries.length === 0 || + correction.entries.some( + (entry): boolean => + !SAFE_NAME.test(entry.identity) || + !SAFE_NAME.test(entry.settledByJournalId) || + entry.supersedesJournalIds.length === 0 || + entry.supersedesJournalIds.some((id): boolean => !SAFE_NAME.test(id)), + ) + ) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'population correction is outside the non-secret grammar', + ); + } + await this.append({ + phase: 'population-classification-correction', + at: this.now(), + correction, + }); + } + + async seal( + outcome: 'ok' | 'refused' | 'error' | 'indeterminate', + reasonCode: string, + ): Promise { + if (!SAFE_NAME.test(reasonCode)) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'reason code is outside the non-secret grammar', + ); + } + await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode }); + await this.handle.close(); + this.closed = true; + const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`); + try { + await rename(this.openPath, sealedPath); + await syncDirectory(this.journalsDirectory); + return sealedPath; + } catch { + throw new CredentialJournalError( + 'journal-unavailable', + 'sealed journal could not be committed durably', + ); + } + } + + async closeIncomplete(): Promise { + if (this.closed) return; + await this.handle.close(); + this.closed = true; + } +} + +export async function listCredentialJournals( + stateRoot: string, +): Promise { + const journalsDirectory = join(stateRoot, 'journals'); + let names: string[]; + try { + names = await readdir(journalsDirectory); + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []; + throw new CredentialJournalError('journal-unavailable', 'journal directory could not be read'); + } + return names + .filter((name: string): boolean => /\.(?:open|sealed)\.jsonl$/.test(name)) + .sort() + .map((name: string): CredentialJournalSummaryDto => { + const state = name.endsWith('.open.jsonl') ? 'open' : 'sealed'; + return { + id: name.replace(/\.(?:open|sealed)\.jsonl$/, ''), + state, + path: join(journalsDirectory, name), + }; + }); +} diff --git a/packages/mosaic/src/credentials/credential-provider.dto.ts b/packages/mosaic/src/credentials/credential-provider.dto.ts new file mode 100644 index 00000000..9970a689 --- /dev/null +++ b/packages/mosaic/src/credentials/credential-provider.dto.ts @@ -0,0 +1,50 @@ +import type { + ProviderIdentityEvidenceDto, + ReceivePackEvidenceDto, + RepositoryPermissionEvidenceDto, +} from './credential-result.dto.js'; + +export interface ResolvedCredential { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly resolutionId: string; + readonly secret: Uint8Array; +} + +export interface CredentialResolver { + resolve(identity: string, estate: string, host: string): Promise; +} + +export interface GiteaCredentialProvider { + readIdentity(resolved: ResolvedCredential): Promise; + readRepositoryPermission( + resolved: ResolvedCredential, + repo: string, + ): Promise; + probeReceivePack( + resolved: ResolvedCredential | undefined, + repo: string, + ): Promise; +} + +export interface CredentialEstateRegistry { + matches(estate: string, host: string): boolean; +} + +export interface CredentialValidationDependencies { + readonly resolver: CredentialResolver; + readonly provider: GiteaCredentialProvider; + readonly estateRegistry: CredentialEstateRegistry; +} + +export interface GiteaReadValidationRequestDto { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: string; +} + +export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto { + readonly readOnlyControlIdentity: string; +} diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts new file mode 100644 index 00000000..dd54f888 --- /dev/null +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -0,0 +1,77 @@ +export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate'; +export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown'; +export type RepositoryPermission = 'read' | 'write' | 'admin'; +export type ReceivePackState = 'advertised' | 'refused'; + +export interface CredentialReasonDto { + readonly code: string; + readonly message: string; +} + +export interface CredentialSubjectDto { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: string; +} + +export interface ProviderIdentityEvidenceDto { + readonly login: string; + readonly endpoint: string; + readonly contentType: string; +} + +export interface RepositoryPermissionEvidenceDto { + readonly effective: RepositoryPermission; + readonly endpoint: string; + readonly contentType: string; +} + +export interface ReceivePackEvidenceDto { + readonly state: ReceivePackState; + readonly principal: string | null; + readonly resolutionId: string | null; + readonly contentType: string; +} + +export interface ReadOnlyControlEvidenceDto { + readonly identity: string; + readonly providerPermission: RepositoryPermission; + readonly receivePack: ReceivePackState; +} + +export interface WriteDifferentialEvidenceDto { + readonly state: 'can-write'; + readonly credentialBinding: 'same-resolution'; + readonly transportPrincipal: string; + readonly authenticatedReceivePack: 'advertised'; + readonly readOnlyControl: ReadOnlyControlEvidenceDto; + readonly unauthenticatedReceivePack: 'refused'; + readonly artifactCreated: false; + readonly proves: string; + readonly doesNotProve: string; +} + +export interface CredentialValidationEvidenceDto { + readonly providerIdentity: ProviderIdentityEvidenceDto | null; + readonly repositoryPermission: RepositoryPermissionEvidenceDto | null; + readonly writeDifferential: WriteDifferentialEvidenceDto | null; +} + +export interface CredentialAuditResultDto { + readonly journalId: string | null; + readonly state: 'not-started' | 'open' | 'sealed'; +} + +export interface CredentialValidationResultDto { + readonly schemaVersion: 1; + readonly operation: 'validate'; + readonly outcome: CredentialOutcome; + readonly exitCode: 0 | 10 | 20 | 30; + readonly retryable: boolean; + readonly subject: CredentialSubjectDto; + readonly mutation: CredentialMutationState; + readonly reason: CredentialReasonDto; + readonly evidence: CredentialValidationEvidenceDto; + readonly audit: CredentialAuditResultDto; +} diff --git a/packages/mosaic/src/credentials/credential-validate-service.ts b/packages/mosaic/src/credentials/credential-validate-service.ts new file mode 100644 index 00000000..101cee36 --- /dev/null +++ b/packages/mosaic/src/credentials/credential-validate-service.ts @@ -0,0 +1,83 @@ +import { CredentialAuditJournal } from './audit-journal.js'; +import type { + CredentialValidationDependencies, + GiteaReadValidationRequestDto, + GiteaWriteValidationRequestDto, +} from './credential-provider.dto.js'; +import type { + CredentialValidationResultDto, + RepositoryPermission, +} from './credential-result.dto.js'; +import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js'; + +export interface CredentialValidationServiceOptions { + readonly stateRoot: string; + readonly actor: string; +} + +function permissionDecision(permission: RepositoryPermission): string { + if (permission === 'admin') return 'permission-admin'; + if (permission === 'write') return 'permission-write'; + return 'permission-read'; +} + +async function openValidationJournal( + request: GiteaReadValidationRequestDto, + options: CredentialValidationServiceOptions, +): Promise { + const journal = await CredentialAuditJournal.open(options.stateRoot, { + operation: 'validate', + actor: options.actor, + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }); + await journal.recordIntent('validation-requested'); + return journal; +} + +async function recordAndSealValidation( + journal: CredentialAuditJournal, + validation: CredentialValidationResultDto, +): Promise { + if (validation.evidence.providerIdentity !== null) { + await journal.recordProviderEvidence({ + endpoint: validation.evidence.providerIdentity.endpoint, + contentType: validation.evidence.providerIdentity.contentType, + decision: 'identity-verified', + }); + } + if (validation.evidence.repositoryPermission !== null) { + await journal.recordProviderEvidence({ + endpoint: validation.evidence.repositoryPermission.endpoint, + contentType: validation.evidence.repositoryPermission.contentType, + decision: permissionDecision(validation.evidence.repositoryPermission.effective), + }); + } + await journal.seal(validation.outcome, validation.reason.code); + return { + ...validation, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; +} + +export async function runCredentialReadValidation( + request: GiteaReadValidationRequestDto, + dependencies: CredentialValidationDependencies, + options: CredentialValidationServiceOptions, +): Promise { + const journal = await openValidationJournal(request, options); + const validation = await evaluateGiteaReadValidation(request, dependencies); + return recordAndSealValidation(journal, validation); +} + +export async function runCredentialValidation( + request: GiteaWriteValidationRequestDto, + dependencies: CredentialValidationDependencies, + options: CredentialValidationServiceOptions, +): Promise { + const journal = await openValidationJournal(request, options); + const validation = await evaluateGiteaWriteValidation(request, dependencies); + return recordAndSealValidation(journal, validation); +} diff --git a/packages/mosaic/src/credentials/delegated-credential.spec.ts b/packages/mosaic/src/credentials/delegated-credential.spec.ts new file mode 100644 index 00000000..0f62c1ed --- /dev/null +++ b/packages/mosaic/src/credentials/delegated-credential.spec.ts @@ -0,0 +1,74 @@ +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { readDelegatedCredentialFromFd } from './delegated-credential.js'; + +let cleanup: string | undefined; +afterEach(async (): Promise => { + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +describe('protected delegated credential channel', (): void => { + it('reads authority from an inherited fd number without putting the secret in argv or env', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-')); + const path = join(cleanup, 'authority'); + await writeFile( + path, + JSON.stringify({ + identity: 'provisioner', + estate: 'homelab', + host: 'git.example.invalid', + secret: 'seeded-authority-canary', + }), + { mode: 0o600 }, + ); + const handle = await open(path, 'r'); + try { + const resolved = await readDelegatedCredentialFromFd( + handle.fd, + 'provisioner', + 'homelab', + 'git.example.invalid', + ); + expect(resolved.identity).toBe('provisioner'); + expect(Buffer.from(resolved.secret).toString('utf8')).toBe('seeded-authority-canary'); + } finally { + await handle.close(); + } + }); + + it('rejects an authority identity or estate mismatch without echoing the secret', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-')); + const path = join(cleanup, 'authority'); + await writeFile( + path, + JSON.stringify({ + identity: 'other', + estate: 'usc', + host: 'git.example.invalid', + secret: 'seeded-authority-canary', + }), + { mode: 0o600 }, + ); + const handle = await open(path, 'r'); + try { + let message = ''; + try { + await readDelegatedCredentialFromFd( + handle.fd, + 'provisioner', + 'homelab', + 'git.example.invalid', + ); + } catch (error: unknown) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain('delegated-authority-mismatch'); + expect(message).not.toContain('seeded-authority-canary'); + } finally { + await handle.close(); + } + }); +}); diff --git a/packages/mosaic/src/credentials/delegated-credential.ts b/packages/mosaic/src/credentials/delegated-credential.ts new file mode 100644 index 00000000..e2abc423 --- /dev/null +++ b/packages/mosaic/src/credentials/delegated-credential.ts @@ -0,0 +1,94 @@ +import { randomUUID } from 'node:crypto'; +import { fstatSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { z } from 'zod'; +import type { ResolvedCredential } from './credential-provider.dto.js'; + +const authoritySchema = z + .object({ + identity: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/), + estate: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + host: z.string().regex(/^[a-z0-9][a-z0-9.-]*$/), + secret: z + .string() + .min(1) + .max(16 * 1024) + .regex(/^\S+$/), + }) + .strict(); + +export class DelegatedCredentialError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Delegated credential rejected: code=${code} ${message}`); + this.name = 'DelegatedCredentialError'; + } +} + +export async function readDelegatedCredentialFromFd( + fd: number, + expectedIdentity: string, + expectedEstate: string, + expectedHost: string, +): Promise { + if (!Number.isSafeInteger(fd) || fd < 3 || fd > 1024) { + throw new DelegatedCredentialError('delegated-authority-unavailable', 'invalid inherited fd'); + } + let bytes: Buffer; + try { + const stat = fstatSync(fd); + if (!stat.isFile() && !stat.isFIFO()) { + throw new Error('fd is not a regular file or pipe'); + } + bytes = await readFile(`/proc/self/fd/${fd}`); + } catch { + throw new DelegatedCredentialError( + 'delegated-authority-unavailable', + 'protected inherited credential fd could not be read', + ); + } + if (bytes.byteLength > 32 * 1024) { + bytes.fill(0); + throw new DelegatedCredentialError( + 'delegated-authority-unavailable', + 'protected credential payload exceeded the bound', + ); + } + let raw: unknown; + try { + raw = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch { + bytes.fill(0); + throw new DelegatedCredentialError( + 'delegated-authority-unavailable', + 'protected credential payload was invalid', + ); + } + bytes.fill(0); + const parsed = authoritySchema.safeParse(raw); + if (!parsed.success) { + throw new DelegatedCredentialError( + 'delegated-authority-unavailable', + 'protected credential payload did not match the schema', + ); + } + if ( + parsed.data.identity !== expectedIdentity || + parsed.data.estate !== expectedEstate || + parsed.data.host !== expectedHost + ) { + throw new DelegatedCredentialError( + 'delegated-authority-mismatch', + 'protected credential does not match the explicit actor, estate, and host', + ); + } + return Object.freeze({ + identity: parsed.data.identity, + estate: parsed.data.estate, + host: parsed.data.host, + resolutionId: randomUUID(), + secret: new TextEncoder().encode(parsed.data.secret), + }); +} diff --git a/packages/mosaic/src/credentials/estate-registry.dto.ts b/packages/mosaic/src/credentials/estate-registry.dto.ts new file mode 100644 index 00000000..60a7adb9 --- /dev/null +++ b/packages/mosaic/src/credentials/estate-registry.dto.ts @@ -0,0 +1,14 @@ +export type CredentialProviderKind = 'gitea'; + +export interface CredentialHostConfigDto { + readonly host: string; + readonly provider: CredentialProviderKind; + readonly apiBaseUrl: string; + readonly tokenPrefix: string; +} + +export interface CredentialEstateConfigDto { + readonly name: string; + readonly readOnlyControlIdentity?: string; + readonly hosts: readonly CredentialHostConfigDto[]; +} diff --git a/packages/mosaic/src/credentials/estate-registry.spec.ts b/packages/mosaic/src/credentials/estate-registry.spec.ts new file mode 100644 index 00000000..a15b8602 --- /dev/null +++ b/packages/mosaic/src/credentials/estate-registry.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { parseCredentialEstateRegistry } from './estate-registry.js'; + +const validRegistry = JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + readOnlyControlIdentity: 'read-control', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], +}); + +describe('credential estate registry', (): void => { + it('requires an exact declared estate-host pair', (): void => { + const registry = parseCredentialEstateRegistry(validRegistry); + + expect(registry.matches('homelab', 'git.example.invalid')).toBe(true); + expect(registry.matches('usc', 'git.example.invalid')).toBe(false); + expect(registry.matches('homelab', 'other.example.invalid')).toBe(false); + }); + + it('rejects a provider URL whose host differs from the declared host', (): void => { + const source = validRegistry.replace( + 'https://git.example.invalid', + 'https://other.example.invalid', + ); + + expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/); + }); + + it('rejects one host assigned to multiple estates', (): void => { + const source = JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + { + name: 'other', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-other', + }, + ], + }, + ], + }); + + expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/); + }); + + it('rejects URLs with userinfo, path, query, fragment, or non-HTTPS scheme', (): void => { + for (const apiBaseUrl of [ + 'http://git.example.invalid', + 'https://user@git.example.invalid', + 'https://git.example.invalid/api', + 'https://git.example.invalid?x=1', + 'https://git.example.invalid#x', + ]) { + const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl); + expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/); + } + }); + + it('requires a configured read-only control for write validation', (): void => { + const registry = parseCredentialEstateRegistry(validRegistry); + const withoutControl = parseCredentialEstateRegistry( + validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''), + ); + + expect(registry.readOnlyControl('homelab')).toBe('read-control'); + expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/); + }); +}); diff --git a/packages/mosaic/src/credentials/estate-registry.ts b/packages/mosaic/src/credentials/estate-registry.ts new file mode 100644 index 00000000..093397a3 --- /dev/null +++ b/packages/mosaic/src/credentials/estate-registry.ts @@ -0,0 +1,142 @@ +import { z } from 'zod'; +import type { CredentialEstateRegistry } from './credential-provider.dto.js'; +import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js'; + +const NAME = /^[a-z0-9][a-z0-9-]*$/; +const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +const hostSchema = z + .object({ + host: z.string().regex(HOST), + provider: z.literal('gitea'), + apiBaseUrl: z.string(), + tokenPrefix: z.string().regex(NAME), + }) + .strict(); + +const estateSchema = z + .object({ + name: z.string().regex(NAME), + readOnlyControlIdentity: z.string().regex(IDENTITY).optional(), + hosts: z.array(hostSchema).min(1), + }) + .strict(); + +const registrySchema = z + .object({ + version: z.literal(1), + estates: z.array(estateSchema).min(1), + }) + .strict(); + +export class CredentialEstateRegistryError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Credential estate registry rejected: code=${code} ${message}`); + this.name = 'CredentialEstateRegistryError'; + } +} + +function validateApiUrl(host: CredentialHostConfigDto): void { + let url: URL; + try { + url = new URL(host.apiBaseUrl); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new CredentialEstateRegistryError('invalid-api-url', detail); + } + if ( + url.protocol !== 'https:' || + url.username !== '' || + url.password !== '' || + url.pathname !== '/' || + url.search !== '' || + url.hash !== '' + ) { + throw new CredentialEstateRegistryError( + 'invalid-api-url', + 'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment', + ); + } + if (url.hostname !== host.host) { + throw new CredentialEstateRegistryError( + 'api-host-mismatch', + 'provider API URL hostname does not equal the declared host', + ); + } +} + +export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry { + private readonly estates: ReadonlyMap; + + constructor(estates: readonly CredentialEstateConfigDto[]) { + this.estates = new Map( + estates.map( + (estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [ + estate.name, + estate, + ], + ), + ); + } + + matches(estate: string, host: string): boolean { + return this.resolve(estate, host) !== undefined; + } + + resolve(estate: string, host: string): CredentialHostConfigDto | undefined { + return this.estates + .get(estate) + ?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host); + } + + readOnlyControl(estate: string): string { + const identity = this.estates.get(estate)?.readOnlyControlIdentity; + if (identity === undefined) { + throw new CredentialEstateRegistryError( + 'read-only-control-missing', + `estate ${estate} has no provider-confirmed read-only control identity`, + ); + } + return identity; + } +} + +export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry { + let raw: unknown; + try { + raw = JSON.parse(source); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new CredentialEstateRegistryError('invalid-json', detail); + } + + const parsed = registrySchema.safeParse(raw); + if (!parsed.success) { + throw new CredentialEstateRegistryError( + 'invalid-schema', + parsed.error.issues[0]?.message ?? 'invalid', + ); + } + + const estateNames = new Set(); + const hostNames = new Set(); + for (const estate of parsed.data.estates) { + if (estateNames.has(estate.name)) { + throw new CredentialEstateRegistryError('duplicate-estate', estate.name); + } + estateNames.add(estate.name); + for (const host of estate.hosts) { + validateApiUrl(host); + if (hostNames.has(host.host)) { + throw new CredentialEstateRegistryError('duplicate-host', host.host); + } + hostNames.add(host.host); + } + } + + return new ParsedCredentialEstateRegistry(parsed.data.estates); +} diff --git a/packages/mosaic/src/credentials/file-credential-store.spec.ts b/packages/mosaic/src/credentials/file-credential-store.spec.ts new file mode 100644 index 00000000..35dac898 --- /dev/null +++ b/packages/mosaic/src/credentials/file-credential-store.spec.ts @@ -0,0 +1,101 @@ +import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mkdtemp } from 'node:fs/promises'; +import { afterEach, describe, expect, it } from 'vitest'; +import { rm } from 'node:fs/promises'; +import { parseCredentialEstateRegistry } from './estate-registry.js'; +import { FileCredentialResolver } from './file-credential-store.js'; + +let cleanup: string | undefined; + +async function fixtureRoot(): Promise { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-store-')); + const root = join(cleanup, 'tokens'); + await mkdir(root, { mode: 0o700 }); + return root; +} + +function registry(): ReturnType { + return parseCredentialEstateRegistry( + JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], + }), + ); +} + +afterEach(async (): Promise => { + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +describe('phase-1 governed file credential resolver', (): void => { + it('resolves only the exact estate/host/identity token at a test-overridable root', async (): Promise => { + const root = await fixtureRoot(); + await writeFile(join(root, 'gitea-example-seat.token'), 'canary-token', { mode: 0o600 }); + const resolver = new FileCredentialResolver(root, registry()); + + const resolved = await resolver.resolve('seat', 'homelab', 'git.example.invalid'); + const wrongEstate = await resolver.resolve('seat', 'usc', 'git.example.invalid'); + + expect(resolved?.identity).toBe('seat'); + expect(Buffer.from(resolved?.secret ?? []).toString('utf8')).toBe('canary-token'); + expect(wrongEstate).toBeUndefined(); + }); + + it('rejects a token file with group or other permissions', async (): Promise => { + const root = await fixtureRoot(); + const path = join(root, 'gitea-example-seat.token'); + await writeFile(path, 'canary-token', { mode: 0o600 }); + await chmod(path, 0o640); + const resolver = new FileCredentialResolver(root, registry()); + + await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow( + /insecure-token-mode/, + ); + }); + + it('rejects a symlinked token instead of following it', async (): Promise => { + const root = await fixtureRoot(); + const target = join(cleanup ?? root, 'outside-token'); + await writeFile(target, 'canary-token', { mode: 0o600 }); + await symlink(target, join(root, 'gitea-example-seat.token')); + const resolver = new FileCredentialResolver(root, registry()); + + await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow( + /symbolic link|unavailable/, + ); + }); + + it('rejects traversal-shaped identities before touching storage', async (): Promise => { + const root = await fixtureRoot(); + const resolver = new FileCredentialResolver(root, registry()); + + await expect(resolver.resolve('../other', 'homelab', 'git.example.invalid')).rejects.toThrow( + /invalid-identity/, + ); + }); + + it('returns undefined for an absent token without borrowing another identity', async (): Promise => { + const root = await fixtureRoot(); + await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 }); + const resolver = new FileCredentialResolver(root, registry()); + + const resolved = await resolver.resolve('missing-seat', 'homelab', 'git.example.invalid'); + + expect(resolved).toBeUndefined(); + }); +}); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts new file mode 100644 index 00000000..119aa8fd --- /dev/null +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -0,0 +1,92 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { readRegularFileSecure, type SecureFileSnapshot } from '../fleet/secure-file.js'; +import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js'; +import type { ParsedCredentialEstateRegistry } from './estate-registry.js'; + +const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const MAX_TOKEN_BYTES = 16 * 1024; + +export class CredentialStoreError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Credential store rejected: code=${code} ${message}`); + this.name = 'CredentialStoreError'; + } +} + +function isMissingFile(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'ENOENT' + ); +} + +function validateSecret(content: Buffer): Uint8Array { + if (content.byteLength === 0 || content.byteLength > MAX_TOKEN_BYTES) { + throw new CredentialStoreError('invalid-token-size', 'token file size is outside bounds'); + } + for (const byte of content) { + if (byte <= 0x20 || byte === 0x7f) { + throw new CredentialStoreError( + 'invalid-token-bytes', + 'token file contains whitespace or control bytes', + ); + } + } + return new Uint8Array(content); +} + +export class FileCredentialResolver implements CredentialResolver { + constructor( + private readonly tokenDirectory: string, + private readonly estateRegistry: ParsedCredentialEstateRegistry, + ) {} + + async resolve( + identity: string, + estate: string, + host: string, + ): Promise { + if (!IDENTITY.test(identity)) { + throw new CredentialStoreError( + 'invalid-identity', + 'identity is outside the allowlist grammar', + ); + } + const hostConfig = this.estateRegistry.resolve(estate, host); + if (hostConfig === undefined) return undefined; + + const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`); + let snapshot: SecureFileSnapshot; + try { + snapshot = readRegularFileSecure(path, { + root: this.tokenDirectory, + maxBytes: MAX_TOKEN_BYTES, + }); + } catch (error: unknown) { + if (isMissingFile(error)) return undefined; + throw error; + } + + const permissions = snapshot.mode & 0o777; + if ((permissions & 0o077) !== 0) { + throw new CredentialStoreError( + 'insecure-token-mode', + 'token file grants group or other access', + ); + } + + return Object.freeze({ + identity, + estate, + host, + resolutionId: randomUUID(), + secret: validateSecret(snapshot.content), + }); + } +} diff --git a/packages/mosaic/src/credentials/gitea-provider.spec.ts b/packages/mosaic/src/credentials/gitea-provider.spec.ts new file mode 100644 index 00000000..9384f872 --- /dev/null +++ b/packages/mosaic/src/credentials/gitea-provider.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import { GiteaCredentialProviderAdapter, GiteaTeamGrantProviderAdapter } from './gitea-provider.js'; +import type { ResolvedCredential } from './credential-provider.dto.js'; + +const credential: ResolvedCredential = Object.freeze({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'resolution-1', + secret: new TextEncoder().encode('seeded-secret-canary'), +}); + +function jsonResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json;charset=utf-8' }, + }); +} + +describe('Gitea credential provider transport', (): void => { + it('reads the provider identity with the fixed transport and no secret in the URL', async (): Promise => { + const calls: Array<{ readonly input: string; readonly init?: RequestInit }> = []; + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (input: string | URL | Request, init?: RequestInit): Promise => { + calls.push({ input: String(input), ...(init === undefined ? {} : { init }) }); + return jsonResponse({ id: 21, login: 'seat-name' }); + }, + ); + + const evidence = await adapter.readIdentity(credential); + + expect(evidence).toEqual({ + login: 'seat-name', + endpoint: 'GET /api/v1/user', + contentType: 'application/json;charset=utf-8', + }); + expect(calls[0]?.input).toBe('https://git.example.invalid/api/v1/user'); + expect(calls[0]?.input).not.toContain('seeded-secret-canary'); + expect(new Headers(calls[0]?.init?.headers).get('user-agent')).toBe('mosaic-cred/1'); + }); + + it('maps the authenticated provider repository object to effective permission', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => + jsonResponse({ + id: 99, + full_name: 'owner/repo', + permissions: { admin: false, push: true, pull: true }, + }), + ); + + const evidence = await adapter.readRepositoryPermission(credential, 'owner/repo'); + + expect(evidence.effective).toBe('write'); + expect(evidence.endpoint).toBe('GET /api/v1/repos/owner/repo'); + }); + + it('binds an authenticated receive-pack advertisement to the supplied credential handle', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => + new Response('001f# service=git-receive-pack\n0000', { + status: 200, + headers: { + 'content-type': 'application/x-git-receive-pack-advertisement', + }, + }), + ); + + const evidence = await adapter.probeReceivePack(credential, 'owner/repo'); + + expect(evidence).toEqual({ + state: 'advertised', + principal: 'seat-name', + resolutionId: 'resolution-1', + contentType: 'application/x-git-receive-pack-advertisement', + }); + }); + + it('reports authenticated and unauthenticated receive-pack refusals without inventing success', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => + new Response('denied', { status: 403, headers: { 'content-type': 'text/plain' } }), + ); + + await expect(adapter.probeReceivePack(credential, 'owner/repo')).resolves.toMatchObject({ + state: 'refused', + principal: 'seat-name', + resolutionId: 'resolution-1', + }); + await expect(adapter.probeReceivePack(undefined, 'owner/repo')).resolves.toMatchObject({ + state: 'refused', + principal: null, + resolutionId: null, + }); + }); + + it('does not call a scope-forbidden identity read a dead credential', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => jsonResponse({ message: 'forbidden' }, 403), + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'identity-read-forbidden', + }); + }); + + it('classifies only the supplied credential as rejected without inferring identity absence', async (): Promise => { + let calls = 0; + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => { + calls += 1; + return jsonResponse({ message: 'unauthorized' }, 401); + }, + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'credential-rejected', + }); + expect(calls).toBe(1); + }); + + it('classifies a rejected credential separately when the declared identity exists', async (): Promise => { + let call = 0; + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => { + call += 1; + if (call === 1) return jsonResponse({ message: 'unauthorized' }, 401); + return jsonResponse({ id: 21, login: 'seat-name' }); + }, + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'credential-rejected', + }); + }); + + it('rejects a 200 HTML identity response as unexpected content type', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => + new Response('not an API object', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'unexpected-content-type', + }); + }); + + it('reads team permission, member attachment, and repository attachment separately', async (): Promise => { + const adapter = new GiteaTeamGrantProviderAdapter( + 'https://git.example.invalid', + async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = String(input); + if (url.endsWith('/api/v1/orgs/owner/teams')) { + return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]); + } + if (init?.method === 'PUT') return new Response(null, { status: 204 }); + if (url.includes('/members/seat-name')) { + return jsonResponse({ id: 21, login: 'seat-name' }); + } + if (url.includes('/repos/owner/repo')) { + return jsonResponse({ id: 4, full_name: 'owner/repo' }); + } + return jsonResponse({ message: 'unexpected' }, 500); + }, + ); + + const team = await adapter.resolveTeam(credential, 'owner', 'writers'); + await adapter.addTeamMember(credential, team.id, 'seat-name'); + await adapter.attachTeamRepository(credential, team.id, 'owner/repo'); + await expect(adapter.readTeamMember(credential, team.id, 'seat-name')).resolves.toMatchObject({ + state: 'present', + }); + await expect( + adapter.readTeamRepository(credential, team.id, 'owner/repo'), + ).resolves.toMatchObject({ state: 'present' }); + expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' }); + }); + + it('never includes seeded secret material in provider error messages', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => { + throw new Error('connection reset'); + }, + ); + + let message = ''; + try { + await adapter.readIdentity(credential); + } catch (error: unknown) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).not.toContain('seeded-secret-canary'); + expect(message).toContain('provider-unavailable'); + }); +}); diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts new file mode 100644 index 00000000..cc00101a --- /dev/null +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -0,0 +1,573 @@ +import { z } from 'zod'; +import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js'; +import type { GiteaGrantProvider } from './grant.js'; +import type { + GiteaTeamGrantProvider, + PresenceEvidence, + TeamResolutionEvidence, +} from './team-grant.js'; +import type { + CollaboratorPermissionEvidenceDto, + OrganizationMembershipEvidenceDto, +} from './grant.dto.js'; +import type { + ProviderIdentityEvidenceDto, + ReceivePackEvidenceDto, + RepositoryPermission, + RepositoryPermissionEvidenceDto, +} from './credential-result.dto.js'; + +const MAX_PROVIDER_BYTES = 1024 * 1024; +const USER_AGENT = 'mosaic-cred/1'; +const JSON_CONTENT_TYPE = 'application/json'; +const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement'; +const REPO_COMPONENT = /^[A-Za-z0-9_.-]+$/; + +type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +const userSchema = z + .object({ + id: z.number().int(), + login: z.string().min(1), + is_admin: z.boolean().optional(), + visibility: z.enum(['public', 'limited', 'private']).optional(), + }) + .passthrough(); + +const collaboratorPermissionSchema = z + .object({ + permission: z.enum(['read', 'write', 'admin']), + user: z.object({ login: z.string().min(1) }).passthrough(), + }) + .passthrough(); + +const organizationSchema = z.object({ username: z.string().min(1) }).passthrough(); +const teamSchema = z + .object({ + id: z.number().int().positive(), + name: z.string().min(1), + permission: z.enum(['read', 'write', 'admin']), + }) + .passthrough(); + +const repoSchema = z + .object({ + id: z.number().int(), + full_name: z.string().min(3), + permissions: z + .object({ + admin: z.boolean(), + push: z.boolean(), + pull: z.boolean(), + }) + .strict(), + }) + .passthrough(); + +export class CredentialProviderEvidenceError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Gitea credential evidence unavailable: code=${code} ${message}`); + this.name = 'CredentialProviderEvidenceError'; + } +} + +function contentType(response: Response): string { + return response.headers.get('content-type') ?? ''; +} + +function isJson(response: Response): boolean { + return contentType(response).toLowerCase().startsWith(JSON_CONTENT_TYPE); +} + +async function boundedBody(response: Response): Promise { + const declared = response.headers.get('content-length'); + if (declared !== null) { + const bytes = Number.parseInt(declared, 10); + if (Number.isFinite(bytes) && bytes > MAX_PROVIDER_BYTES) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider response exceeded the bounded size', + ); + } + } + const body = new Uint8Array(await response.arrayBuffer()); + if (body.byteLength > MAX_PROVIDER_BYTES) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider response exceeded the bounded size', + ); + } + return body; +} + +async function jsonObject(response: Response): Promise { + if (!isJson(response)) { + throw new CredentialProviderEvidenceError( + 'unexpected-content-type', + 'provider response was not JSON', + ); + } + const bytes = await boundedBody(response); + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider JSON could not be parsed', + ); + } +} + +function tokenText(resolved: ResolvedCredential): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(resolved.secret); + } catch { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'credential bytes were not valid text', + ); + } +} + +function apiAuthorization(resolved: ResolvedCredential): string { + return `token ${tokenText(resolved)}`; +} + +function gitAuthorization(resolved: ResolvedCredential): string { + const basic = Buffer.from(`${resolved.identity}:${tokenText(resolved)}`, 'utf8').toString( + 'base64', + ); + return `Basic ${basic}`; +} + +function repoPath(repo: string): { readonly owner: string; readonly name: string } { + const pieces = repo.split('/'); + const owner = pieces[0]; + const name = pieces[1]; + if ( + pieces.length !== 2 || + owner === undefined || + name === undefined || + !REPO_COMPONENT.test(owner) || + !REPO_COMPONENT.test(name) + ) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'repository must be exactly owner/name in the allowlist grammar', + ); + } + return { owner, name }; +} + +function effectivePermission(permissions: { + readonly admin: boolean; + readonly push: boolean; + readonly pull: boolean; +}): RepositoryPermission { + if (permissions.admin) return 'admin'; + if (permissions.push) return 'write'; + return 'read'; +} + +export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider { + protected readonly origin: string; + + constructor( + apiBaseUrl: string, + private readonly fetchImpl: FetchLike = fetch, + ) { + const parsed = new URL(apiBaseUrl); + this.origin = parsed.origin; + } + + protected async request(url: string, init: RequestInit): Promise { + try { + return await this.fetchImpl(url, init); + } catch { + throw new CredentialProviderEvidenceError( + 'provider-unavailable', + 'provider request failed before evidence was available', + ); + } + } + + private async classifyRejectedIdentity(rejected: Response): Promise { + if (!isJson(rejected)) { + throw new CredentialProviderEvidenceError( + 'unexpected-content-type', + 'provider credential rejection was not JSON', + ); + } + const status = rejected.status; + await boundedBody(rejected); + if (status === 403 || status === 404) { + throw new CredentialProviderEvidenceError( + 'identity-read-forbidden', + 'provider denied the identity endpoint; credential capability must be tested in scope', + ); + } + throw new CredentialProviderEvidenceError( + 'credential-rejected', + 'provider rejected the supplied credential; account existence was not inferred', + ); + } + + async readIdentity(resolved: ResolvedCredential): Promise { + const endpoint = 'GET /api/v1/user'; + const response = await this.request(`${this.origin}/api/v1/user`, { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(resolved), + 'User-Agent': USER_AGENT, + }, + }); + if (response.status === 401 || response.status === 403 || response.status === 404) { + return this.classifyRejectedIdentity(response); + } + if (!response.ok) { + throw new CredentialProviderEvidenceError( + 'provider-unavailable', + `provider identity request returned HTTP ${response.status.toString()}`, + ); + } + const parsed = userSchema.safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider identity object lacked required fields', + ); + } + return { + login: parsed.data.login, + endpoint, + contentType: contentType(response), + }; + } + + async readRepositoryPermission( + resolved: ResolvedCredential, + repo: string, + ): Promise { + const { owner, name } = repoPath(repo); + const endpoint = `GET /api/v1/repos/${owner}/${name}`; + const response = await this.request(`${this.origin}/api/v1/repos/${owner}/${name}`, { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(resolved), + 'User-Agent': USER_AGENT, + }, + }); + if (!response.ok) { + throw new CredentialProviderEvidenceError( + 'provider-unavailable', + `provider repository request returned HTTP ${response.status.toString()}`, + ); + } + const parsed = repoSchema.safeParse(await jsonObject(response)); + if (!parsed.success || parsed.data.full_name !== repo) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider repository object did not identify the requested repository', + ); + } + return { + effective: effectivePermission(parsed.data.permissions), + endpoint, + contentType: contentType(response), + }; + } + + async probeReceivePack( + resolved: ResolvedCredential | undefined, + repo: string, + ): Promise { + const { owner, name } = repoPath(repo); + const headers = new Headers({ + Accept: RECEIVE_PACK_CONTENT_TYPE, + 'User-Agent': USER_AGENT, + }); + if (resolved !== undefined) headers.set('Authorization', gitAuthorization(resolved)); + const response = await this.request( + `${this.origin}/${owner}/${name}.git/info/refs?service=git-receive-pack`, + { method: 'GET', headers }, + ); + const responseType = contentType(response); + if (response.status === 401 || response.status === 403) { + await boundedBody(response); + return { + state: 'refused', + principal: resolved?.identity ?? null, + resolutionId: resolved?.resolutionId ?? null, + contentType: responseType, + }; + } + if (!response.ok || !responseType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)) { + throw new CredentialProviderEvidenceError( + response.ok ? 'unexpected-content-type' : 'provider-unavailable', + `receive-pack response was not an advertisement (HTTP ${response.status.toString()})`, + ); + } + const body = new TextDecoder('utf-8', { fatal: true }).decode(await boundedBody(response)); + if (!body.includes('# service=git-receive-pack')) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'receive-pack advertisement lacked the protocol service preamble', + ); + } + return { + state: 'advertised', + principal: resolved?.identity ?? null, + resolutionId: resolved?.resolutionId ?? null, + contentType: responseType, + }; + } +} + +export class GiteaGrantProviderAdapter + extends GiteaCredentialProviderAdapter + implements GiteaGrantProvider +{ + async grantCollaborator( + authority: ResolvedCredential, + identity: string, + repo: string, + permission: RepositoryPermission, + ): Promise { + const { owner, name } = repoPath(repo); + const response = await this.request( + `${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}`, + { + method: 'PUT', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(authority), + 'Content-Type': JSON_CONTENT_TYPE, + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ permission }), + }, + ); + await boundedBody(response); + if (!response.ok) { + throw new CredentialProviderEvidenceError( + response.status === 401 || response.status === 403 + ? 'credential-rejected' + : 'provider-unavailable', + `provider grant request returned HTTP ${response.status.toString()}`, + ); + } + } + + async readCollaboratorPermission( + authority: ResolvedCredential, + identity: string, + repo: string, + ): Promise { + const { owner, name } = repoPath(repo); + const endpoint = `GET /api/v1/repos/${owner}/${name}/collaborators/${identity}/permission`; + const response = await this.request( + `${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}/permission`, + { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'readback-missing', + `collaborator permission read-back returned HTTP ${response.status.toString()}`, + ); + } + const parsed = collaboratorPermissionSchema.safeParse(await jsonObject(response)); + if (!parsed.success || parsed.data.user.login !== identity) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'collaborator permission object did not identify the declared subject', + ); + } + return { + identity: parsed.data.user.login, + permission: parsed.data.permission, + endpoint, + contentType: contentType(response), + }; + } + + async readOrganizationMembership( + subject: ResolvedCredential, + organization: string, + ): Promise { + const endpoint = `GET /api/v1/users/${subject.identity}/orgs`; + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(subject.identity)}/orgs`, + { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(subject), + 'User-Agent': USER_AGENT, + }, + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + response.status === 401 || response.status === 403 + ? 'scope-not-evaluable' + : 'provider-unavailable', + `organization membership read-back returned HTTP ${response.status.toString()}`, + ); + } + const parsed = z.array(organizationSchema).safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'organization membership response was not an organization array', + ); + } + return { + state: parsed.data.some((entry): boolean => entry.username === organization) + ? 'present' + : 'absent', + endpoint, + contentType: contentType(response), + }; + } +} + +export class GiteaTeamGrantProviderAdapter + extends GiteaGrantProviderAdapter + implements GiteaTeamGrantProvider +{ + async resolveTeam( + authority: ResolvedCredential, + organization: string, + team: string, + ): Promise { + const endpoint = `GET /api/v1/orgs/${organization}/teams`; + const response = await this.request( + `${this.origin}/api/v1/orgs/${encodeURIComponent(organization)}/teams`, + { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'provider-unavailable', + 'team list was unavailable', + ); + } + const parsed = z.array(teamSchema).safeParse(await jsonObject(response)); + const matches = parsed.success + ? parsed.data.filter((entry): boolean => entry.name === team) + : []; + if (matches.length !== 1 || matches[0] === undefined) { + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'team did not resolve uniquely', + ); + } + return { ...matches[0], endpoint, contentType: contentType(response) }; + } + + async addTeamMember( + authority: ResolvedCredential, + teamId: number, + identity: string, + ): Promise { + await this.putTeamPath( + authority, + `/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`, + ); + } + + async attachTeamRepository( + authority: ResolvedCredential, + teamId: number, + repo: string, + ): Promise { + const { owner, name } = repoPath(repo); + await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`); + } + + private async putTeamPath(authority: ResolvedCredential, path: string): Promise { + const response = await this.request(`${this.origin}${path}`, { + method: 'PUT', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }); + await boundedBody(response); + if (!response.ok) { + throw new CredentialProviderEvidenceError( + 'provider-unavailable', + 'team grant mutation failed', + ); + } + } + + async readTeamMember( + authority: ResolvedCredential, + teamId: number, + identity: string, + ): Promise { + return this.readPresence( + authority, + `GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`, + ); + } + + async readTeamRepository( + authority: ResolvedCredential, + teamId: number, + repo: string, + ): Promise { + const { owner, name } = repoPath(repo); + return this.readPresence( + authority, + `GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`, + ); + } + + private async readPresence( + authority: ResolvedCredential, + endpoint: string, + ): Promise { + const response = await this.request(`${this.origin}${endpoint.slice(4)}`, { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }); + if (response.status === 404) { + await boundedBody(response); + return { state: 'absent', endpoint, contentType: contentType(response) }; + } + if (!response.ok || !isJson(response)) { + await boundedBody(response); + throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed'); + } + await boundedBody(response); + return { state: 'present', endpoint, contentType: contentType(response) }; + } +} diff --git a/packages/mosaic/src/credentials/grant.dto.ts b/packages/mosaic/src/credentials/grant.dto.ts new file mode 100644 index 00000000..3a33868f --- /dev/null +++ b/packages/mosaic/src/credentials/grant.dto.ts @@ -0,0 +1,45 @@ +import type { + CredentialAuditResultDto, + CredentialMutationState, + CredentialOutcome, + CredentialReasonDto, + CredentialSubjectDto, + CredentialValidationEvidenceDto, + RepositoryPermission, +} from './credential-result.dto.js'; + +export interface CollaboratorPermissionEvidenceDto { + readonly identity: string; + readonly permission: RepositoryPermission; + readonly endpoint: string; + readonly contentType: string; +} + +export interface OrganizationMembershipEvidenceDto { + readonly state: 'present' | 'absent'; + readonly endpoint: string; + readonly contentType: string; +} + +export interface CredentialGrantEvidenceDto extends CredentialValidationEvidenceDto { + readonly collaboratorPermission: CollaboratorPermissionEvidenceDto | null; + readonly organizationMembership: OrganizationMembershipEvidenceDto | null; +} + +export interface CredentialGrantResultDto { + readonly schemaVersion: 1; + readonly operation: 'grant'; + readonly outcome: CredentialOutcome; + readonly exitCode: 0 | 10 | 20 | 30; + readonly retryable: boolean; + readonly subject: CredentialSubjectDto; + readonly mutation: CredentialMutationState; + readonly reason: CredentialReasonDto; + readonly evidence: CredentialGrantEvidenceDto; + readonly audit: CredentialAuditResultDto; +} + +export interface DirectGrantRequestDto extends CredentialSubjectDto { + readonly permission: RepositoryPermission; + readonly readOnlyControlIdentity: string; +} diff --git a/packages/mosaic/src/credentials/grant.spec.ts b/packages/mosaic/src/credentials/grant.spec.ts new file mode 100644 index 00000000..cbb49bf5 --- /dev/null +++ b/packages/mosaic/src/credentials/grant.spec.ts @@ -0,0 +1,182 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { listCredentialJournals } from './audit-journal.js'; +import { grantDirectRepositoryPermission } from './grant.js'; +import type { ResolvedCredential } from './credential-provider.dto.js'; +import type { GiteaGrantProvider } from './grant.js'; +import type { CredentialValidationDependencies } from './validate.js'; + +let cleanup: string | undefined; +const authority: ResolvedCredential = Object.freeze({ + identity: 'provisioner', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'authority', + secret: new TextEncoder().encode('authority-canary'), +}); + +async function stateRoot(): Promise { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-grant-')); + return join(cleanup, 'state'); +} + +afterEach(async (): Promise => { + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +function validationDependencies(permission: 'read' | 'write'): CredentialValidationDependencies { + const subject: ResolvedCredential = Object.freeze({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'subject', + secret: new TextEncoder().encode('subject-canary'), + }); + const control: ResolvedCredential = Object.freeze({ + identity: 'read-control', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'control', + secret: new TextEncoder().encode('control-canary'), + }); + return { + estateRegistry: { matches: (): boolean => true }, + resolver: { + async resolve(identity: string): Promise { + if (identity === 'seat-name') return subject; + if (identity === 'read-control') return control; + return undefined; + }, + }, + provider: { + async readIdentity(resolved: ResolvedCredential) { + return { + login: resolved.identity, + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async readRepositoryPermission(resolved: ResolvedCredential) { + return { + effective: resolved.identity === 'seat-name' ? permission : 'read', + endpoint: 'GET /api/v1/repos/owner/repo', + contentType: 'application/json', + }; + }, + async probeReceivePack(resolved: ResolvedCredential | undefined) { + const subjectWrite = resolved?.identity === 'seat-name' && permission === 'write'; + return { + state: subjectWrite ? 'advertised' : 'refused', + principal: resolved?.identity ?? null, + resolutionId: resolved?.resolutionId ?? null, + contentType: subjectWrite ? 'application/x-git-receive-pack-advertisement' : 'text/plain', + }; + }, + }, + }; +} + +describe('direct repository grant', (): void => { + it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise => { + const root = await stateRoot(); + const provider: GiteaGrantProvider = { + async readIdentity() { + return { + login: 'provisioner', + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async grantCollaborator(): Promise { + expect((await listCredentialJournals(root))[0]?.state).toBe('open'); + }, + async readCollaboratorPermission() { + return { + identity: 'seat-name', + permission: 'write', + endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission', + contentType: 'application/json', + }; + }, + async readOrganizationMembership() { + return { + state: 'absent', + endpoint: 'GET /api/v1/users/seat-name/orgs', + contentType: 'application/json', + }; + }, + }; + + const result = await grantDirectRepositoryPermission( + { + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + permission: 'write', + readOnlyControlIdentity: 'read-control', + }, + authority, + provider, + validationDependencies('write'), + { stateRoot: root, actor: 'provisioner' }, + ); + + expect(result.outcome).toBe('ok'); + expect(result.mutation).toBe('applied'); + expect(result.evidence.repositoryPermission?.effective).toBe('write'); + expect(result.evidence.organizationMembership?.state).toBe('absent'); + expect(result.audit.state).toBe('sealed'); + }); + + it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise => { + const root = await stateRoot(); + const provider: GiteaGrantProvider = { + async readIdentity() { + return { + login: 'provisioner', + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async grantCollaborator(): Promise {}, + async readCollaboratorPermission() { + return { + identity: 'seat-name', + permission: 'read', + endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission', + contentType: 'application/json', + }; + }, + async readOrganizationMembership() { + return { + state: 'absent', + endpoint: 'GET /api/v1/users/seat-name/orgs', + contentType: 'application/json', + }; + }, + }; + + const result = await grantDirectRepositoryPermission( + { + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + permission: 'write', + readOnlyControlIdentity: 'read-control', + }, + authority, + provider, + validationDependencies('read'), + { stateRoot: root, actor: 'provisioner' }, + ); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('permission-evidence-disagrees'); + expect(result.mutation).toBe('applied'); + }); +}); diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts new file mode 100644 index 00000000..dbb3d95d --- /dev/null +++ b/packages/mosaic/src/credentials/grant.ts @@ -0,0 +1,163 @@ +import { CredentialAuditJournal } from './audit-journal.js'; +import type { + CredentialValidationDependencies, + ResolvedCredential, +} from './credential-provider.dto.js'; +import type { RepositoryPermission } from './credential-result.dto.js'; +import type { + CollaboratorPermissionEvidenceDto, + CredentialGrantResultDto, + DirectGrantRequestDto, + OrganizationMembershipEvidenceDto, +} from './grant.dto.js'; +import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js'; + +export interface GiteaGrantProvider { + readIdentity(authority: ResolvedCredential): Promise<{ + readonly login: string; + readonly endpoint: string; + readonly contentType: string; + }>; + grantCollaborator( + authority: ResolvedCredential, + identity: string, + repo: string, + permission: RepositoryPermission, + ): Promise; + readCollaboratorPermission( + authority: ResolvedCredential, + identity: string, + repo: string, + ): Promise; + readOrganizationMembership( + subject: ResolvedCredential, + organization: string, + ): Promise; +} + +export interface CredentialGrantServiceOptions { + readonly stateRoot: string; + readonly actor: string; +} + +function exitFor(outcome: CredentialGrantResultDto['outcome']): 0 | 10 | 20 | 30 { + if (outcome === 'ok') return 0; + if (outcome === 'refused') return 10; + if (outcome === 'error') return 20; + return 30; +} + +export async function grantDirectRepositoryPermission( + request: DirectGrantRequestDto, + authority: ResolvedCredential, + grantProvider: GiteaGrantProvider, + validationDependencies: CredentialValidationDependencies, + options: CredentialGrantServiceOptions, +): Promise { + const journal = await CredentialAuditJournal.open(options.stateRoot, { + operation: 'grant', + actor: options.actor, + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }); + await journal.recordIntent('provider-grant'); + const authorityIdentity = await grantProvider.readIdentity(authority); + if (authorityIdentity.login !== options.actor) { + await journal.seal('refused', 'provider-identity-mismatch'); + return { + schemaVersion: 1, + operation: 'grant', + outcome: 'refused', + exitCode: 10, + retryable: false, + subject: { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }, + mutation: 'none', + reason: { + code: 'provider-identity-mismatch', + message: 'Delegated grant authority did not authenticate as the explicit audit actor.', + }, + evidence: { + providerIdentity: authorityIdentity, + repositoryPermission: null, + writeDifferential: null, + collaboratorPermission: null, + organizationMembership: null, + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; + } + await grantProvider.grantCollaborator( + authority, + request.identity, + request.repo, + request.permission, + ); + + const collaborator = await grantProvider.readCollaboratorPermission( + authority, + request.identity, + request.repo, + ); + const subject = await validationDependencies.resolver.resolve( + request.identity, + request.estate, + request.host, + ); + const organization = request.repo.split('/')[0] ?? ''; + const organizationMembership = + subject === undefined + ? null + : await grantProvider.readOrganizationMembership(subject, organization); + const validation = + request.permission === 'read' + ? await evaluateGiteaReadValidation(request, validationDependencies) + : await evaluateGiteaWriteValidation(request, validationDependencies); + + const readBackMatches = + collaborator.identity === request.identity && + collaborator.permission === request.permission && + validation.outcome === 'ok' && + validation.evidence.repositoryPermission?.effective === request.permission; + const outcome: CredentialGrantResultDto['outcome'] = readBackMatches ? 'ok' : 'indeterminate'; + const reasonCode = readBackMatches ? 'grant-verified' : 'permission-evidence-disagrees'; + await journal.recordProviderEvidence({ + endpoint: collaborator.endpoint, + contentType: collaborator.contentType, + decision: `permission-${collaborator.permission}`, + }); + await journal.seal(outcome, reasonCode); + + return { + schemaVersion: 1, + operation: 'grant', + outcome, + exitCode: exitFor(outcome), + retryable: false, + subject: { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }, + mutation: 'applied', + reason: { + code: reasonCode, + message: readBackMatches + ? 'Grant matched every required provider read-back.' + : 'Grant mutation completed but provider permission evidence disagreed.', + }, + evidence: { + ...validation.evidence, + collaboratorPermission: collaborator, + organizationMembership, + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; +} diff --git a/packages/mosaic/src/credentials/team-grant.spec.ts b/packages/mosaic/src/credentials/team-grant.spec.ts new file mode 100644 index 00000000..c16e9cbb --- /dev/null +++ b/packages/mosaic/src/credentials/team-grant.spec.ts @@ -0,0 +1,139 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { grantTeamRepositoryPermission, type GiteaTeamGrantProvider } from './team-grant.js'; +import type { ResolvedCredential } from './credential-provider.dto.js'; +import type { CredentialValidationDependencies } from './validate.js'; + +let cleanup: string | undefined; +afterEach(async (): Promise => { + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +const authority: ResolvedCredential = Object.freeze({ + identity: 'provisioner', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'authority', + secret: new TextEncoder().encode('authority-canary'), +}); +const subject: ResolvedCredential = Object.freeze({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'subject', + secret: new TextEncoder().encode('subject-canary'), +}); +const control: ResolvedCredential = Object.freeze({ + identity: 'read-control', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'control', + secret: new TextEncoder().encode('control-canary'), +}); + +function validation(): CredentialValidationDependencies { + return { + estateRegistry: { matches: (): boolean => true }, + resolver: { + async resolve(identity: string) { + return identity === 'seat-name' ? subject : control; + }, + }, + provider: { + async readIdentity(resolved: ResolvedCredential) { + return { + login: resolved.identity, + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async readRepositoryPermission(resolved: ResolvedCredential) { + return { + effective: resolved.identity === 'seat-name' ? 'write' : 'read', + endpoint: 'GET /api/v1/repos/owner/repo', + contentType: 'application/json', + }; + }, + async probeReceivePack(resolved: ResolvedCredential | undefined) { + const write = resolved?.identity === 'seat-name'; + return { + state: write ? 'advertised' : 'refused', + principal: resolved?.identity ?? null, + resolutionId: resolved?.resolutionId ?? null, + contentType: write ? 'application/x-git-receive-pack-advertisement' : 'text/plain', + }; + }, + }, + }; +} + +describe('team repository grant', (): void => { + it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); + const provider: GiteaTeamGrantProvider = { + async readIdentity() { + return { + login: 'provisioner', + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async resolveTeam() { + return { + id: 7, + name: 'writers', + permission: 'write', + endpoint: 'GET /api/v1/orgs/owner/teams', + contentType: 'application/json', + }; + }, + async addTeamMember(): Promise {}, + async attachTeamRepository(): Promise {}, + async readTeamMember() { + return { + state: 'present', + endpoint: 'GET /api/v1/teams/7/members/seat-name', + contentType: 'application/json', + }; + }, + async readTeamRepository() { + return { + state: 'present', + endpoint: 'GET /api/v1/teams/7/repos/owner/repo', + contentType: 'application/json', + }; + }, + async readOrganizationMembership() { + return { + state: 'present', + endpoint: 'GET /api/v1/users/seat-name/orgs', + contentType: 'application/json', + }; + }, + }; + + const result = await grantTeamRepositoryPermission( + { + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + permission: 'write', + team: 'writers', + readOnlyControlIdentity: 'read-control', + }, + authority, + provider, + validation(), + { stateRoot: join(cleanup, 'state'), actor: 'provisioner' }, + ); + + expect(result.outcome).toBe('ok'); + expect(result.evidence.organizationMembership?.state).toBe('present'); + expect(result.evidence.teamMembership?.state).toBe('present'); + expect(result.evidence.teamRepository?.state).toBe('present'); + }); +}); diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts new file mode 100644 index 00000000..7d0f2c76 --- /dev/null +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -0,0 +1,194 @@ +import { CredentialAuditJournal } from './audit-journal.js'; +import type { + CredentialValidationDependencies, + ResolvedCredential, +} from './credential-provider.dto.js'; +import type { + CredentialGrantResultDto, + DirectGrantRequestDto, + OrganizationMembershipEvidenceDto, +} from './grant.dto.js'; +import type { RepositoryPermission } from './credential-result.dto.js'; +import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js'; + +export interface TeamResolutionEvidence { + readonly id: number; + readonly name: string; + readonly permission: RepositoryPermission; + readonly endpoint: string; + readonly contentType: string; +} +export interface PresenceEvidence { + readonly state: 'present' | 'absent'; + readonly endpoint: string; + readonly contentType: string; +} +export interface TeamGrantRequest extends DirectGrantRequestDto { + readonly team: string; +} +export interface TeamGrantResult extends CredentialGrantResultDto { + readonly evidence: CredentialGrantResultDto['evidence'] & { + readonly team: TeamResolutionEvidence | null; + readonly teamMembership: PresenceEvidence | null; + readonly teamRepository: PresenceEvidence | null; + }; +} +export interface GiteaTeamGrantProvider { + readIdentity( + authority: ResolvedCredential, + ): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>; + resolveTeam( + authority: ResolvedCredential, + organization: string, + team: string, + ): Promise; + addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise; + attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise; + readTeamMember( + authority: ResolvedCredential, + teamId: number, + identity: string, + ): Promise; + readTeamRepository( + authority: ResolvedCredential, + teamId: number, + repo: string, + ): Promise; + readOrganizationMembership( + subject: ResolvedCredential, + organization: string, + ): Promise; +} +export interface TeamGrantOptions { + readonly stateRoot: string; + readonly actor: string; +} + +export async function grantTeamRepositoryPermission( + request: TeamGrantRequest, + authority: ResolvedCredential, + provider: GiteaTeamGrantProvider, + dependencies: CredentialValidationDependencies, + options: TeamGrantOptions, +): Promise { + const journal = await CredentialAuditJournal.open(options.stateRoot, { + operation: 'grant', + actor: options.actor, + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }); + await journal.recordIntent('provider-grant'); + const authorityIdentity = await provider.readIdentity(authority); + const organization = request.repo.split('/')[0] ?? ''; + const team = await provider.resolveTeam(authority, organization, request.team); + if (authorityIdentity.login !== options.actor || team.permission !== request.permission) { + await journal.seal('refused', 'provider-identity-mismatch'); + return result( + request, + journal, + 'refused', + 'none', + 'provider-identity-mismatch', + null, + null, + null, + null, + null, + ); + } + await provider.addTeamMember(authority, team.id, request.identity); + await provider.attachTeamRepository(authority, team.id, request.repo); + const teamMembership = await provider.readTeamMember(authority, team.id, request.identity); + const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo); + const subject = await dependencies.resolver.resolve( + request.identity, + request.estate, + request.host, + ); + const organizationMembership = + subject === undefined ? null : await provider.readOrganizationMembership(subject, organization); + const validation = + request.permission === 'read' + ? await evaluateGiteaReadValidation(request, dependencies) + : await evaluateGiteaWriteValidation(request, dependencies); + const ok = + teamMembership.state === 'present' && + teamRepository.state === 'present' && + organizationMembership?.state === 'present' && + validation.outcome === 'ok' && + validation.evidence.repositoryPermission?.effective === request.permission; + await journal.recordProviderEvidence({ + endpoint: teamMembership.endpoint, + contentType: teamMembership.contentType, + decision: 'team-member-present', + }); + await journal.recordProviderEvidence({ + endpoint: teamRepository.endpoint, + contentType: teamRepository.contentType, + decision: 'team-repository-present', + }); + await journal.seal( + ok ? 'ok' : 'indeterminate', + ok ? 'grant-verified' : 'permission-evidence-disagrees', + ); + return result( + request, + journal, + ok ? 'ok' : 'indeterminate', + 'applied', + ok ? 'grant-verified' : 'permission-evidence-disagrees', + validation, + team, + teamMembership, + teamRepository, + organizationMembership, + ); +} + +function result( + request: TeamGrantRequest, + journal: CredentialAuditJournal, + outcome: 'ok' | 'refused' | 'indeterminate', + mutation: 'none' | 'applied', + code: string, + validation: Awaited> | null, + team: TeamResolutionEvidence | null, + teamMembership: PresenceEvidence | null, + teamRepository: PresenceEvidence | null, + organizationMembership: OrganizationMembershipEvidenceDto | null, +): TeamGrantResult { + return { + schemaVersion: 1, + operation: 'grant', + outcome, + exitCode: outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30, + retryable: false, + subject: { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }, + mutation, + reason: { + code, + message: + outcome === 'ok' + ? 'Team grant matched every provider read-back.' + : 'Team grant was refused or could not be established.', + }, + evidence: { + providerIdentity: validation?.evidence.providerIdentity ?? null, + repositoryPermission: validation?.evidence.repositoryPermission ?? null, + writeDifferential: validation?.evidence.writeDifferential ?? null, + collaboratorPermission: null, + organizationMembership, + team, + teamMembership, + teamRepository, + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; +} diff --git a/packages/mosaic/src/credentials/validate.spec.ts b/packages/mosaic/src/credentials/validate.spec.ts new file mode 100644 index 00000000..57d407ae --- /dev/null +++ b/packages/mosaic/src/credentials/validate.spec.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from 'vitest'; +import { CredentialProviderEvidenceError } from './gitea-provider.js'; +import { + evaluateGiteaReadValidation, + evaluateGiteaWriteValidation, + type CredentialResolver, + type CredentialValidationDependencies, + type GiteaCredentialProvider, + type ProviderIdentityEvidence, + type ReceivePackEvidence, + type RepositoryPermissionEvidence, + type ResolvedCredential, +} from './validate.js'; + +interface FixtureOptions { + readonly subjectProviderIdentity?: string; + readonly subjectPermission?: 'read' | 'write' | 'admin'; + readonly subjectTransportState?: 'advertised' | 'refused'; + readonly subjectTransportPrincipal?: string; + readonly subjectTransportResolutionId?: string; + readonly controlProviderIdentity?: string; + readonly controlPermission?: 'read' | 'write' | 'admin'; + readonly controlTransportState?: 'advertised' | 'refused'; + readonly controlTransportPrincipal?: string; + readonly unauthenticatedTransportState?: 'advertised' | 'refused'; + readonly omitControl?: boolean; +} + +interface Fixture { + readonly dependencies: CredentialValidationDependencies; + readonly resolverCalls: string[]; + readonly identityHandles: ResolvedCredential[]; + readonly permissionHandles: ResolvedCredential[]; + readonly receivePackHandles: Array; +} + +const SUBJECT = 'seat-name'; +const CONTROL = 'read-only-control'; +const ESTATE = 'homelab'; +const HOST = 'git.example.invalid'; +const REPO = 'owner/repo'; + +function credential(identity: string, resolutionId: string): ResolvedCredential { + return Object.freeze({ + identity, + estate: ESTATE, + host: HOST, + resolutionId, + secret: new Uint8Array([99, 97, 110, 97, 114, 121]), + }); +} + +function fixture(options: FixtureOptions = {}): Fixture { + const subjectCredential = credential(SUBJECT, 'subject-resolution'); + const controlCredential = credential(CONTROL, 'control-resolution'); + const resolverCalls: string[] = []; + const identityHandles: ResolvedCredential[] = []; + const permissionHandles: ResolvedCredential[] = []; + const receivePackHandles: Array = []; + + const resolver: CredentialResolver = { + async resolve(identity: string): Promise { + resolverCalls.push(identity); + if (identity === SUBJECT) return subjectCredential; + if (identity === CONTROL && options.omitControl !== true) return controlCredential; + return undefined; + }, + }; + + const provider: GiteaCredentialProvider = { + async readIdentity(resolved: ResolvedCredential): Promise { + identityHandles.push(resolved); + const login = + resolved.identity === SUBJECT + ? (options.subjectProviderIdentity ?? SUBJECT) + : (options.controlProviderIdentity ?? CONTROL); + return { + login, + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async readRepositoryPermission( + resolved: ResolvedCredential, + ): Promise { + permissionHandles.push(resolved); + const effective = + resolved.identity === SUBJECT + ? (options.subjectPermission ?? 'write') + : (options.controlPermission ?? 'read'); + return { + effective, + endpoint: `GET /api/v1/repos/${REPO}`, + contentType: 'application/json', + }; + }, + async probeReceivePack(resolved: ResolvedCredential | undefined): Promise { + receivePackHandles.push(resolved); + if (resolved === undefined) { + return { + state: options.unauthenticatedTransportState ?? 'refused', + principal: null, + resolutionId: null, + contentType: 'text/plain', + }; + } + if (resolved.identity === SUBJECT) { + return { + state: options.subjectTransportState ?? 'advertised', + principal: options.subjectTransportPrincipal ?? SUBJECT, + resolutionId: options.subjectTransportResolutionId ?? resolved.resolutionId, + contentType: 'application/x-git-receive-pack-advertisement', + }; + } + return { + state: options.controlTransportState ?? 'refused', + principal: options.controlTransportPrincipal ?? CONTROL, + resolutionId: resolved.resolutionId, + contentType: 'text/plain', + }; + }, + }; + + return { + dependencies: { + resolver, + provider, + estateRegistry: { + matches(estate: string, host: string): boolean { + return estate === ESTATE && host === HOST; + }, + }, + }, + resolverCalls, + identityHandles, + permissionHandles, + receivePackHandles, + }; +} + +async function validate(options: FixtureOptions = {}): Promise<{ + readonly result: Awaited>; + readonly observed: Fixture; +}> { + const observed = fixture(options); + const result = await evaluateGiteaWriteValidation( + { + identity: SUBJECT, + estate: ESTATE, + host: HOST, + repo: REPO, + readOnlyControlIdentity: CONTROL, + }, + observed.dependencies, + ); + return { result, observed }; +} + +describe('Gitea read validation', (): void => { + it('reads the explicit provider identity and repository permission without a write control', async (): Promise => { + const observed = fixture({ subjectPermission: 'read' }); + const result = await evaluateGiteaReadValidation( + { identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO }, + observed.dependencies, + ); + + expect(result.outcome).toBe('ok'); + expect(result.evidence.providerIdentity?.login).toBe(SUBJECT); + expect(result.evidence.repositoryPermission?.effective).toBe('read'); + expect(result.evidence.writeDifferential).toBeNull(); + expect(observed.resolverCalls).toEqual([SUBJECT]); + }); + + it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise => { + const observed = fixture({ subjectPermission: 'read' }); + observed.dependencies.provider.readIdentity = async (): Promise => { + throw new CredentialProviderEvidenceError( + 'credential-rejected', + 'provider rejected the supplied credential', + ); + }; + const result = await evaluateGiteaReadValidation( + { identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO }, + observed.dependencies, + ); + + expect(result.outcome).toBe('refused'); + expect(result.exitCode).toBe(10); + expect(result.reason.code).toBe('credential-rejected'); + }); + + it('confirms in-scope capability while reporting identity as not measured', async (): Promise => { + const observed = fixture({ subjectPermission: 'write' }); + observed.dependencies.provider.readIdentity = async (): Promise => { + throw new CredentialProviderEvidenceError( + 'identity-read-forbidden', + 'identity endpoint requires a scope this token does not hold', + ); + }; + const result = await evaluateGiteaReadValidation( + { identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO }, + observed.dependencies, + ); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('identity-not-measured'); + expect(result.evidence.providerIdentity).toBeNull(); + expect(result.evidence.repositoryPermission?.effective).toBe('write'); + }); + + it('refuses a shared fallback rather than reporting a different principal as the subject', async (): Promise => { + const observed = fixture({ + subjectProviderIdentity: 'shared-owner', + subjectPermission: 'read', + }); + const result = await evaluateGiteaReadValidation( + { identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO }, + observed.dependencies, + ); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('provider-identity-mismatch'); + }); +}); + +describe('principal-bound Gitea write validation contract v1.1', (): void => { + it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise => { + const { result, observed } = await validate(); + + expect(result.outcome).toBe('ok'); + expect(observed.resolverCalls).toEqual([SUBJECT, CONTROL]); + expect(observed.identityHandles[0]).toBe(observed.permissionHandles[0]); + expect(observed.identityHandles[0]).toBe(observed.receivePackHandles[0]); + }); + + it('refuses a subject credential whose provider identity is a shared fallback', async (): Promise => { + const { result } = await validate({ subjectProviderIdentity: 'shared-owner' }); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('provider-identity-mismatch'); + expect(result.mutation).toBe('none'); + }); + + it('routes a transport principal mismatch to indeterminate, not refused', async (): Promise => { + const { result } = await validate({ subjectTransportPrincipal: 'shared-owner' }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('transport-principal-mismatch'); + }); + + it('routes a transport credential-handle mismatch to indeterminate', async (): Promise => { + const { result } = await validate({ subjectTransportResolutionId: 'fallback-resolution' }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('transport-principal-mismatch'); + }); + + it('refuses when the provider repository object authoritatively denies write', async (): Promise => { + const { result } = await validate({ subjectPermission: 'read' }); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('permission-denied'); + }); + + it('is indeterminate when repo permission says write but receive-pack refuses', async (): Promise => { + const { result } = await validate({ subjectTransportState: 'refused' }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('permission-evidence-disagrees'); + }); + + it('makes a write-capable read-only control invalidate the entire result', async (): Promise => { + const { result } = await validate({ controlPermission: 'write' }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('read-only-control-invalid'); + }); + + it('makes an identity-mismatched read-only control invalidate the entire result', async (): Promise => { + const { result } = await validate({ controlProviderIdentity: 'other-control' }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('read-only-control-invalid'); + }); + + it('makes a read-only control that receives write transport invalidate the result', async (): Promise => { + const { result } = await validate({ controlTransportState: 'advertised' }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('read-only-control-invalid'); + }); + + it('is indeterminate when the configured read-only control credential is absent', async (): Promise => { + const { result } = await validate({ omitControl: true }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('read-only-control-invalid'); + }); + + it('keeps the unauthenticated arm and rejects an advertisement there', async (): Promise => { + const { result } = await validate({ unauthenticatedTransportState: 'advertised' }); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('permission-evidence-disagrees'); + }); + + it('refuses an estate-host mismatch before resolving any credential', async (): Promise => { + const observed = fixture(); + const result = await evaluateGiteaWriteValidation( + { + identity: SUBJECT, + estate: 'usc', + host: HOST, + repo: REPO, + readOnlyControlIdentity: CONTROL, + }, + observed.dependencies, + ); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('estate-host-mismatch'); + expect(observed.resolverCalls).toEqual([]); + }); + + it('returns structured proof bounds only after every principal-bound arm passes', async (): Promise => { + const { result } = await validate(); + + expect(result.outcome).toBe('ok'); + expect(result.evidence.writeDifferential).toMatchObject({ + state: 'can-write', + credentialBinding: 'same-resolution', + transportPrincipal: SUBJECT, + authenticatedReceivePack: 'advertised', + readOnlyControl: { + identity: CONTROL, + providerPermission: 'read', + receivePack: 'refused', + }, + unauthenticatedReceivePack: 'refused', + artifactCreated: false, + }); + expect(result.evidence.writeDifferential?.proves).toContain('declared subject credential'); + expect(result.evidence.writeDifferential?.doesNotProve).toContain('branch protection'); + }); +}); diff --git a/packages/mosaic/src/credentials/validate.ts b/packages/mosaic/src/credentials/validate.ts new file mode 100644 index 00000000..e276f447 --- /dev/null +++ b/packages/mosaic/src/credentials/validate.ts @@ -0,0 +1,477 @@ +import { CredentialProviderEvidenceError } from './gitea-provider.js'; +import type { + CredentialValidationDependencies, + GiteaCredentialProvider, + GiteaReadValidationRequestDto, + GiteaWriteValidationRequestDto, + ResolvedCredential, +} from './credential-provider.dto.js'; +import type { + CredentialOutcome, + CredentialReasonDto, + CredentialValidationEvidenceDto, + CredentialValidationResultDto, + ProviderIdentityEvidenceDto, + ReceivePackEvidenceDto, + RepositoryPermissionEvidenceDto, + WriteDifferentialEvidenceDto, +} from './credential-result.dto.js'; + +export type { + CredentialResolver, + CredentialValidationDependencies, + GiteaCredentialProvider, + GiteaReadValidationRequestDto, + GiteaWriteValidationRequestDto, + ResolvedCredential, +} from './credential-provider.dto.js'; +export type { + ProviderIdentityEvidenceDto as ProviderIdentityEvidence, + ReceivePackEvidenceDto as ReceivePackEvidence, + RepositoryPermissionEvidenceDto as RepositoryPermissionEvidence, +} from './credential-result.dto.js'; + +const JSON_CONTENT_TYPE = 'application/json'; +const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement'; + +interface ResultOptions { + readonly outcome: CredentialOutcome; + readonly code: string; + readonly message: string; + readonly retryable?: boolean; + readonly evidence?: CredentialValidationEvidenceDto; +} + +function subject(request: GiteaReadValidationRequestDto): CredentialValidationResultDto['subject'] { + return { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }; +} + +function result( + request: GiteaReadValidationRequestDto, + options: ResultOptions, +): CredentialValidationResultDto { + const exits: Readonly> = { + ok: 0, + refused: 10, + error: 20, + indeterminate: 30, + }; + return { + schemaVersion: 1, + operation: 'validate', + outcome: options.outcome, + exitCode: exits[options.outcome], + retryable: options.retryable ?? false, + subject: subject(request), + mutation: 'none', + reason: { code: options.code, message: options.message }, + evidence: options.evidence ?? { + providerIdentity: null, + repositoryPermission: null, + writeDifferential: null, + }, + audit: { journalId: null, state: 'not-started' }, + }; +} + +function refused( + request: GiteaReadValidationRequestDto, + reason: CredentialReasonDto, + evidence?: CredentialValidationEvidenceDto, +): CredentialValidationResultDto { + return result(request, { + outcome: 'refused', + code: reason.code, + message: reason.message, + ...(evidence === undefined ? {} : { evidence }), + }); +} + +function indeterminate( + request: GiteaReadValidationRequestDto, + reason: CredentialReasonDto, + evidence?: CredentialValidationEvidenceDto, +): CredentialValidationResultDto { + return result(request, { + outcome: 'indeterminate', + code: reason.code, + message: reason.message, + ...(evidence === undefined ? {} : { evidence }), + }); +} + +function providerEvidenceFailure( + request: GiteaReadValidationRequestDto, + error: CredentialProviderEvidenceError, +): CredentialValidationResultDto { + if (error.code === 'credential-rejected') { + return refused(request, { + code: error.code, + message: 'The provider authoritatively rejected the supplied subject credential.', + }); + } + return indeterminate(request, { + code: error.code, + message: 'Provider evidence could not be evaluated completely.', + }); +} + +function identityContentTypeValid(evidence: ProviderIdentityEvidenceDto): boolean { + return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE); +} + +function permissionContentTypeValid(evidence: RepositoryPermissionEvidenceDto): boolean { + return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE); +} + +function advertised(evidence: ReceivePackEvidenceDto): boolean { + return ( + evidence.state === 'advertised' && + evidence.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE) + ); +} + +async function resolveCredential( + request: GiteaWriteValidationRequestDto, + identity: string, + dependencies: CredentialValidationDependencies, +): Promise { + return dependencies.resolver.resolve(identity, request.estate, request.host); +} + +async function readSubjectEvidence( + request: GiteaWriteValidationRequestDto, + resolved: ResolvedCredential, + provider: GiteaCredentialProvider, +): Promise<{ + readonly identity: ProviderIdentityEvidenceDto; + readonly permission: RepositoryPermissionEvidenceDto; + readonly receivePack: ReceivePackEvidenceDto; +}> { + const identity = await provider.readIdentity(resolved); + const permission = await provider.readRepositoryPermission(resolved, request.repo); + const receivePack = await provider.probeReceivePack(resolved, request.repo); + return { identity, permission, receivePack }; +} + +function successfulEvidence( + subjectIdentity: ProviderIdentityEvidenceDto, + subjectPermission: RepositoryPermissionEvidenceDto, + subjectReceivePack: ReceivePackEvidenceDto, + controlIdentity: ProviderIdentityEvidenceDto, + controlPermission: RepositoryPermissionEvidenceDto, + controlReceivePack: ReceivePackEvidenceDto, +): CredentialValidationEvidenceDto { + const writeDifferential: WriteDifferentialEvidenceDto = { + state: 'can-write', + credentialBinding: 'same-resolution', + transportPrincipal: subjectIdentity.login, + authenticatedReceivePack: 'advertised', + readOnlyControl: { + identity: controlIdentity.login, + providerPermission: controlPermission.effective, + receivePack: controlReceivePack.state, + }, + unauthenticatedReceivePack: 'refused', + artifactCreated: false, + proves: + 'The declared subject credential authenticated provider identity, repository permission, and write transport while a distinct provider-confirmed read-only principal and an unauthenticated caller were refused.', + doesNotProve: + 'A particular ref update will pass branch protection, hooks, races, or content policy.', + }; + return { + providerIdentity: subjectIdentity, + repositoryPermission: subjectPermission, + writeDifferential, + }; +} + +async function evaluateGiteaReadValidationUnsafe( + request: GiteaReadValidationRequestDto, + dependencies: CredentialValidationDependencies, +): Promise { + if (!dependencies.estateRegistry.matches(request.estate, request.host)) { + return refused(request, { + code: 'estate-host-mismatch', + message: 'The declared estate does not contain the declared host.', + }); + } + const resolved = await dependencies.resolver.resolve( + request.identity, + request.estate, + request.host, + ); + if (resolved === undefined) { + return refused(request, { + code: 'no-token-for-identity', + message: 'The explicit identity has no credential in the declared estate.', + }); + } + let providerIdentity: ProviderIdentityEvidenceDto | null; + try { + providerIdentity = await dependencies.provider.readIdentity(resolved); + } catch (error: unknown) { + if ( + error instanceof CredentialProviderEvidenceError && + error.code === 'identity-read-forbidden' + ) { + const repositoryPermission = await dependencies.provider.readRepositoryPermission( + resolved, + request.repo, + ); + const evidence: CredentialValidationEvidenceDto = { + providerIdentity: null, + repositoryPermission, + writeDifferential: null, + }; + if (!permissionContentTypeValid(repositoryPermission)) { + return indeterminate( + request, + { + code: 'unexpected-content-type', + message: 'In-scope capability evidence was not JSON.', + }, + evidence, + ); + } + return indeterminate( + request, + { + code: 'identity-not-measured', + message: + 'Repository capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.', + }, + evidence, + ); + } + throw error; + } + const repositoryPermission = await dependencies.provider.readRepositoryPermission( + resolved, + request.repo, + ); + const evidence: CredentialValidationEvidenceDto = { + providerIdentity, + repositoryPermission, + writeDifferential: null, + }; + if ( + !identityContentTypeValid(providerIdentity) || + !permissionContentTypeValid(repositoryPermission) + ) { + return indeterminate( + request, + { + code: 'unexpected-content-type', + message: 'Provider read evidence was not JSON.', + }, + evidence, + ); + } + if (providerIdentity.login !== request.identity) { + return refused( + request, + { + code: 'provider-identity-mismatch', + message: 'The provider credential identity does not equal the declared subject.', + }, + evidence, + ); + } + return result(request, { + outcome: 'ok', + code: 'validation-verified', + message: 'Provider identity and repository permission were read back.', + evidence, + }); +} + +export async function evaluateGiteaReadValidation( + request: GiteaReadValidationRequestDto, + dependencies: CredentialValidationDependencies, +): Promise { + try { + return await evaluateGiteaReadValidationUnsafe(request, dependencies); + } catch (error: unknown) { + if (error instanceof CredentialProviderEvidenceError) { + return providerEvidenceFailure(request, error); + } + throw error; + } +} + +async function evaluateGiteaWriteValidationUnsafe( + request: GiteaWriteValidationRequestDto, + dependencies: CredentialValidationDependencies, +): Promise { + if (!dependencies.estateRegistry.matches(request.estate, request.host)) { + return refused(request, { + code: 'estate-host-mismatch', + message: 'The declared estate does not contain the declared host.', + }); + } + + const resolved = await resolveCredential(request, request.identity, dependencies); + if (resolved === undefined) { + return refused(request, { + code: 'no-token-for-identity', + message: 'The explicit identity has no credential in the declared estate.', + }); + } + + const subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); + const baseEvidence: CredentialValidationEvidenceDto = { + providerIdentity: subjectEvidence.identity, + repositoryPermission: subjectEvidence.permission, + writeDifferential: null, + }; + + if (!identityContentTypeValid(subjectEvidence.identity)) { + return indeterminate( + request, + { + code: 'unexpected-content-type', + message: 'The provider identity response was not JSON.', + }, + baseEvidence, + ); + } + if (subjectEvidence.identity.login !== request.identity) { + return refused( + request, + { + code: 'provider-identity-mismatch', + message: 'The provider credential identity does not equal the declared subject.', + }, + baseEvidence, + ); + } + if (!permissionContentTypeValid(subjectEvidence.permission)) { + return indeterminate( + request, + { + code: 'unexpected-content-type', + message: 'The provider repository response was not JSON.', + }, + baseEvidence, + ); + } + if (subjectEvidence.permission.effective === 'read') { + return refused( + request, + { + code: 'permission-denied', + message: 'The provider repository object denies write permission.', + }, + baseEvidence, + ); + } + if ( + subjectEvidence.receivePack.principal !== request.identity || + subjectEvidence.receivePack.resolutionId !== resolved.resolutionId + ) { + return indeterminate( + request, + { + code: 'transport-principal-mismatch', + message: 'The write transport evidence is not bound to the declared subject credential.', + }, + baseEvidence, + ); + } + if (!advertised(subjectEvidence.receivePack)) { + return indeterminate( + request, + { + code: 'permission-evidence-disagrees', + message: 'Repository permission and write transport evidence disagree.', + }, + baseEvidence, + ); + } + + const control = await resolveCredential(request, request.readOnlyControlIdentity, dependencies); + if (control === undefined) { + return indeterminate(request, { + code: 'read-only-control-invalid', + message: 'The configured read-only control credential could not be resolved.', + }); + } + const controlIdentity = await dependencies.provider.readIdentity(control); + const controlPermission = await dependencies.provider.readRepositoryPermission( + control, + request.repo, + ); + const controlReceivePack = await dependencies.provider.probeReceivePack(control, request.repo); + + const controlIsDistinct = + request.readOnlyControlIdentity !== request.identity && + control.resolutionId !== resolved.resolutionId; + const controlIdentityMatches = + identityContentTypeValid(controlIdentity) && + controlIdentity.login === request.readOnlyControlIdentity; + const controlPermissionIsReadOnly = + permissionContentTypeValid(controlPermission) && controlPermission.effective === 'read'; + const controlTransportIsBoundAndRefused = + controlReceivePack.state === 'refused' && + controlReceivePack.principal === request.readOnlyControlIdentity && + controlReceivePack.resolutionId === control.resolutionId && + !controlReceivePack.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE); + if ( + !controlIsDistinct || + !controlIdentityMatches || + !controlPermissionIsReadOnly || + !controlTransportIsBoundAndRefused + ) { + return indeterminate(request, { + code: 'read-only-control-invalid', + message: + 'The read-only control was absent, identity-mismatched, write-capable, unbound, or admitted to write transport.', + }); + } + + const unauthenticated = await dependencies.provider.probeReceivePack(undefined, request.repo); + if ( + unauthenticated.state !== 'refused' || + unauthenticated.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE) + ) { + return indeterminate(request, { + code: 'permission-evidence-disagrees', + message: 'The unauthenticated write-transport control was not refused.', + }); + } + + const evidence = successfulEvidence( + subjectEvidence.identity, + subjectEvidence.permission, + subjectEvidence.receivePack, + controlIdentity, + controlPermission, + controlReceivePack, + ); + return result(request, { + outcome: 'ok', + code: 'validation-verified', + message: 'Every required provider evidence layer agreed.', + evidence, + }); +} + +export async function evaluateGiteaWriteValidation( + request: GiteaWriteValidationRequestDto, + dependencies: CredentialValidationDependencies, +): Promise { + try { + return await evaluateGiteaWriteValidationUnsafe(request, dependencies); + } catch (error: unknown) { + if (error instanceof CredentialProviderEvidenceError) { + return providerEvidenceFailure(request, error); + } + throw error; + } +}