From 1e58e6c74a18338656345bba99b15f544b808df1 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:07:29 -0500 Subject: [PATCH 01/13] 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; + } +} -- 2.54.0 From 2419313286b5e127401ddaa3d94a433c0033fbc1 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:14:54 -0500 Subject: [PATCH 02/13] fix(mosaic): preserve grant safety invariants --- docs/credentials/GRANT-VALIDATE-CONTRACT.md | 2 +- packages/mosaic/src/commands/cred.ts | 17 +- .../credentials/credential-provider.dto.ts | 2 + .../credentials/delegated-credential.spec.ts | 23 ++ .../src/credentials/delegated-credential.ts | 4 + .../src/credentials/gitea-provider.spec.ts | 26 +++ .../mosaic/src/credentials/gitea-provider.ts | 88 +++++++- packages/mosaic/src/credentials/grant.spec.ts | 41 ++++ packages/mosaic/src/credentials/grant.ts | 209 +++++++++++------- .../mosaic/src/credentials/team-grant.spec.ts | 84 +++++++ packages/mosaic/src/credentials/team-grant.ts | 179 ++++++++++----- .../mosaic/src/credentials/validate.spec.ts | 21 ++ packages/mosaic/src/credentials/validate.ts | 10 + 13 files changed, 562 insertions(+), 144 deletions(-) diff --git a/docs/credentials/GRANT-VALIDATE-CONTRACT.md b/docs/credentials/GRANT-VALIDATE-CONTRACT.md index ee45eb72..10133059 100644 --- a/docs/credentials/GRANT-VALIDATE-CONTRACT.md +++ b/docs/credentials/GRANT-VALIDATE-CONTRACT.md @@ -193,4 +193,4 @@ No ref is updated and no repository artifact is created. This proves that the de ## 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. +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 enumerates the team's complete repository attachment set before mutation and refuses any team already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). It then 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/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 5388e717..6b7fcb84 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -13,7 +13,10 @@ import type { RepositoryPermission, } from '../credentials/credential-result.dto.js'; import { readDelegatedCredentialFromFd } from '../credentials/delegated-credential.js'; -import { grantDirectRepositoryPermission } from '../credentials/grant.js'; +import { + CredentialGrantExecutionError, + 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'; @@ -125,10 +128,11 @@ export async function executeCredentialValidate( host: options.host, repo: options.repo, readOnlyControlIdentity: options.readOnlyControl ?? '(unresolved)', + requiredPermission: options.require as RepositoryPermission, }; try { - if (options.require !== 'read' && options.require !== 'write') { + if (!['read', 'write', 'admin'].includes(options.require)) { return errorResult(request, 'invalid-input'); } const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath)); @@ -171,6 +175,8 @@ function grantErrorResult( identity: string, options: CredentialGrantCommandOptions, code: string, + mutation: 'none' | 'unknown' | 'applied' = 'none', + journalId: string | null = null, ): CredentialGrantResultDto { return { schemaVersion: 1, @@ -179,7 +185,7 @@ function grantErrorResult( exitCode: 20, retryable: false, subject: { identity, estate: options.estate, host: options.host, repo: options.repo }, - mutation: 'none', + mutation, reason: { code, message: 'The local grant control failed before an access verdict was available.', @@ -191,7 +197,7 @@ function grantErrorResult( collaboratorPermission: null, organizationMembership: null, }, - audit: { journalId: null, state: 'not-started' }, + audit: { journalId, state: journalId === null ? 'not-started' : 'open' }, }; } @@ -261,6 +267,9 @@ export async function executeCredentialGrant( serviceOptions, ); } catch (error: unknown) { + if (error instanceof CredentialGrantExecutionError) { + return grantErrorResult(identity, options, error.code, error.mutation, error.journalId); + } if ( error instanceof CredentialJournalError || error instanceof CredentialEstateRegistryError || diff --git a/packages/mosaic/src/credentials/credential-provider.dto.ts b/packages/mosaic/src/credentials/credential-provider.dto.ts index 9970a689..a7807965 100644 --- a/packages/mosaic/src/credentials/credential-provider.dto.ts +++ b/packages/mosaic/src/credentials/credential-provider.dto.ts @@ -1,6 +1,7 @@ import type { ProviderIdentityEvidenceDto, ReceivePackEvidenceDto, + RepositoryPermission, RepositoryPermissionEvidenceDto, } from './credential-result.dto.js'; @@ -43,6 +44,7 @@ export interface GiteaReadValidationRequestDto { readonly estate: string; readonly host: string; readonly repo: string; + readonly requiredPermission?: RepositoryPermission; } export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto { diff --git a/packages/mosaic/src/credentials/delegated-credential.spec.ts b/packages/mosaic/src/credentials/delegated-credential.spec.ts index 0f62c1ed..cac708d8 100644 --- a/packages/mosaic/src/credentials/delegated-credential.spec.ts +++ b/packages/mosaic/src/credentials/delegated-credential.spec.ts @@ -39,6 +39,29 @@ describe('protected delegated credential channel', (): void => { } }); + it('rejects a regular-file authority fd with group or other access', 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: 0o644 }, + ); + const handle = await open(path, 'r'); + try { + await expect( + readDelegatedCredentialFromFd(handle.fd, 'provisioner', 'homelab', 'git.example.invalid'), + ).rejects.toMatchObject({ code: 'delegated-authority-unavailable' }); + } 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'); diff --git a/packages/mosaic/src/credentials/delegated-credential.ts b/packages/mosaic/src/credentials/delegated-credential.ts index e2abc423..84bca6ec 100644 --- a/packages/mosaic/src/credentials/delegated-credential.ts +++ b/packages/mosaic/src/credentials/delegated-credential.ts @@ -42,6 +42,10 @@ export async function readDelegatedCredentialFromFd( if (!stat.isFile() && !stat.isFIFO()) { throw new Error('fd is not a regular file or pipe'); } + const currentUid = process.getuid?.(); + if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) { + throw new Error('fd owner or permissions are unsafe'); + } bytes = await readFile(`/proc/self/fd/${fd}`); } catch { throw new DelegatedCredentialError( diff --git a/packages/mosaic/src/credentials/gitea-provider.spec.ts b/packages/mosaic/src/credentials/gitea-provider.spec.ts index 9384f872..301a3677 100644 --- a/packages/mosaic/src/credentials/gitea-provider.spec.ts +++ b/packages/mosaic/src/credentials/gitea-provider.spec.ts @@ -141,6 +141,32 @@ describe('Gitea credential provider transport', (): void => { }); }); + it('cancels an undeclared oversized streaming provider response before buffering it all', async (): Promise => { + let pulls = 0; + let cancelled = false; + const stream = new ReadableStream({ + pull(controller): void { + pulls += 1; + controller.enqueue(new Uint8Array(64 * 1024)); + if (pulls === 100) controller.close(); + }, + cancel(): void { + cancelled = true; + }, + }); + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => + new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }), + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'unexpected-provider-shape', + }); + expect(pulls).toBeLessThan(100); + expect(cancelled).toBe(true); + }); + it('rejects a 200 HTML identity response as unexpected content type', async (): Promise => { const adapter = new GiteaCredentialProviderAdapter( 'https://git.example.invalid', diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index cc00101a..8ce52a3f 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -4,6 +4,7 @@ import type { GiteaGrantProvider } from './grant.js'; import type { GiteaTeamGrantProvider, PresenceEvidence, + TeamRepositorySetEvidence, TeamResolutionEvidence, } from './team-grant.js'; import type { @@ -85,21 +86,53 @@ function isJson(response: Response): boolean { 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) { + if (!/^\d+$/.test(declared)) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider response declared an invalid content length', + ); + } + const bytes = Number(declared); + if (!Number.isSafeInteger(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) { + if (response.body === null) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_PROVIDER_BYTES) { + await reader.cancel(); + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider response exceeded the bounded size', + ); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + if (declared !== null && total !== Number(declared)) { throw new CredentialProviderEvidenceError( 'unexpected-provider-shape', - 'provider response exceeded the bounded size', + 'provider response length contradicted its declaration', ); } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } return body; } @@ -486,6 +519,51 @@ export class GiteaTeamGrantProviderAdapter return { ...matches[0], endpoint, contentType: contentType(response) }; } + async listTeamRepositories( + authority: ResolvedCredential, + teamId: number, + ): Promise { + const endpoint = `GET /api/v1/teams/${teamId.toString()}/repos`; + const repositories: string[] = []; + let observedType = ''; + for (let page = 1; page <= 100; page += 1) { + const response = await this.request( + `${this.origin}/api/v1/teams/${teamId.toString()}/repos?limit=50&page=${page.toString()}`, + { + 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', + 'team repository set was unavailable', + ); + } + observedType = contentType(response); + const parsed = z.array(repoSchema).safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'team repository set was not a repository array', + ); + } + repositories.push(...parsed.data.map((repo): string => repo.full_name)); + if (parsed.data.length < 50) { + return { repositories, endpoint, contentType: observedType }; + } + } + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'team repository set exceeded the pagination bound', + ); + } + async addTeamMember( authority: ResolvedCredential, teamId: number, diff --git a/packages/mosaic/src/credentials/grant.spec.ts b/packages/mosaic/src/credentials/grant.spec.ts index cbb49bf5..26838b90 100644 --- a/packages/mosaic/src/credentials/grant.spec.ts +++ b/packages/mosaic/src/credentials/grant.spec.ts @@ -132,6 +132,47 @@ describe('direct repository grant', (): void => { expect(result.audit.state).toBe('sealed'); }); + it('preserves applied mutation and journal context when post-grant read-back fails', 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() { + throw new Error('read-back unavailable'); + }, + async readOrganizationMembership() { + throw new Error('must not be reached'); + }, + }; + + 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('indeterminate'); + expect(result.mutation).toBe('applied'); + expect(result.reason.code).toBe('readback-missing'); + expect(result.audit.journalId).not.toBeNull(); + 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 = { diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index dbb3d95d..fa63e38a 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -1,4 +1,4 @@ -import { CredentialAuditJournal } from './audit-journal.js'; +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; import type { CredentialValidationDependencies, ResolvedCredential, @@ -35,6 +35,17 @@ export interface GiteaGrantProvider { ): Promise; } +export class CredentialGrantExecutionError extends Error { + constructor( + public readonly code: string, + public readonly mutation: 'none' | 'unknown' | 'applied', + public readonly journalId: string, + ) { + super(`Credential grant control failed: code=${code}`); + this.name = 'CredentialGrantExecutionError'; + } +} + export interface CredentialGrantServiceOptions { readonly stateRoot: string; readonly actor: string; @@ -63,14 +74,86 @@ export async function grantDirectRepositoryPermission( 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'); + let mutation: 'none' | 'unknown' | 'applied' = 'none'; + try { + 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' }, + }; + } + mutation = 'unknown'; + await grantProvider.grantCollaborator( + authority, + request.identity, + request.repo, + request.permission, + ); + mutation = 'applied'; + + 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: 'refused', - exitCode: 10, + outcome, + exitCode: exitFor(outcome), retryable: false, subject: { identity: request.identity, @@ -78,13 +161,52 @@ export async function grantDirectRepositoryPermission( host: request.host, repo: request.repo, }, - mutation: 'none', + mutation: 'applied', reason: { - code: 'provider-identity-mismatch', - message: 'Delegated grant authority did not authenticate as the explicit audit actor.', + code: reasonCode, + message: readBackMatches + ? 'Grant matched every required provider read-back.' + : 'Grant mutation completed but provider permission evidence disagreed.', }, evidence: { - providerIdentity: authorityIdentity, + ...validation.evidence, + collaboratorPermission: collaborator, + organizationMembership, + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; + } catch (error: unknown) { + if (error instanceof CredentialJournalError) { + throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId()); + } + const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown'; + try { + await journal.seal('indeterminate', reasonCode); + } catch (journalError: unknown) { + if (journalError instanceof CredentialJournalError) { + throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId()); + } + throw journalError; + } + return { + schemaVersion: 1, + operation: 'grant', + outcome: 'indeterminate', + exitCode: 30, + retryable: false, + subject: { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }, + mutation, + reason: { + code: reasonCode, + message: 'Grant mutation state was preserved after provider evidence failed.', + }, + evidence: { + providerIdentity: null, repositoryPermission: null, writeDifferential: null, collaboratorPermission: null, @@ -93,71 +215,4 @@ export async function grantDirectRepositoryPermission( 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 index c16e9cbb..b5da30ae 100644 --- a/packages/mosaic/src/credentials/team-grant.spec.ts +++ b/packages/mosaic/src/credentials/team-grant.spec.ts @@ -90,6 +90,13 @@ describe('team repository grant', (): void => { contentType: 'application/json', }; }, + async listTeamRepositories() { + return { + repositories: [], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, async addTeamMember(): Promise {}, async attachTeamRepository(): Promise {}, async readTeamMember() { @@ -136,4 +143,81 @@ describe('team repository grant', (): void => { expect(result.evidence.teamMembership?.state).toBe('present'); expect(result.evidence.teamRepository?.state).toBe('present'); }); + + it('refuses a shared team already attached to any repository outside the request', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); + let mutated = false; + 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 listTeamRepositories() { + return { + repositories: ['owner/unrelated'], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, + async addTeamMember(): Promise { + mutated = true; + }, + async attachTeamRepository(): Promise { + mutated = true; + }, + async readTeamMember() { + return { + state: 'absent', + endpoint: 'GET /api/v1/teams/7/members/seat-name', + contentType: 'application/json', + }; + }, + async readTeamRepository() { + return { + state: 'absent', + endpoint: 'GET /api/v1/teams/7/repos/owner/repo', + contentType: 'application/json', + }; + }, + async readOrganizationMembership() { + return { + state: 'absent', + 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('refused'); + expect(result.reason.code).toBe('team-scope-exceeds-request'); + expect(mutated).toBe(false); + }); }); diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts index 7d0f2c76..30c8d80a 100644 --- a/packages/mosaic/src/credentials/team-grant.ts +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -1,4 +1,4 @@ -import { CredentialAuditJournal } from './audit-journal.js'; +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; import type { CredentialValidationDependencies, ResolvedCredential, @@ -9,6 +9,7 @@ import type { OrganizationMembershipEvidenceDto, } from './grant.dto.js'; import type { RepositoryPermission } from './credential-result.dto.js'; +import { CredentialGrantExecutionError } from './grant.js'; import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js'; export interface TeamResolutionEvidence { @@ -23,6 +24,11 @@ export interface PresenceEvidence { readonly endpoint: string; readonly contentType: string; } +export interface TeamRepositorySetEvidence { + readonly repositories: readonly string[]; + readonly endpoint: string; + readonly contentType: string; +} export interface TeamGrantRequest extends DirectGrantRequestDto { readonly team: string; } @@ -31,6 +37,7 @@ export interface TeamGrantResult extends CredentialGrantResultDto { readonly team: TeamResolutionEvidence | null; readonly teamMembership: PresenceEvidence | null; readonly teamRepository: PresenceEvidence | null; + readonly teamRepositorySet: TeamRepositorySetEvidence | null; }; } export interface GiteaTeamGrantProvider { @@ -42,6 +49,10 @@ export interface GiteaTeamGrantProvider { organization: string, team: string, ): Promise; + listTeamRepositories( + authority: ResolvedCredential, + teamId: number, + ): Promise; addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise; attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise; readTeamMember( @@ -80,17 +91,116 @@ export async function grantTeamRepositoryPermission( 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'); + let mutation: 'none' | 'unknown' | 'applied' = 'none'; + try { + 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, + null, + ); + } + const teamRepositorySet = await provider.listTeamRepositories(authority, team.id); + if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) { + await journal.seal('refused', 'team-scope-exceeds-request'); + return result( + request, + journal, + 'refused', + 'none', + 'team-scope-exceeds-request', + null, + team, + null, + null, + null, + teamRepositorySet, + ); + } + mutation = 'unknown'; + await provider.addTeamMember(authority, team.id, request.identity); + mutation = 'applied'; + 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, - 'refused', - 'none', - 'provider-identity-mismatch', + ok ? 'ok' : 'indeterminate', + 'applied', + ok ? 'grant-verified' : 'permission-evidence-disagrees', + validation, + team, + teamMembership, + teamRepository, + organizationMembership, + teamRepositorySet, + ); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) { + throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId()); + } + const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown'; + try { + await journal.seal('indeterminate', reasonCode); + } catch (journalError: unknown) { + if (journalError instanceof CredentialJournalError) { + throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId()); + } + throw journalError; + } + return result( + request, + journal, + 'indeterminate', + mutation, + reasonCode, + null, null, null, null, @@ -98,66 +208,20 @@ export async function grantTeamRepositoryPermission( 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', + mutation: 'none' | 'unknown' | 'applied', code: string, validation: Awaited> | null, team: TeamResolutionEvidence | null, teamMembership: PresenceEvidence | null, teamRepository: PresenceEvidence | null, organizationMembership: OrganizationMembershipEvidenceDto | null, + teamRepositorySet: TeamRepositorySetEvidence | null, ): TeamGrantResult { return { schemaVersion: 1, @@ -188,6 +252,7 @@ function result( team, teamMembership, teamRepository, + teamRepositorySet, }, audit: { journalId: journal.journalId(), state: 'sealed' }, }; diff --git a/packages/mosaic/src/credentials/validate.spec.ts b/packages/mosaic/src/credentials/validate.spec.ts index 57d407ae..30705b9f 100644 --- a/packages/mosaic/src/credentials/validate.spec.ts +++ b/packages/mosaic/src/credentials/validate.spec.ts @@ -24,6 +24,7 @@ interface FixtureOptions { readonly controlTransportPrincipal?: string; readonly unauthenticatedTransportState?: 'advertised' | 'refused'; readonly omitControl?: boolean; + readonly requiredPermission?: 'read' | 'write' | 'admin'; } interface Fixture { @@ -150,6 +151,7 @@ async function validate(options: FixtureOptions = {}): Promise<{ host: HOST, repo: REPO, readOnlyControlIdentity: CONTROL, + requiredPermission: options.requiredPermission, }, observed.dependencies, ); @@ -269,6 +271,25 @@ describe('principal-bound Gitea write validation contract v1.1', (): void => { expect(result.reason.code).toBe('permission-evidence-disagrees'); }); + it('refuses write permission when admin permission is explicitly required', async (): Promise => { + const { result } = await validate({ + requiredPermission: 'admin', + subjectPermission: 'write', + }); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('permission-denied'); + }); + + it('accepts admin permission when admin is explicitly required', async (): Promise => { + const { result } = await validate({ + requiredPermission: 'admin', + subjectPermission: 'admin', + }); + + expect(result.outcome).toBe('ok'); + }); + it('makes a write-capable read-only control invalidate the entire result', async (): Promise => { const { result } = await validate({ controlPermission: 'write' }); diff --git a/packages/mosaic/src/credentials/validate.ts b/packages/mosaic/src/credentials/validate.ts index e276f447..bdce5153 100644 --- a/packages/mosaic/src/credentials/validate.ts +++ b/packages/mosaic/src/credentials/validate.ts @@ -361,6 +361,16 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } + if (request.requiredPermission === 'admin' && subjectEvidence.permission.effective !== 'admin') { + return refused( + request, + { + code: 'permission-denied', + message: 'The provider repository object denies required admin permission.', + }, + baseEvidence, + ); + } if (subjectEvidence.permission.effective === 'read') { return refused( request, -- 2.54.0 From 050ac63737e1b3b8606fe944a8778c96d71a4d80 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:40:29 -0500 Subject: [PATCH 03/13] feat(mosaic): complete credential lifecycle and fail closed --- docs/scratchpads/1045-mosaic-cred.md | 8 +- .../framework/tools/git/detect-platform.sh | 13 +- .../framework/tools/git/git-credential-mosaic | 15 + .../tools/git/test-git-credential-mosaic.sh | 20 + .../tools/git/test-gitea-token-identity.sh | 30 +- packages/mosaic/src/commands/cred.ts | 574 +++++++++++++++++- .../src/credentials/audit-journal.dto.ts | 5 +- .../src/credentials/audit-journal.spec.ts | 4 +- .../mosaic/src/credentials/audit-journal.ts | 28 + .../src/credentials/credential-binding.dto.ts | 10 + .../src/credentials/credential-result.dto.ts | 2 +- .../credential-validate-service.ts | 1 + .../src/credentials/delegated-credential.ts | 30 +- .../credentials/file-credential-store.spec.ts | 49 +- .../src/credentials/file-credential-store.ts | 164 ++++- .../src/credentials/gitea-provider.spec.ts | 34 +- .../mosaic/src/credentials/gitea-provider.ts | 187 +++++- packages/mosaic/src/credentials/grant.spec.ts | 9 +- packages/mosaic/src/credentials/grant.ts | 34 ++ .../mosaic/src/credentials/lifecycle.dto.ts | 49 ++ .../mosaic/src/credentials/lifecycle.spec.ts | 143 +++++ packages/mosaic/src/credentials/lifecycle.ts | 272 +++++++++ .../mosaic/src/credentials/tea-login-store.ts | 127 ++++ packages/mosaic/src/credentials/team-grant.ts | 44 ++ .../mosaic/src/credentials/validate.spec.ts | 39 +- packages/mosaic/src/credentials/validate.ts | 80 ++- packages/mosaic/src/fleet/secure-file.ts | 4 + 27 files changed, 1946 insertions(+), 29 deletions(-) create mode 100644 packages/mosaic/src/credentials/credential-binding.dto.ts create mode 100644 packages/mosaic/src/credentials/lifecycle.dto.ts create mode 100644 packages/mosaic/src/credentials/lifecycle.spec.ts create mode 100644 packages/mosaic/src/credentials/lifecycle.ts create mode 100644 packages/mosaic/src/credentials/tea-login-store.ts diff --git a/docs/scratchpads/1045-mosaic-cred.md b/docs/scratchpads/1045-mosaic-cred.md index 90104a4e..3d4eafad 100644 --- a/docs/scratchpads/1045-mosaic-cred.md +++ b/docs/scratchpads/1045-mosaic-cred.md @@ -51,7 +51,7 @@ No explicit token cap supplied. Working cap: keep implementation in one package - [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). +- [ ] Implementation (validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, and fleet fail-closed resolver paths implemented; final review hardening and live provider lifecycle evidence open). - [ ] Independent code/security reviews. - [ ] CI and provider evidence. @@ -65,7 +65,9 @@ Red-first evidence: - 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. +Current focused evidence: 62/62 across 9 credential suites; package lint and typecheck green. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled. + +Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation. 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/`. @@ -75,5 +77,5 @@ Write differential for be-coder-06 passed with the configured read-only control - 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. +- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes. - Branch model compatibility remains escalated above this lane. Do not claim completion at `next`. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 97c6e5b3..214dbf67 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -534,12 +534,21 @@ get_gitea_token() { # would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed # to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the # caller aborts loudly rather than acting as the wrong identity. - echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2 - echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2 + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=%s path=%s\n' \ + "$_ident" "$host" "$_ident_src" "$_idtok" >&2 return 1 fi fi + # Fleet automation never borrows a shared human/default credential. An + # explicit interactive caller may still reach the shared paths below, but + # a fleet process must name an identity and resolve that identity exactly. + if [[ -n "${MOSAIC_AGENT_NAME:-}" ]]; then + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity= host=%s shared_path_entered=false source=MOSAIC_AGENT_NAME\n' \ + "$host" >&2 + return 1 + fi + # 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env) if [[ -f "$cred_loader" ]]; then local token diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 915fc7bc..b0bffc4c 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -45,8 +45,23 @@ if [ -n "$ident" ]; then echo "password=$(cat "$idtok")" exit 0 fi + if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=git-credential-mosaic path=%s\n' \ + "$ident" "$host" "$idtok" >&2 + exit 1 + fi fi fi +if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -z "$ident" ]; then + case "$host" in + git.uscllc.com|git.mosaicstack.dev) + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity= host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2 + exit 1 + ;; + esac +fi case "$host" in git.uscllc.com) svc=gitea-usc;; git.mosaicstack.dev) svc=gitea-mosaicstack;; diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index e84778b6..456d2f67 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -85,6 +85,16 @@ out=$(run_helper "git.mosaicstack.dev" "") assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')" assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" +set +e +out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-unset.stderr") +if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet unset identity did not stop at the resolver marker" >&2 + fail=1 +fi + # --------------------------------------------------------------------------- # 2. git-supplied username resolves to an identity WITH a per-slot token -> # that identity + token wins over the shared account. @@ -120,6 +130,16 @@ out=$(run_helper "git.mosaicstack.dev" "no-such-agent") assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')" assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" +set +e +out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-missing.stderr") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-missing.stderr") +if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=no-token-for-identity"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet missing token did not stop at the resolver marker" >&2 + fail=1 +fi + # --------------------------------------------------------------------------- # 6. Correct per-slot token PATH is chosen per host: same agent id, different # host prefix (gitea-usc- vs gitea-mosaicstack-). diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index d56e7bee..4c01116b 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -85,7 +85,23 @@ call_get_gitea_token() { # --------------------------------------------------------------------------- git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true out=$(call_get_gitea_token "git.mosaicstack.dev") -assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out" +assert_eq "interactive shared fallback (no identity)" "shared-mosaicstack-token" "$out" + +# Fleet context with no explicit identity refuses before the shared path. This +# is the marker-emission positive control for the fail-closed mechanism. +set +e +out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/stderr-fleet-unset.tmp") +rc=$? +set -e +err=$(cat "$WORK_DIR/stderr-fleet-unset.tmp") +if [[ "$rc" -eq 0 || -n "$out" ]]; then + echo "FAIL: fleet unset identity must refuse with empty stdout" >&2 + fail=1 +fi +if [[ "$err" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet unset identity did not emit the stable resolver refusal marker: $err" >&2 + fail=1 +fi # --------------------------------------------------------------------------- # 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot @@ -93,8 +109,8 @@ assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out" # --------------------------------------------------------------------------- echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token" git -C "$REPO_DIR" config mosaic.gitIdentity agentA -out=$(call_get_gitea_token "git.mosaicstack.dev") -assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out" +out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA) +assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out" # --------------------------------------------------------------------------- # 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. @@ -143,13 +159,17 @@ assert_failloud() { echo "$stderr" >&2 fail=1 fi + if [[ "$stderr" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$stderr" != *"reason=no-token-for-identity"* || "$stderr" != *"shared_path_entered=false"* ]]; then + echo "FAIL: $desc — stable resolver refusal marker missing: $stderr" >&2 + fail=1 + fi if [[ "$stderr" != *"$expected_tok_path"* ]]; then echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2 echo "$stderr" >&2 fail=1 fi - if [[ "$stderr" == *"shared"*"token"* ]]; then - echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2 + if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then + echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2 echo "$stderr" >&2 fail=1 fi diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 6b7fcb84..c677e7ae 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,7 +1,13 @@ +import { fstatSync, writeSync } from 'node:fs'; +import { open, readFile, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import type { Command } from 'commander'; -import { CredentialJournalError } from '../credentials/audit-journal.js'; +import { + CredentialAuditJournal, + CredentialJournalError, + listCredentialJournals, +} from '../credentials/audit-journal.js'; import { readRegularFileSecure } from '../fleet/secure-file.js'; import { runCredentialReadValidation, @@ -20,6 +26,9 @@ import { import type { CredentialGrantResultDto } from '../credentials/grant.dto.js'; import { grantTeamRepositoryPermission } from '../credentials/team-grant.js'; import type { TeamGrantResult } from '../credentials/team-grant.js'; +import { provisionCredential, revokeCredential } from '../credentials/lifecycle.js'; +import type { CredentialLifecycleResultDto } from '../credentials/lifecycle.dto.js'; +import { TeaLoginStore } from '../credentials/tea-login-store.js'; import { CredentialEstateRegistryError, parseCredentialEstateRegistry, @@ -27,9 +36,11 @@ import { import { CredentialStoreError, FileCredentialResolver, + FileCredentialStore, } from '../credentials/file-credential-store.js'; import { GiteaCredentialProviderAdapter, + GiteaLifecycleProviderAdapter, GiteaTeamGrantProviderAdapter, } from '../credentials/gitea-provider.js'; @@ -64,6 +75,23 @@ interface CredentialGrantCommandOptions { readonly json?: boolean; } +interface CredentialLifecycleCommandOptions { + readonly estate: string; + readonly host: string; + readonly actor: string; + readonly authorityFd?: string; + readonly outputFd?: string; + readonly scopes?: string; + readonly tokenName?: string; + readonly registry?: string; + readonly tokenDir?: string; + readonly stateDir?: string; + readonly seatEnv?: string; + readonly teaConfig?: string; + readonly mosaicHome?: string; + readonly json?: boolean; +} + function defaultMosaicHome(options: { readonly mosaicHome?: string }): string { return options.mosaicHome ?? join(homedir(), '.config', 'mosaic'); } @@ -281,8 +309,414 @@ export async function executeCredentialGrant( } } +function lifecycleLocations(options: CredentialLifecycleCommandOptions): { + readonly registryPath: string; + readonly tokenDirectory: string; + readonly stateRoot: string; +} { + const mosaicHome = defaultMosaicHome(options); + return { + registryPath: options.registry ?? join(mosaicHome, 'cred', 'estates.json'), + tokenDirectory: + options.tokenDir ?? + process.env['MOSAIC_GITEA_TOKEN_DIR'] ?? + join(mosaicHome, 'secrets', 'gitea-tokens'), + stateRoot: options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred'), + }; +} + +function localLifecycleResult( + operation: CredentialLifecycleResultDto['operation'], + identity: string, + options: CredentialLifecycleCommandOptions, + values: Partial & { + readonly outcome: CredentialLifecycleResultDto['outcome']; + readonly code: string; + readonly message: string; + }, +): CredentialLifecycleResultDto { + const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const; + return { + schemaVersion: 1, + operation, + outcome: values.outcome, + exitCode: exits[values.outcome], + retryable: false, + subject: { identity, estate: options.estate, host: options.host, repo: null }, + mutation: values.mutation ?? 'none', + reason: { code: values.code, message: values.message }, + evidence: values.evidence ?? { + providerIdentity: null, + token: null, + teaLogin: null, + identities: [], + journalIds: [], + }, + audit: values.audit ?? { journalId: null, state: 'not-started' }, + }; +} + +async function lifecycleContext(options: CredentialLifecycleCommandOptions): Promise<{ + readonly registry: ReturnType; + readonly store: FileCredentialStore; + readonly provider: GiteaLifecycleProviderAdapter; + readonly teaStore: TeaLoginStore; + readonly stateRoot: string; +}> { + const locations = lifecycleLocations(options); + const registry = parseCredentialEstateRegistry(readRegistrySource(locations.registryPath)); + const host = registry.resolve(options.estate, options.host); + if (host === undefined) { + throw new CredentialEstateRegistryError('estate-host-mismatch', 'estate and host do not match'); + } + return { + registry, + store: new FileCredentialStore(locations.tokenDirectory, registry), + provider: new GiteaLifecycleProviderAdapter(host.apiBaseUrl, fetch), + teaStore: new TeaLoginStore( + options.teaConfig ?? join(homedir(), '.config', 'tea', 'config.yml'), + ), + stateRoot: locations.stateRoot, + }; +} + +async function lifecycleAuthority( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise>> { + return readDelegatedCredentialFromFd( + Number(options.authorityFd), + options.actor, + options.estate, + options.host, + ); +} + +export async function executeCredentialProvision( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + try { + if (options.authorityFd === undefined || options.tokenName === undefined) { + return localLifecycleResult('provision', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'Authority fd and token name are required.', + }); + } + const scopes = (options.scopes ?? '').split(',').filter(Boolean); + if (scopes.length === 0) { + return localLifecycleResult('provision', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'At least one explicit token scope is required.', + }); + } + const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(identity, options); + return await provisionCredential( + { + identity, + estate: options.estate, + host: options.host, + tokenName: options.tokenName, + scopes, + }, + authority, + context.provider, + context.store, + context.teaStore, + { stateRoot: context.stateRoot, actor: options.actor }, + ); + } catch (error: unknown) { + const code = + error instanceof CredentialJournalError || + error instanceof CredentialEstateRegistryError || + error instanceof CredentialStoreError + ? error.code + : 'internal-invariant'; + return localLifecycleResult('provision', identity, options, { + outcome: 'error', + code, + message: 'Provisioning control failed locally.', + }); + } +} + +export async function executeCredentialRevoke( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + try { + if (options.authorityFd === undefined) { + return localLifecycleResult('revoke', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'Authority fd is required.', + }); + } + const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(identity, options); + return await revokeCredential( + { identity, estate: options.estate, host: options.host }, + authority, + context.provider, + context.store, + { stateRoot: context.stateRoot, actor: options.actor }, + ); + } catch { + return localLifecycleResult('revoke', identity, options, { + outcome: 'error', + code: 'internal-invariant', + message: 'Revocation control failed locally.', + }); + } +} + +export async function executeCredentialRotate( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + try { + if (options.authorityFd === undefined || options.tokenName === undefined) { + return localLifecycleResult('rotate', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'Authority fd and new token name are required.', + }); + } + const context = await lifecycleContext(options); + const old = await context.store.readBinding(identity, options.estate, options.host); + if (old === undefined) { + return localLifecycleResult('rotate', identity, options, { + outcome: 'refused', + code: 'no-token-for-identity', + message: 'No existing binding can be rotated.', + }); + } + const authority = await lifecycleAuthority(identity, options); + const journal = await CredentialAuditJournal.open(context.stateRoot, { + operation: 'rotate', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('rotate-requested'); + const provisioned = await provisionCredential( + { + identity, + estate: options.estate, + host: options.host, + tokenName: options.tokenName, + scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean), + }, + authority, + context.provider, + context.store, + context.teaStore, + { stateRoot: context.stateRoot, actor: options.actor }, + ); + if (provisioned.outcome !== 'ok') { + await journal.seal(provisioned.outcome, provisioned.reason.code); + return { + ...provisioned, + operation: 'rotate', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; + } + await journal.recordMutation('token-mint-applied'); + await context.provider.revokeToken(authority, identity, old.tokenName); + await journal.recordMutation('token-revoke-applied'); + await journal.seal('ok', 'rotate-verified'); + return { + ...provisioned, + operation: 'rotate', + reason: { + code: 'rotate-verified', + message: 'New token was read back before old token revocation.', + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; + } catch { + return localLifecycleResult('rotate', identity, options, { + outcome: 'indeterminate', + mutation: 'unknown', + code: 'mutation-state-unknown', + message: 'Rotation did not establish both new-token acceptance and old-token revocation.', + }); + } +} + +export async function executeCredentialWire( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + if (options.seatEnv === undefined) { + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'An explicit seat environment file is required.', + }); + } + const locations = lifecycleLocations(options); + const journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation: 'wire', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('wire-requested'); + try { + const existing = await readFile(options.seatEnv, 'utf8').catch((): string => ''); + const lines = existing + .split(/\r?\n/) + .filter( + (line): boolean => + !line.startsWith('MOSAIC_GIT_IDENTITY=') && !line.startsWith('GITEA_LOGIN='), + ); + lines.push(`MOSAIC_GIT_IDENTITY=${identity}`, `GITEA_LOGIN=${identity}`); + const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, options.seatEnv); + await journal.recordMutation('wire-applied'); + await journal.seal('ok', 'wire-verified'); + return localLifecycleResult('wire', identity, options, { + outcome: 'ok', + mutation: 'applied', + code: 'wire-verified', + message: 'Both fleet identity axes were written to the explicit seat environment.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch { + await journal.seal('error', 'wire-failed'); + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + mutation: 'unknown', + code: 'wire-failed', + message: 'Seat environment wiring failed.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } +} + +export async function executeCredentialGet( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + const locations = lifecycleLocations(options); + const journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation: 'get', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('get-requested'); + try { + const fd = Number(options.outputFd); + const stat = fstatSync(fd); + if ( + !Number.isSafeInteger(fd) || + fd < 3 || + stat.uid !== process.getuid?.() || + (stat.mode & 0o077) !== 0 + ) { + throw new Error('unsafe output fd'); + } + const context = await lifecycleContext(options); + const resolved = await new FileCredentialResolver( + lifecycleLocations(options).tokenDirectory, + context.registry, + ).resolve(identity, options.estate, options.host); + if (resolved === undefined) { + await journal.seal('refused', 'no-token-for-identity'); + return localLifecycleResult('get', identity, options, { + outcome: 'refused', + code: 'no-token-for-identity', + message: 'No exact governed credential exists.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } + const prefix = new TextEncoder().encode( + `protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`, + ); + writeSync(fd, prefix); + writeSync(fd, resolved.secret); + writeSync(fd, new TextEncoder().encode('\n\n')); + await journal.recordMutation('credential-issued'); + await journal.seal('ok', 'get-verified'); + return localLifecycleResult('get', identity, options, { + outcome: 'ok', + code: 'get-verified', + message: 'Credential was emitted only to the protected output fd.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch { + await journal.seal('error', 'insecure-credential-destination'); + return localLifecycleResult('get', identity, options, { + outcome: 'error', + code: 'insecure-credential-destination', + message: 'Protected credential output fd was unavailable or unsafe.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } +} + +export async function executeCredentialList( + options: CredentialLifecycleCommandOptions, +): Promise { + try { + const context = await lifecycleContext(options); + const identities = await context.store.list(options.estate, options.host); + return localLifecycleResult('list', 'all', options, { + outcome: 'ok', + code: 'list-verified', + message: 'Governed credential bindings were listed without secrets.', + evidence: { providerIdentity: null, token: null, teaLogin: null, identities, journalIds: [] }, + }); + } catch { + return localLifecycleResult('list', 'all', options, { + outcome: 'error', + code: 'internal-invariant', + message: 'Credential listing failed.', + }); + } +} + +export async function executeCredentialAudit( + options: CredentialLifecycleCommandOptions, +): Promise { + const journals = await listCredentialJournals(lifecycleLocations(options).stateRoot); + return localLifecycleResult('audit', 'all', options, { + outcome: 'ok', + code: 'audit-verified', + message: 'Durable journal index was read.', + evidence: { + providerIdentity: null, + token: null, + teaLogin: null, + identities: [], + journalIds: journals.map((journal): string => journal.id), + }, + }); +} + type PrintableCredentialResult = Pick< - CredentialValidationResultDto | CredentialGrantResultDto | TeamGrantResult, + | CredentialValidationResultDto + | CredentialGrantResultDto + | TeamGrantResult + | CredentialLifecycleResultDto, 'operation' | 'outcome' | 'exitCode' | 'reason' >; @@ -306,6 +740,125 @@ export function registerCredentialCommand(parent: Command): void { cred.outputHelp(); }); + cred + .command('provision ') + .description('Mint, scope-read-back, store, and bind a provider credential') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit delegated Basic authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .requiredOption('--token-name ', 'Unique provider token name') + .requiredOption('--scopes ', 'Comma-separated least-privilege token scopes') + .option('--tea-config ', 'Tea login configuration path') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialProvision(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('wire ') + .description('Idempotently wire both explicit fleet identity axes into a seat environment') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--seat-env ', 'Explicit seat environment file') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialWire(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('get ') + .description('Emit one exact credential only to a protected inherited fd') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--output-fd ', 'Protected inherited credential output fd') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one non-secret machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialGet(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('rotate ') + .description('Read back a replacement token before revoking the old token') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit delegated Basic authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .requiredOption('--token-name ', 'Unique replacement provider token name') + .option('--scopes ', 'Replacement scopes; defaults to existing exact scopes') + .option('--tea-config ', 'Tea login configuration path') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialRotate(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('revoke ') + .description('Revoke the provider token before removing its governed local binding') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit delegated Basic authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialRevoke(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('list') + .description('List governed identities without reading or printing secret material') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialList(options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('audit') + .description('List durable credential journals without secret-bearing payloads') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialAudit(options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + cred .command('grant ') .description('Grant repository permission and accept only provider object read-back') @@ -354,4 +907,21 @@ export function registerCredentialCommand(parent: Command): void { printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); + + cred + .command('whoami ') + .description('Read back runtime identity when scoped, otherwise report identity not measured') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--repo ', 'In-scope capability repository') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--actor ', 'Explicit audit actor') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialValidateCommandOptions): Promise => { + const result = await executeCredentialValidate(identity, { ...options, require: 'read' }); + 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 index 71ffde27..eac425e5 100644 --- a/packages/mosaic/src/credentials/audit-journal.dto.ts +++ b/packages/mosaic/src/credentials/audit-journal.dto.ts @@ -5,7 +5,10 @@ export type CredentialJournalOperation = | 'get' | 'validate' | 'rotate' - | 'revoke'; + | 'revoke' + | 'whoami' + | 'list' + | 'audit'; export interface CredentialJournalContextDto { readonly operation: CredentialJournalOperation; diff --git a/packages/mosaic/src/credentials/audit-journal.spec.ts b/packages/mosaic/src/credentials/audit-journal.spec.ts index fe8530df..010acc6a 100644 --- a/packages/mosaic/src/credentials/audit-journal.spec.ts +++ b/packages/mosaic/src/credentials/audit-journal.spec.ts @@ -51,7 +51,7 @@ describe('credential durable audit journal', (): void => { it('leaves an unsealed journal visible for recovery', async (): Promise => { const root = await stateRoot(); - await CredentialAuditJournal.open(root, { + const journal = await CredentialAuditJournal.open(root, { operation: 'rotate', actor: 'provisioner', identity: 'seat-name', @@ -64,6 +64,7 @@ describe('credential durable audit journal', (): void => { expect(journals).toHaveLength(1); expect(journals[0]?.state).toBe('open'); + await journal.closeIncomplete(); }); it('fails fatally when the durable journal root cannot be created', async (): Promise => { @@ -103,6 +104,7 @@ describe('credential durable audit journal', (): void => { const journals = await listCredentialJournals(root); const source = await readFile(journals[0]?.path ?? '', 'utf8'); expect(source).not.toContain('seeded-secret-canary'); + await journal.closeIncomplete(); }); it('supersedes a false sealed classification without editing the original journal', async (): Promise => { diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index ff5c592b..e95431d3 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -20,6 +20,7 @@ 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-none', 'permission-read', 'permission-write', 'permission-admin', @@ -29,10 +30,27 @@ const SAFE_DECISIONS = new Set([ 'revoke-verified', 'rotate-verified', 'validation-requested', + 'provision-requested', + 'rotate-requested', + 'revoke-requested', + 'wire-requested', + 'get-requested', 'validation-verified', 'team-member-present', 'team-repository-present', + 'team-repository-set-verified', 'organization-member-present', + 'organization-member-absent', + 'collaborator-grant-applied', + 'team-member-applied', + 'team-repository-applied', + 'transport-write-verified', + 'token-mint-applied', + 'token-binding-stored', + 'tea-login-stored', + 'token-revoke-applied', + 'wire-applied', + 'credential-issued', 'classification-correction', ]); @@ -159,6 +177,16 @@ export class CredentialAuditJournal { await this.append({ phase: 'provider-evidence', at: this.now(), evidence }); } + async recordMutation(decision: string): Promise { + if (!SAFE_DECISIONS.has(decision)) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'mutation decision is outside the non-secret allowlist', + ); + } + await this.append({ phase: 'mutation', at: this.now(), decision }); + } + async recordCorrection(correction: CredentialJournalCorrectionDto): Promise { if ( !SAFE_NAME.test(correction.supersedesJournalId) || diff --git a/packages/mosaic/src/credentials/credential-binding.dto.ts b/packages/mosaic/src/credentials/credential-binding.dto.ts new file mode 100644 index 00000000..ac62bfcc --- /dev/null +++ b/packages/mosaic/src/credentials/credential-binding.dto.ts @@ -0,0 +1,10 @@ +export interface CredentialBindingMetadataDto { + readonly schemaVersion?: 1; + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly providerLogin: string; + readonly tokenName: string; + readonly scopes: readonly string[]; + readonly createdAt: string; +} diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts index dd54f888..bb0f181c 100644 --- a/packages/mosaic/src/credentials/credential-result.dto.ts +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -1,6 +1,6 @@ export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate'; export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown'; -export type RepositoryPermission = 'read' | 'write' | 'admin'; +export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin'; export type ReceivePackState = 'advertised' | 'refused'; export interface CredentialReasonDto { diff --git a/packages/mosaic/src/credentials/credential-validate-service.ts b/packages/mosaic/src/credentials/credential-validate-service.ts index 101cee36..ad3f4a02 100644 --- a/packages/mosaic/src/credentials/credential-validate-service.ts +++ b/packages/mosaic/src/credentials/credential-validate-service.ts @@ -16,6 +16,7 @@ export interface CredentialValidationServiceOptions { } function permissionDecision(permission: RepositoryPermission): string { + if (permission === 'none') return 'permission-none'; if (permission === 'admin') return 'permission-admin'; if (permission === 'write') return 'permission-write'; return 'permission-read'; diff --git a/packages/mosaic/src/credentials/delegated-credential.ts b/packages/mosaic/src/credentials/delegated-credential.ts index 84bca6ec..2ab04555 100644 --- a/packages/mosaic/src/credentials/delegated-credential.ts +++ b/packages/mosaic/src/credentials/delegated-credential.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { fstatSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { createReadStream, fstatSync } from 'node:fs'; import { z } from 'zod'; import type { ResolvedCredential } from './credential-provider.dto.js'; @@ -27,6 +26,31 @@ export class DelegatedCredentialError extends Error { } } +async function readProtectedFd(fd: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout((): void => controller.abort(), 5_000); + const chunks: Buffer[] = []; + let total = 0; + try { + const stream = createReadStream(`/proc/self/fd/${fd}`, { + highWaterMark: 4 * 1024, + signal: controller.signal, + }); + for await (const chunk of stream) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += bytes.byteLength; + if (total > 32 * 1024) { + stream.destroy(); + throw new Error('protected credential payload exceeded the bound'); + } + chunks.push(bytes); + } + return Buffer.concat(chunks, total); + } finally { + clearTimeout(timeout); + } +} + export async function readDelegatedCredentialFromFd( fd: number, expectedIdentity: string, @@ -46,7 +70,7 @@ export async function readDelegatedCredentialFromFd( if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) { throw new Error('fd owner or permissions are unsafe'); } - bytes = await readFile(`/proc/self/fd/${fd}`); + bytes = await readProtectedFd(fd); } catch { throw new DelegatedCredentialError( 'delegated-authority-unavailable', diff --git a/packages/mosaic/src/credentials/file-credential-store.spec.ts b/packages/mosaic/src/credentials/file-credential-store.spec.ts index 35dac898..7d456651 100644 --- a/packages/mosaic/src/credentials/file-credential-store.spec.ts +++ b/packages/mosaic/src/credentials/file-credential-store.spec.ts @@ -5,7 +5,7 @@ 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'; +import { FileCredentialResolver, FileCredentialStore } from './file-credential-store.js'; let cleanup: string | undefined; @@ -56,6 +56,19 @@ describe('phase-1 governed file credential resolver', (): void => { expect(wrongEstate).toBeUndefined(); }); + it('rejects a group-writable token directory even when the token file is private', async (): Promise => { + const root = await fixtureRoot(); + await writeFile(join(root, 'gitea-example-seat-name.token'), 'private-token', { + mode: 0o600, + }); + await chmod(root, 0o770); + const resolver = new FileCredentialResolver(root, registry()); + + await expect(resolver.resolve('seat-name', 'homelab', 'git.example.invalid')).rejects.toThrow( + /insecure-token-owner/, + ); + }); + it('rejects a token file with group or other permissions', async (): Promise => { const root = await fixtureRoot(); const path = join(root, 'gitea-example-seat.token'); @@ -89,6 +102,40 @@ describe('phase-1 governed file credential resolver', (): void => { ); }); + it('atomically stores, lists, reads binding metadata, and removes a governed credential', async (): Promise => { + const root = await fixtureRoot(); + const store = new FileCredentialStore(root, registry()); + await store.put( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + providerLogin: 'seat', + tokenName: 'mosaic-seat-1', + scopes: ['write:repository'], + createdAt: '2026-08-05T00:00:00.000Z', + }, + new TextEncoder().encode('new-private-token'), + ); + + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']); + await expect( + store.readBinding('seat', 'homelab', 'git.example.invalid'), + ).resolves.toMatchObject({ + providerLogin: 'seat', + tokenName: 'mosaic-seat-1', + }); + await expect( + new FileCredentialResolver(root, registry()).resolve( + 'seat', + 'homelab', + 'git.example.invalid', + ), + ).resolves.toMatchObject({ identity: 'seat' }); + await store.remove('seat', 'homelab', 'git.example.invalid'); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); + }); + 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 }); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 119aa8fd..88ddf769 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -1,11 +1,31 @@ import { randomUUID } from 'node:crypto'; +import { lstatSync } from 'node:fs'; +import { open, readdir, rename, unlink } from 'node:fs/promises'; import { join } from 'node:path'; -import { readRegularFileSecure, type SecureFileSnapshot } from '../fleet/secure-file.js'; +import { z } from 'zod'; +import { + ensureManagedDirectory, + readRegularFileSecure, + type SecureFileSnapshot, +} from '../fleet/secure-file.js'; +import type { CredentialBindingMetadataDto } from './credential-binding.dto.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; +const bindingSchema = z + .object({ + schemaVersion: z.literal(1), + identity: z.string().regex(IDENTITY), + estate: z.string().min(1), + host: z.string().min(1), + providerLogin: z.string().regex(IDENTITY), + tokenName: z.string().regex(IDENTITY), + scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32), + createdAt: z.string().datetime(), + }) + .strict(); export class CredentialStoreError extends Error { constructor( @@ -61,6 +81,23 @@ export class FileCredentialResolver implements CredentialResolver { const hostConfig = this.estateRegistry.resolve(estate, host); if (hostConfig === undefined) return undefined; + const currentUid = process.getuid?.(); + if (currentUid === undefined) { + throw new CredentialStoreError('insecure-token-owner', 'runtime uid is unavailable'); + } + const directory = lstatSync(this.tokenDirectory); + if ( + !directory.isDirectory() || + directory.isSymbolicLink() || + directory.uid !== currentUid || + (directory.mode & 0o022) !== 0 + ) { + throw new CredentialStoreError( + 'insecure-token-owner', + 'token directory ownership or write permissions are unsafe', + ); + } + const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`); let snapshot: SecureFileSnapshot; try { @@ -74,6 +111,12 @@ export class FileCredentialResolver implements CredentialResolver { } const permissions = snapshot.mode & 0o777; + if (snapshot.uid !== currentUid) { + throw new CredentialStoreError( + 'insecure-token-owner', + 'token file is not owned by the runtime uid', + ); + } if ((permissions & 0o077) !== 0) { throw new CredentialStoreError( 'insecure-token-mode', @@ -90,3 +133,122 @@ export class FileCredentialResolver implements CredentialResolver { }); } } + +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +export class FileCredentialStore { + constructor( + private readonly tokenDirectory: string, + private readonly estateRegistry: ParsedCredentialEstateRegistry, + ) {} + + private paths( + identity: string, + estate: string, + host: string, + ): { + readonly token: string; + readonly binding: string; + readonly prefix: string; + } { + if (!IDENTITY.test(identity)) { + throw new CredentialStoreError( + 'invalid-identity', + 'identity is outside the allowlist grammar', + ); + } + const config = this.estateRegistry.resolve(estate, host); + if (config === undefined) { + throw new CredentialStoreError('estate-host-mismatch', 'estate and host do not match'); + } + const prefix = `${config.tokenPrefix}-${identity}`; + return { + token: join(this.tokenDirectory, `${prefix}.token`), + binding: join(this.tokenDirectory, `${prefix}.binding.json`), + prefix, + }; + } + + async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise { + const paths = this.paths(metadata.identity, metadata.estate, metadata.host); + ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory); + const token = validateSecret(Buffer.from(secret)); + const binding = bindingSchema.parse({ ...metadata, schemaVersion: 1 }); + const suffix = randomUUID(); + const tokenTemp = `${paths.token}.${suffix}.tmp`; + const bindingTemp = `${paths.binding}.${suffix}.tmp`; + const tokenHandle = await open(tokenTemp, 'wx', 0o600); + const bindingHandle = await open(bindingTemp, 'wx', 0o600); + try { + await tokenHandle.writeFile(token); + await tokenHandle.sync(); + await bindingHandle.writeFile(`${JSON.stringify(binding)}\n`, 'utf8'); + await bindingHandle.sync(); + } finally { + await tokenHandle.close(); + await bindingHandle.close(); + } + await rename(bindingTemp, paths.binding); + await rename(tokenTemp, paths.token); + await syncDirectory(this.tokenDirectory); + } + + async readBinding( + identity: string, + estate: string, + host: string, + ): Promise { + const paths = this.paths(identity, estate, host); + let snapshot: SecureFileSnapshot; + try { + snapshot = readRegularFileSecure(paths.binding, { + root: this.tokenDirectory, + maxBytes: 64 * 1024, + }); + } catch (error: unknown) { + if (isMissingFile(error)) return undefined; + throw error; + } + if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) { + throw new CredentialStoreError('insecure-token-owner', 'binding metadata is not private'); + } + const parsed = bindingSchema.safeParse(JSON.parse(snapshot.content.toString('utf8'))); + if (!parsed.success) { + throw new CredentialStoreError( + 'invalid-binding', + 'binding metadata failed schema validation', + ); + } + return parsed.data; + } + + async list(estate: string, host: string): Promise { + const config = this.estateRegistry.resolve(estate, host); + if (config === undefined) return []; + const names = await readdir(this.tokenDirectory); + const prefix = `${config.tokenPrefix}-`; + return names + .filter((name): boolean => name.startsWith(prefix) && name.endsWith('.token')) + .map((name): string => name.slice(prefix.length, -'.token'.length)) + .filter((identity): boolean => IDENTITY.test(identity)) + .sort(); + } + + async remove(identity: string, estate: string, host: string): Promise { + const paths = this.paths(identity, estate, host); + await unlink(paths.token).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await unlink(paths.binding).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await syncDirectory(this.tokenDirectory); + } +} diff --git a/packages/mosaic/src/credentials/gitea-provider.spec.ts b/packages/mosaic/src/credentials/gitea-provider.spec.ts index 301a3677..faa23cfc 100644 --- a/packages/mosaic/src/credentials/gitea-provider.spec.ts +++ b/packages/mosaic/src/credentials/gitea-provider.spec.ts @@ -195,7 +195,11 @@ describe('Gitea credential provider transport', (): void => { return jsonResponse({ id: 21, login: 'seat-name' }); } if (url.includes('/repos/owner/repo')) { - return jsonResponse({ id: 4, full_name: 'owner/repo' }); + return jsonResponse({ + id: 4, + full_name: 'owner/repo', + permissions: { admin: false, push: true, pull: true }, + }); } return jsonResponse({ message: 'unexpected' }, 500); }, @@ -213,6 +217,34 @@ describe('Gitea credential provider transport', (): void => { expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' }); }); + it('rejects a successful team read-back that names the wrong object', async (): Promise => { + const adapter = new GiteaTeamGrantProviderAdapter( + 'https://git.example.invalid', + async (): Promise => jsonResponse({ id: 99, login: 'other-seat' }), + ); + + await expect(adapter.readTeamMember(credential, 7, 'seat-name')).rejects.toMatchObject({ + code: 'unexpected-provider-shape', + }); + }); + + it('bounds a provider that never returns response headers', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (_input: string | URL | Request, init?: RequestInit): Promise => + new Promise((_resolve, reject): void => { + init?.signal?.addEventListener('abort', (): void => { + reject(new Error('aborted')); + }); + }), + 10, + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + }); + it('never includes seeded secret material in provider error messages', async (): Promise => { const adapter = new GiteaCredentialProviderAdapter( 'https://git.example.invalid', diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index 8ce52a3f..5492b7f5 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js'; import type { GiteaGrantProvider } from './grant.js'; +import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js'; +import type { TokenObjectEvidenceDto } from './lifecycle.dto.js'; import type { GiteaTeamGrantProvider, PresenceEvidence, @@ -43,6 +45,14 @@ const collaboratorPermissionSchema = z .passthrough(); const organizationSchema = z.object({ username: z.string().min(1) }).passthrough(); +const tokenObjectSchema = z + .object({ + name: z.string().min(1), + sha1: z.string().min(1).optional(), + token: z.string().min(1).optional(), + scopes: z.array(z.string()).default([]), + }) + .passthrough(); const teamSchema = z .object({ id: z.number().int().positive(), @@ -202,7 +212,8 @@ function effectivePermission(permissions: { }): RepositoryPermission { if (permissions.admin) return 'admin'; if (permissions.push) return 'write'; - return 'read'; + if (permissions.pull) return 'read'; + return 'none'; } export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider { @@ -211,14 +222,27 @@ export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider { constructor( apiBaseUrl: string, private readonly fetchImpl: FetchLike = fetch, + private readonly requestTimeoutMs = 10_000, ) { const parsed = new URL(apiBaseUrl); this.origin = parsed.origin; + if ( + !Number.isSafeInteger(requestTimeoutMs) || + requestTimeoutMs < 1 || + requestTimeoutMs > 30_000 + ) { + throw new CredentialProviderEvidenceError( + 'invalid-input', + 'provider request timeout is outside the bounded range', + ); + } } protected async request(url: string, init: RequestInit): Promise { + const deadline = AbortSignal.timeout(this.requestTimeoutMs); + const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]); try { - return await this.fetchImpl(url, init); + return await this.fetchImpl(url, { ...init, signal }); } catch { throw new CredentialProviderEvidenceError( 'provider-unavailable', @@ -610,6 +634,10 @@ export class GiteaTeamGrantProviderAdapter return this.readPresence( authority, `GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`, + (value: unknown): boolean => { + const parsed = userSchema.safeParse(value); + return parsed.success && parsed.data.login === identity; + }, ); } @@ -622,12 +650,17 @@ export class GiteaTeamGrantProviderAdapter return this.readPresence( authority, `GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`, + (value: unknown): boolean => { + const parsed = repoSchema.safeParse(value); + return parsed.success && parsed.data.full_name === repo; + }, ); } private async readPresence( authority: ResolvedCredential, endpoint: string, + matchesExpectedObject: (value: unknown) => boolean, ): Promise { const response = await this.request(`${this.origin}${endpoint.slice(4)}`, { method: 'GET', @@ -645,7 +678,155 @@ export class GiteaTeamGrantProviderAdapter await boundedBody(response); throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed'); } - await boundedBody(response); + if (!matchesExpectedObject(await jsonObject(response))) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'team read-back did not identify the requested object', + ); + } return { state: 'present', endpoint, contentType: contentType(response) }; } } + +function basicAuthorization(authority: ResolvedCredential): string { + const prefix = Buffer.from(`${authority.identity}:`, 'utf8'); + const material = Buffer.concat([prefix, Buffer.from(authority.secret)]); + try { + return `Basic ${material.toString('base64')}`; + } finally { + prefix.fill(0); + material.fill(0); + } +} + +export class GiteaLifecycleProviderAdapter + extends GiteaCredentialProviderAdapter + implements GiteaLifecycleProvider +{ + async readBasicIdentity(authority: 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: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'credential-rejected', + 'delegated Basic authority was rejected', + ); + } + const parsed = userSchema.safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'delegated Basic identity response was malformed', + ); + } + return { login: parsed.data.login, endpoint, contentType: contentType(response) }; + } + + async mintToken( + authority: ResolvedCredential, + identity: string, + name: string, + scopes: readonly string[], + ): Promise { + const endpoint = `POST /api/v1/users/${identity}/tokens`; + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`, + { + method: 'POST', + headers: { + Accept: JSON_CONTENT_TYPE, + 'Content-Type': JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ name, scopes }), + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError('provider-unavailable', 'token mint failed'); + } + const parsed = tokenObjectSchema.safeParse(await jsonObject(response)); + const secret = parsed.success ? (parsed.data.sha1 ?? parsed.data.token) : undefined; + if (!parsed.success || secret === undefined || parsed.data.name !== name) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'minted token object lacked the requested name or secret', + ); + } + return { + secret: new TextEncoder().encode(secret), + evidence: { + name: parsed.data.name, + scopes: parsed.data.scopes, + endpoint, + contentType: contentType(response), + }, + }; + } + + async readToken( + authority: ResolvedCredential, + identity: string, + name: string, + ): Promise { + const endpoint = `GET /api/v1/users/${identity}/tokens`; + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`, + { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError('readback-missing', 'token list read-back failed'); + } + const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response)); + const matches = parsed.success + ? parsed.data.filter((token): boolean => token.name === name) + : []; + if (matches.length !== 1 || matches[0] === undefined) { + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'minted token did not resolve uniquely by name', + ); + } + return { + name: matches[0].name, + scopes: matches[0].scopes, + endpoint, + contentType: contentType(response), + }; + } + + async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise { + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`, + { + method: 'DELETE', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + await boundedBody(response); + if (!response.ok) { + throw new CredentialProviderEvidenceError('mutation-state-unknown', 'token revoke failed'); + } + } +} diff --git a/packages/mosaic/src/credentials/grant.spec.ts b/packages/mosaic/src/credentials/grant.spec.ts index 26838b90..8eb62b70 100644 --- a/packages/mosaic/src/credentials/grant.spec.ts +++ b/packages/mosaic/src/credentials/grant.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -130,6 +130,13 @@ describe('direct repository grant', (): void => { expect(result.evidence.repositoryPermission?.effective).toBe('write'); expect(result.evidence.organizationMembership?.state).toBe('absent'); expect(result.audit.state).toBe('sealed'); + const [sealed] = await listCredentialJournals(root); + const source = await readFile(sealed?.path ?? '', 'utf8'); + expect(source).toContain('"phase":"mutation"'); + expect(source).toContain('"decision":"collaborator-grant-applied"'); + expect(source).toContain('"decision":"identity-verified"'); + expect(source).toContain('"decision":"organization-member-absent"'); + expect(source).toContain('"decision":"transport-write-verified"'); }); it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise => { diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index fa63e38a..fda7b5a8 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -106,6 +106,11 @@ export async function grantDirectRepositoryPermission( audit: { journalId: journal.journalId(), state: 'sealed' }, }; } + await journal.recordProviderEvidence({ + endpoint: authorityIdentity.endpoint, + contentType: authorityIdentity.contentType, + decision: 'identity-verified', + }); mutation = 'unknown'; await grantProvider.grantCollaborator( authority, @@ -114,6 +119,7 @@ export async function grantDirectRepositoryPermission( request.permission, ); mutation = 'applied'; + await journal.recordMutation('collaborator-grant-applied'); const collaborator = await grantProvider.readCollaboratorPermission( authority, @@ -135,6 +141,34 @@ export async function grantDirectRepositoryPermission( ? await evaluateGiteaReadValidation(request, validationDependencies) : await evaluateGiteaWriteValidation(request, validationDependencies); + if (organizationMembership !== null) { + await journal.recordProviderEvidence({ + endpoint: organizationMembership.endpoint, + contentType: organizationMembership.contentType, + decision: + organizationMembership.state === 'present' + ? 'organization-member-present' + : 'organization-member-absent', + }); + } + 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: `permission-${validation.evidence.repositoryPermission.effective}`, + }); + } + if (validation.evidence.writeDifferential !== null) { + await journal.recordMutation('transport-write-verified'); + } + const readBackMatches = collaborator.identity === request.identity && collaborator.permission === request.permission && diff --git a/packages/mosaic/src/credentials/lifecycle.dto.ts b/packages/mosaic/src/credentials/lifecycle.dto.ts new file mode 100644 index 00000000..ec141cf4 --- /dev/null +++ b/packages/mosaic/src/credentials/lifecycle.dto.ts @@ -0,0 +1,49 @@ +import type { CredentialOutcome } from './credential-result.dto.js'; + +export type CredentialLifecycleOperation = + | 'provision' + | 'wire' + | 'get' + | 'whoami' + | 'list' + | 'rotate' + | 'revoke' + | 'audit'; + +export interface TokenObjectEvidenceDto { + readonly name: string; + readonly scopes: readonly string[]; + readonly endpoint: string; + readonly contentType: string; +} + +export interface CredentialLifecycleResultDto { + readonly schemaVersion: 1; + readonly operation: CredentialLifecycleOperation; + readonly outcome: CredentialOutcome; + readonly exitCode: 0 | 10 | 20 | 30; + readonly retryable: boolean; + readonly subject: { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: null; + }; + readonly mutation: 'none' | 'unknown' | 'applied'; + readonly reason: { readonly code: string; readonly message: string }; + readonly evidence: { + readonly providerIdentity: string | null; + readonly token: TokenObjectEvidenceDto | null; + readonly teaLogin: { + readonly name: string; + readonly host: string; + readonly state: 'registered' | 'not-measured'; + } | null; + readonly identities: readonly string[]; + readonly journalIds: readonly string[]; + }; + readonly audit: { + readonly journalId: string | null; + readonly state: 'not-started' | 'open' | 'sealed'; + }; +} diff --git a/packages/mosaic/src/credentials/lifecycle.spec.ts b/packages/mosaic/src/credentials/lifecycle.spec.ts new file mode 100644 index 00000000..cce91727 --- /dev/null +++ b/packages/mosaic/src/credentials/lifecycle.spec.ts @@ -0,0 +1,143 @@ +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { ResolvedCredential } from './credential-provider.dto.js'; +import { parseCredentialEstateRegistry } from './estate-registry.js'; +import { FileCredentialStore } from './file-credential-store.js'; +import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js'; +import { TeaLoginStore } from './tea-login-store.js'; + +let cleanup: string | undefined; +afterEach(async (): Promise => { + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +async function fixture(): Promise<{ + root: string; + store: FileCredentialStore; + teaStore: TeaLoginStore; +}> { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-lifecycle-')); + const tokens = join(cleanup, 'tokens'); + await mkdir(tokens, { mode: 0o700 }); + const registry = parseCredentialEstateRegistry( + JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + readOnlyControlIdentity: 'control', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], + }), + ); + return { + root: join(cleanup, 'state'), + store: new FileCredentialStore(tokens, registry), + teaStore: new TeaLoginStore(join(cleanup, 'tea', 'config.yml')), + }; +} + +const authority: ResolvedCredential = Object.freeze({ + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'basic-authority', + secret: new TextEncoder().encode('password-canary'), +}); + +function provider(): GiteaLifecycleProvider { + return { + async readBasicIdentity() { + return { login: 'seat', endpoint: 'GET /api/v1/user', contentType: 'application/json' }; + }, + async mintToken(_authority, _identity, name, scopes) { + return { + secret: new TextEncoder().encode('minted-token-canary'), + evidence: { + name, + scopes, + endpoint: 'POST /api/v1/users/seat/tokens', + contentType: 'application/json', + }, + }; + }, + async readToken(_authority, _identity, name) { + return { + name, + scopes: ['write:repository'], + endpoint: 'GET /api/v1/users/seat/tokens', + contentType: 'application/json', + }; + }, + async revokeToken(): Promise {}, + }; +} + +describe('credential lifecycle', (): void => { + it('accepts provision only after exact principal and scope read-back', async (): Promise => { + const { root, store, teaStore } = await fixture(); + const result = await provisionCredential( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + tokenName: 'mosaic-seat-1', + scopes: ['write:repository'], + }, + authority, + provider(), + store, + teaStore, + { stateRoot: root, actor: 'seat', now: (): string => '2026-08-05T00:00:00.000Z' }, + ); + expect(result.outcome).toBe('ok'); + await expect( + store.readBinding('seat', 'homelab', 'git.example.invalid'), + ).resolves.toMatchObject({ providerLogin: 'seat', scopes: ['write:repository'] }); + expect(JSON.stringify(result)).not.toContain('minted-token-canary'); + }); + + it('revokes at provider before removing the local binding', async (): Promise => { + const { root, store, teaStore } = await fixture(); + await provisionCredential( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + tokenName: 'mosaic-seat-1', + scopes: ['write:repository'], + }, + authority, + provider(), + store, + teaStore, + { stateRoot: root, actor: 'seat' }, + ); + let revoked = false; + const lifecycleProvider = provider(); + lifecycleProvider.revokeToken = async (): Promise => { + revoked = true; + }; + const result = await revokeCredential( + { identity: 'seat', estate: 'homelab', host: 'git.example.invalid' }, + authority, + lifecycleProvider, + store, + { stateRoot: root, actor: 'seat' }, + ); + expect(result.outcome).toBe('ok'); + expect(revoked).toBe(true); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); + }); +}); diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts new file mode 100644 index 00000000..406ef554 --- /dev/null +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -0,0 +1,272 @@ +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; +import type { ResolvedCredential } from './credential-provider.dto.js'; +import type { FileCredentialStore } from './file-credential-store.js'; +import type { TeaLoginStore } from './tea-login-store.js'; +import type { + CredentialLifecycleOperation, + CredentialLifecycleResultDto, + TokenObjectEvidenceDto, +} from './lifecycle.dto.js'; + +export interface MintedToken { + readonly secret: Uint8Array; + readonly evidence: TokenObjectEvidenceDto; +} + +export interface GiteaLifecycleProvider { + readBasicIdentity(authority: ResolvedCredential): Promise<{ + readonly login: string; + readonly endpoint: string; + readonly contentType: string; + }>; + mintToken( + authority: ResolvedCredential, + identity: string, + name: string, + scopes: readonly string[], + ): Promise; + readToken( + authority: ResolvedCredential, + identity: string, + name: string, + ): Promise; + revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise; +} + +export interface LifecycleRequest { + readonly identity: string; + readonly estate: string; + readonly host: string; +} + +export interface ProvisionRequest extends LifecycleRequest { + readonly tokenName: string; + readonly scopes: readonly string[]; +} + +export interface LifecycleOptions { + readonly stateRoot: string; + readonly actor: string; + readonly now?: () => string; +} + +function lifecycleResult( + operation: CredentialLifecycleOperation, + request: LifecycleRequest, + options: { + readonly outcome: CredentialLifecycleResultDto['outcome']; + readonly mutation: CredentialLifecycleResultDto['mutation']; + readonly code: string; + readonly message: string; + readonly journalId: string | null; + readonly auditState: 'not-started' | 'open' | 'sealed'; + readonly providerIdentity?: string | null; + readonly token?: TokenObjectEvidenceDto | null; + readonly teaLogin?: CredentialLifecycleResultDto['evidence']['teaLogin']; + }, +): CredentialLifecycleResultDto { + const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const; + return { + schemaVersion: 1, + operation, + outcome: options.outcome, + exitCode: exits[options.outcome], + retryable: false, + subject: { ...request, repo: null }, + mutation: options.mutation, + reason: { code: options.code, message: options.message }, + evidence: { + providerIdentity: options.providerIdentity ?? null, + token: options.token ?? null, + teaLogin: options.teaLogin ?? null, + identities: [], + journalIds: [], + }, + audit: { journalId: options.journalId, state: options.auditState }, + }; +} + +async function openLifecycleJournal( + operation: 'provision' | 'rotate' | 'revoke', + request: LifecycleRequest, + options: LifecycleOptions, +): Promise { + const journal = await CredentialAuditJournal.open(options.stateRoot, { + operation, + actor: options.actor, + identity: request.identity, + estate: request.estate, + host: request.host, + repo: null, + }); + await journal.recordIntent(`${operation}-requested`); + return journal; +} + +export async function provisionCredential( + request: ProvisionRequest, + authority: ResolvedCredential, + provider: GiteaLifecycleProvider, + store: FileCredentialStore, + teaStore: TeaLoginStore, + options: LifecycleOptions, +): Promise { + const journal = await openLifecycleJournal('provision', request, options); + let mutation: 'none' | 'unknown' | 'applied' = 'none'; + let minted: MintedToken | undefined; + try { + const identity = await provider.readBasicIdentity(authority); + if (identity.login !== request.identity || authority.identity !== request.identity) { + await journal.seal('refused', 'provider-identity-mismatch'); + return lifecycleResult('provision', request, { + outcome: 'refused', + mutation: 'none', + code: 'provider-identity-mismatch', + message: 'Delegated Basic authority did not bind the requested principal.', + journalId: journal.journalId(), + auditState: 'sealed', + providerIdentity: identity.login, + }); + } + await journal.recordProviderEvidence({ + endpoint: identity.endpoint, + contentType: identity.contentType, + decision: 'identity-verified', + }); + mutation = 'unknown'; + minted = await provider.mintToken( + authority, + request.identity, + request.tokenName, + request.scopes, + ); + mutation = 'applied'; + await journal.recordMutation('token-mint-applied'); + const readBack = await provider.readToken(authority, request.identity, request.tokenName); + const expected = [...request.scopes].sort(); + const actual = [...readBack.scopes].sort(); + if (JSON.stringify(expected) !== JSON.stringify(actual)) { + await journal.seal('indeterminate', 'scope-not-evaluable'); + return lifecycleResult('provision', request, { + outcome: 'indeterminate', + mutation, + code: 'scope-not-evaluable', + message: 'Minted token scope read-back disagreed with the request.', + journalId: journal.journalId(), + auditState: 'sealed', + providerIdentity: identity.login, + token: readBack, + }); + } + await store.put( + { + identity: request.identity, + estate: request.estate, + host: request.host, + providerLogin: identity.login, + tokenName: request.tokenName, + scopes: readBack.scopes, + createdAt: options.now?.() ?? new Date().toISOString(), + }, + minted.secret, + ); + await journal.recordMutation('token-binding-stored'); + await teaStore.put(request.identity, request.host, minted.secret); + const teaLogin = teaStore.readBack(request.identity, request.host); + if (teaLogin === undefined) { + await journal.seal('indeterminate', 'tea-login-missing'); + return lifecycleResult('provision', request, { + outcome: 'indeterminate', + mutation: 'applied', + code: 'tea-login-missing', + message: 'Token was stored but the host-bound Tea login did not read back.', + journalId: journal.journalId(), + auditState: 'sealed', + providerIdentity: identity.login, + token: readBack, + }); + } + await journal.recordMutation('tea-login-stored'); + await journal.recordProviderEvidence({ + endpoint: readBack.endpoint, + contentType: readBack.contentType, + decision: 'scope-verified', + }); + await journal.seal('ok', 'provision-verified'); + return lifecycleResult('provision', request, { + outcome: 'ok', + mutation: 'applied', + code: 'provision-verified', + message: 'Provider principal and exact token scopes were read back and stored.', + journalId: journal.journalId(), + auditState: 'sealed', + providerIdentity: identity.login, + token: readBack, + teaLogin: { ...teaLogin, state: 'registered' }, + }); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) throw error; + const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown'; + await journal.seal('indeterminate', code); + return lifecycleResult('provision', request, { + outcome: 'indeterminate', + mutation, + code, + message: 'Provisioning did not produce complete provider and storage evidence.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } finally { + minted?.secret.fill(0); + } +} + +export async function revokeCredential( + request: LifecycleRequest, + authority: ResolvedCredential, + provider: GiteaLifecycleProvider, + store: FileCredentialStore, + options: LifecycleOptions, +): Promise { + const journal = await openLifecycleJournal('revoke', request, options); + let mutation: 'none' | 'unknown' | 'applied' = 'none'; + try { + const binding = await store.readBinding(request.identity, request.estate, request.host); + if (binding === undefined) { + await journal.seal('refused', 'no-token-for-identity'); + return lifecycleResult('revoke', request, { + outcome: 'refused', + mutation: 'none', + code: 'no-token-for-identity', + message: 'No governed token binding exists for the identity.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } + mutation = 'unknown'; + await provider.revokeToken(authority, request.identity, binding.tokenName); + mutation = 'applied'; + await journal.recordMutation('token-revoke-applied'); + await store.remove(request.identity, request.estate, request.host); + await journal.seal('ok', 'revoke-verified'); + return lifecycleResult('revoke', request, { + outcome: 'ok', + mutation, + code: 'revoke-verified', + message: 'Provider token revocation completed before local binding removal.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) throw error; + await journal.seal('indeterminate', 'mutation-state-unknown'); + return lifecycleResult('revoke', request, { + outcome: 'indeterminate', + mutation, + code: 'mutation-state-unknown', + message: 'Revocation mutation state could not be established completely.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } +} diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts new file mode 100644 index 00000000..5fb3cda9 --- /dev/null +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -0,0 +1,127 @@ +import { randomUUID } from 'node:crypto'; +import { open, rename } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { parse, stringify } from 'yaml'; +import { z } from 'zod'; +import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js'; + +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +export class TeaLoginStoreError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Tea login store rejected: code=${code} ${message}`); + this.name = 'TeaLoginStoreError'; + } +} + +interface TeaLoginRecord { + readonly name: string; + readonly url: string; + readonly token: string; + readonly user: string; + readonly default: boolean; +} + +interface TeaConfig { + readonly logins: TeaLoginRecord[]; +} + +const loginSchema = z + .object({ + name: z.string().regex(SAFE_NAME), + url: z.string().url(), + token: z.string().min(1), + user: z.string().regex(SAFE_NAME), + default: z.boolean().default(false), + }) + .passthrough(); +const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough(); + +function missing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +export class TeaLoginStore { + constructor(private readonly configPath: string) {} + + async put(identity: string, host: string, secret: Uint8Array): Promise { + if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) { + throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar'); + } + const directory = dirname(this.configPath); + ensureManagedDirectory(directory, directory); + let current: TeaConfig = { logins: [] }; + try { + const snapshot = readRegularFileSecure(this.configPath, { + root: directory, + maxBytes: 1024 * 1024, + }); + if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) { + throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private'); + } + const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); + if (!decoded.success) { + throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); + } + current = { logins: decoded.data.logins }; + } catch (error: unknown) { + if (!missing(error)) throw error; + } + const token = Buffer.from(secret).toString('utf8'); + const record: TeaLoginRecord = { + name: identity, + url: `https://${host}`, + token, + user: identity, + default: false, + }; + const logins = current.logins.filter((login): boolean => login.name !== identity); + logins.push(record); + const temp = `${this.configPath}.${randomUUID()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(stringify({ ...current, logins }), 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, this.configPath); + } + + readBack( + identity: string, + host: string, + ): { readonly name: string; readonly host: string } | undefined { + const directory = dirname(this.configPath); + let snapshot; + try { + snapshot = readRegularFileSecure(this.configPath, { root: directory, maxBytes: 1024 * 1024 }); + } catch (error: unknown) { + if (missing(error)) return undefined; + throw error; + } + const decoded: unknown = parse(snapshot.content.toString('utf8')); + if ( + typeof decoded !== 'object' || + decoded === null || + !('logins' in decoded) || + !Array.isArray(decoded.logins) + ) + return undefined; + const matches = decoded.logins.filter((value: unknown): value is TeaLoginRecord => { + if (typeof value !== 'object' || value === null) return false; + return ( + 'name' in value && + value.name === identity && + 'url' in value && + value.url === `https://${host}` && + 'user' in value && + value.user === identity + ); + }); + return matches.length === 1 ? { name: identity, host } : undefined; + } +} diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts index 30c8d80a..36d74a68 100644 --- a/packages/mosaic/src/credentials/team-grant.ts +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -112,7 +112,22 @@ export async function grantTeamRepositoryPermission( null, ); } + await journal.recordProviderEvidence({ + endpoint: authorityIdentity.endpoint, + contentType: authorityIdentity.contentType, + decision: 'identity-verified', + }); + await journal.recordProviderEvidence({ + endpoint: team.endpoint, + contentType: team.contentType, + decision: `permission-${team.permission}`, + }); const teamRepositorySet = await provider.listTeamRepositories(authority, team.id); + await journal.recordProviderEvidence({ + endpoint: teamRepositorySet.endpoint, + contentType: teamRepositorySet.contentType, + decision: 'team-repository-set-verified', + }); if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) { await journal.seal('refused', 'team-scope-exceeds-request'); return result( @@ -132,7 +147,9 @@ export async function grantTeamRepositoryPermission( mutation = 'unknown'; await provider.addTeamMember(authority, team.id, request.identity); mutation = 'applied'; + await journal.recordMutation('team-member-applied'); await provider.attachTeamRepository(authority, team.id, request.repo); + await journal.recordMutation('team-repository-applied'); 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( @@ -148,6 +165,33 @@ export async function grantTeamRepositoryPermission( request.permission === 'read' ? await evaluateGiteaReadValidation(request, dependencies) : await evaluateGiteaWriteValidation(request, dependencies); + if (organizationMembership !== null) { + await journal.recordProviderEvidence({ + endpoint: organizationMembership.endpoint, + contentType: organizationMembership.contentType, + decision: + organizationMembership.state === 'present' + ? 'organization-member-present' + : 'organization-member-absent', + }); + } + 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: `permission-${validation.evidence.repositoryPermission.effective}`, + }); + } + if (validation.evidence.writeDifferential !== null) { + await journal.recordMutation('transport-write-verified'); + } const ok = teamMembership.state === 'present' && teamRepository.state === 'present' && diff --git a/packages/mosaic/src/credentials/validate.spec.ts b/packages/mosaic/src/credentials/validate.spec.ts index 30705b9f..fe945f35 100644 --- a/packages/mosaic/src/credentials/validate.spec.ts +++ b/packages/mosaic/src/credentials/validate.spec.ts @@ -14,12 +14,12 @@ import { interface FixtureOptions { readonly subjectProviderIdentity?: string; - readonly subjectPermission?: 'read' | 'write' | 'admin'; + readonly subjectPermission?: 'none' | 'read' | 'write' | 'admin'; readonly subjectTransportState?: 'advertised' | 'refused'; readonly subjectTransportPrincipal?: string; readonly subjectTransportResolutionId?: string; readonly controlProviderIdentity?: string; - readonly controlPermission?: 'read' | 'write' | 'admin'; + readonly controlPermission?: 'none' | 'read' | 'write' | 'admin'; readonly controlTransportState?: 'advertised' | 'refused'; readonly controlTransportPrincipal?: string; readonly unauthenticatedTransportState?: 'advertised' | 'refused'; @@ -173,6 +173,17 @@ describe('Gitea read validation', (): void => { expect(observed.resolverCalls).toEqual([SUBJECT]); }); + it('refuses a repository object whose permission flags establish no read access', async (): Promise => { + const observed = fixture({ subjectPermission: 'none' }); + const result = await evaluateGiteaReadValidation( + { identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO }, + observed.dependencies, + ); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('permission-denied'); + }); + 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 => { @@ -226,6 +237,30 @@ describe('Gitea read validation', (): void => { }); describe('principal-bound Gitea write validation contract v1.1', (): void => { + it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise => { + const observed = fixture(); + observed.dependencies.provider.readIdentity = async (): Promise => { + throw new CredentialProviderEvidenceError( + 'identity-read-forbidden', + 'identity endpoint scope forbidden', + ); + }; + const result = await evaluateGiteaWriteValidation( + { + identity: SUBJECT, + estate: ESTATE, + host: HOST, + repo: REPO, + readOnlyControlIdentity: CONTROL, + }, + observed.dependencies, + ); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('identity-not-measured'); + expect(result.evidence.repositoryPermission?.effective).toBe('write'); + expect(observed.receivePackHandles).toEqual([expect.objectContaining({ identity: SUBJECT })]); + }); it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise => { const { result, observed } = await validate(); diff --git a/packages/mosaic/src/credentials/validate.ts b/packages/mosaic/src/credentials/validate.ts index bdce5153..d5581736 100644 --- a/packages/mosaic/src/credentials/validate.ts +++ b/packages/mosaic/src/credentials/validate.ts @@ -239,6 +239,16 @@ async function evaluateGiteaReadValidationUnsafe( evidence, ); } + if (repositoryPermission.effective === 'none') { + return refused( + request, + { + code: 'permission-denied', + message: 'The in-scope provider object denies repository access.', + }, + evidence, + ); + } return indeterminate( request, { @@ -273,6 +283,16 @@ async function evaluateGiteaReadValidationUnsafe( evidence, ); } + if (repositoryPermission.effective === 'none') { + return refused( + request, + { + code: 'permission-denied', + message: 'The provider repository object denies read permission.', + }, + evidence, + ); + } if (providerIdentity.login !== request.identity) { return refused( request, @@ -324,7 +344,60 @@ async function evaluateGiteaWriteValidationUnsafe( }); } - const subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); + let subjectEvidence: Awaited>; + try { + subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); + } catch (error: unknown) { + if ( + error instanceof CredentialProviderEvidenceError && + error.code === 'identity-read-forbidden' + ) { + const permission = await dependencies.provider.readRepositoryPermission( + resolved, + request.repo, + ); + const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); + const evidence: CredentialValidationEvidenceDto = { + providerIdentity: null, + repositoryPermission: permission, + writeDifferential: null, + }; + if (permission.effective === 'none' || permission.effective === 'read') { + return refused( + request, + { + code: 'permission-denied', + message: 'The in-scope provider object denies required write capability.', + }, + evidence, + ); + } + if ( + receivePack.principal !== request.identity || + receivePack.resolutionId !== resolved.resolutionId || + !advertised(receivePack) + ) { + return indeterminate( + request, + { + code: 'permission-evidence-disagrees', + message: 'In-scope repository and write transport evidence did not agree.', + }, + evidence, + ); + } + return indeterminate( + request, + { + code: 'identity-not-measured', + message: + 'Write capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.', + }, + evidence, + ); + } + throw error; + } const baseEvidence: CredentialValidationEvidenceDto = { providerIdentity: subjectEvidence.identity, repositoryPermission: subjectEvidence.permission, @@ -371,7 +444,10 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (subjectEvidence.permission.effective === 'read') { + if ( + subjectEvidence.permission.effective === 'read' || + subjectEvidence.permission.effective === 'none' + ) { return refused( request, { diff --git a/packages/mosaic/src/fleet/secure-file.ts b/packages/mosaic/src/fleet/secure-file.ts index 82f27201..e9042fb9 100644 --- a/packages/mosaic/src/fleet/secure-file.ts +++ b/packages/mosaic/src/fleet/secure-file.ts @@ -22,6 +22,8 @@ export interface SecureFileSnapshot { mode: number; dev: number | bigint; ino: number | bigint; + uid: number; + gid: number; } function sameIdentity( @@ -235,6 +237,8 @@ export function readRegularFileSecure( mode: Number(opened.mode), dev: opened.dev, ino: opened.ino, + uid: opened.uid, + gid: opened.gid, }; } finally { closeDescriptors(openedFile.descriptors); -- 2.54.0 From f705800353b2f20302790b32d1dc5b9a9abb627b Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:49:13 -0500 Subject: [PATCH 04/13] fix(mosaic): make credential lifecycle transactional --- docs/PRD.md | 2 +- .../framework/tools/git/detect-platform.sh | 5 + .../framework/tools/git/git-credential-mosaic | 6 ++ .../tools/git/test-git-credential-mosaic.sh | 11 +- .../tools/git/test-gitea-token-identity.sh | 9 ++ packages/mosaic/src/commands/cred.ts | 50 +++++++-- .../mosaic/src/credentials/audit-journal.ts | 2 + .../src/credentials/credential-binding.dto.ts | 1 + .../src/credentials/file-credential-store.ts | 82 +++++++++++++- .../mosaic/src/credentials/gitea-provider.ts | 33 ++++++ .../mosaic/src/credentials/lifecycle.spec.ts | 69 ++++++++++++ packages/mosaic/src/credentials/lifecycle.ts | 101 +++++++++++++----- .../mosaic/src/credentials/tea-login-store.ts | 61 ++++++++++- 13 files changed, 389 insertions(+), 43 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index ad4a8406..e6981f75 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -451,7 +451,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist 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. +3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. 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. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 214dbf67..01b6f0c6 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -516,6 +516,11 @@ get_gitea_token() { _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" _ident_src="git config mosaic.gitIdentity" fi + if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \ + "$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2 + return 1 + fi if [[ -n "$_ident" ]]; then local _idpfx="" case "$host" in diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index b0bffc4c..a1a1048c 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -32,6 +32,12 @@ done ident="$MOSAIC_GIT_IDENTITY" [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident="$username_in" +if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ + "$ident" "$MOSAIC_AGENT_NAME" "$host" >&2 + exit 1 +fi if [ -n "$ident" ]; then case "$host" in git.uscllc.com) idpfx=gitea-usc;; diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index 456d2f67..eb99cb86 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -103,6 +103,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to out=$(run_helper "git.mosaicstack.dev" "agentA") assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')" assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')" +set +e +out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-mismatch.stderr") +if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet identity override was not refused before token resolution" >&2 + fail=1 +fi # --------------------------------------------------------------------------- # 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username. @@ -131,7 +140,7 @@ assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^u assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" set +e -out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-missing.stderr") +out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr") rc=$? set -e err=$(cat "$WORK_DIR/fleet-missing.stderr") diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index 4c01116b..1db4bb39 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -111,6 +111,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to git -C "$REPO_DIR" config mosaic.gitIdentity agentA out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA) assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out" +set +e +out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-mismatch.stderr") +if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet identity override was not refused before token resolution" >&2 + fail=1 +fi # --------------------------------------------------------------------------- # 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index c677e7ae..210eab98 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,5 +1,5 @@ import { fstatSync, writeSync } from 'node:fs'; -import { open, readFile, rename } from 'node:fs/promises'; +import { open, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import type { Command } from 'commander'; @@ -486,7 +486,7 @@ export async function executeCredentialRotate( }); } const context = await lifecycleContext(options); - const old = await context.store.readBinding(identity, options.estate, options.host); + const old = await context.store.snapshot(identity, options.estate, options.host); if (old === undefined) { return localLifecycleResult('rotate', identity, options, { outcome: 'refused', @@ -510,16 +510,17 @@ export async function executeCredentialRotate( estate: options.estate, host: options.host, tokenName: options.tokenName, - scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean), + scopes: (options.scopes ?? old.binding.scopes.join(',')).split(',').filter(Boolean), }, authority, context.provider, context.store, context.teaStore, - { stateRoot: context.stateRoot, actor: options.actor }, + { stateRoot: context.stateRoot, actor: options.actor, allowReplace: true }, ); if (provisioned.outcome !== 'ok') { await journal.seal(provisioned.outcome, provisioned.reason.code); + old.secret.fill(0); return { ...provisioned, operation: 'rotate', @@ -527,9 +528,32 @@ export async function executeCredentialRotate( }; } await journal.recordMutation('token-mint-applied'); - await context.provider.revokeToken(authority, identity, old.tokenName); - await journal.recordMutation('token-revoke-applied'); + try { + await context.provider.revokeToken(authority, identity, old.binding.tokenName); + if (await context.provider.tokenExists(authority, identity, old.binding.tokenName)) { + throw new Error('old token still exists'); + } + await journal.recordMutation('token-revoke-applied'); + } catch { + await context.provider.revokeToken(authority, identity, options.tokenName); + if (await context.provider.tokenExists(authority, identity, options.tokenName)) { + throw new Error('replacement rollback could not be verified'); + } + await context.store.put(old.binding, old.secret); + await context.teaStore.put(identity, options.host, old.secret); + await journal.recordMutation('rotate-rollback-verified'); + await journal.seal('indeterminate', 'old-credential-preserved'); + old.secret.fill(0); + return localLifecycleResult('rotate', identity, options, { + outcome: 'indeterminate', + mutation: 'none', + code: 'old-credential-preserved', + message: 'Replacement failed; the previous credential remains the canonical binding.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } await journal.seal('ok', 'rotate-verified'); + old.secret.fill(0); return { ...provisioned, operation: 'rotate', @@ -571,7 +595,19 @@ export async function executeCredentialWire( }); await journal.recordIntent('wire-requested'); try { - const existing = await readFile(options.seatEnv, 'utf8').catch((): string => ''); + let existing = ''; + try { + const snapshot = readRegularFileSecure(options.seatEnv, { + root: dirname(options.seatEnv), + maxBytes: 1024 * 1024, + }); + if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) { + throw new Error('seat environment ownership or mode is unsafe'); + } + existing = snapshot.content.toString('utf8'); + } catch (error: unknown) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; + } const lines = existing .split(/\r?\n/) .filter( diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index e95431d3..71f75da1 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -48,6 +48,8 @@ const SAFE_DECISIONS = new Set([ 'token-mint-applied', 'token-binding-stored', 'tea-login-stored', + 'provision-rollback-verified', + 'rotate-rollback-verified', 'token-revoke-applied', 'wire-applied', 'credential-issued', diff --git a/packages/mosaic/src/credentials/credential-binding.dto.ts b/packages/mosaic/src/credentials/credential-binding.dto.ts index ac62bfcc..2d77eadb 100644 --- a/packages/mosaic/src/credentials/credential-binding.dto.ts +++ b/packages/mosaic/src/credentials/credential-binding.dto.ts @@ -7,4 +7,5 @@ export interface CredentialBindingMetadataDto { readonly tokenName: string; readonly scopes: readonly string[]; readonly createdAt: string; + readonly tokenDigest?: string; } diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 88ddf769..9f0cb93c 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { lstatSync } from 'node:fs'; import { open, readdir, rename, unlink } from 'node:fs/promises'; import { join } from 'node:path'; @@ -24,6 +24,10 @@ const bindingSchema = z tokenName: z.string().regex(IDENTITY), scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32), createdAt: z.string().datetime(), + tokenDigest: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), }) .strict(); @@ -110,6 +114,28 @@ export class FileCredentialResolver implements CredentialResolver { throw error; } + const bindingPath = join( + this.tokenDirectory, + `${hostConfig.tokenPrefix}-${identity}.binding.json`, + ); + try { + const bindingSnapshot = readRegularFileSecure(bindingPath, { + root: this.tokenDirectory, + maxBytes: 64 * 1024, + }); + const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8'))); + const digest = createHash('sha256').update(snapshot.content).digest('hex'); + if (!binding.success || binding.data.tokenDigest !== digest) { + throw new CredentialStoreError( + 'credential-generation-mismatch', + 'token and binding metadata are not one committed generation', + ); + } + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + // Legacy token files predate binding metadata and remain readable until rotated. + } + const permissions = snapshot.mode & 0o777; if (snapshot.uid !== currentUid) { throw new CredentialStoreError( @@ -180,10 +206,24 @@ export class FileCredentialStore { const paths = this.paths(metadata.identity, metadata.estate, metadata.host); ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory); const token = validateSecret(Buffer.from(secret)); - const binding = bindingSchema.parse({ ...metadata, schemaVersion: 1 }); + const binding = bindingSchema.parse({ + ...metadata, + schemaVersion: 1, + tokenDigest: createHash('sha256').update(token).digest('hex'), + }); const suffix = randomUUID(); const tokenTemp = `${paths.token}.${suffix}.tmp`; const bindingTemp = `${paths.binding}.${suffix}.tmp`; + const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`); + let lock; + try { + lock = await open(lockPath, 'wx', 0o600); + } catch { + throw new CredentialStoreError( + 'conflicting-credential-mutation', + 'another mutation owns the identity lock', + ); + } const tokenHandle = await open(tokenTemp, 'wx', 0o600); const bindingHandle = await open(bindingTemp, 'wx', 0o600); try { @@ -195,9 +235,41 @@ export class FileCredentialStore { await tokenHandle.close(); await bindingHandle.close(); } - await rename(bindingTemp, paths.binding); - await rename(tokenTemp, paths.token); - await syncDirectory(this.tokenDirectory); + try { + await rename(bindingTemp, paths.binding); + await rename(tokenTemp, paths.token); + await syncDirectory(this.tokenDirectory); + } finally { + await lock.close(); + await unlink(lockPath).catch((): void => undefined); + await syncDirectory(this.tokenDirectory); + } + } + + async snapshot( + identity: string, + estate: string, + host: string, + ): Promise< + | { + readonly binding: CredentialBindingMetadataDto; + readonly secret: Uint8Array; + } + | undefined + > { + const binding = await this.readBinding(identity, estate, host); + if (binding === undefined) return undefined; + const resolved = await new FileCredentialResolver( + this.tokenDirectory, + this.estateRegistry, + ).resolve(identity, estate, host); + if (resolved === undefined) { + throw new CredentialStoreError( + 'invalid-binding', + 'binding metadata exists without its token generation', + ); + } + return { binding, secret: new Uint8Array(resolved.secret) }; } async readBinding( diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index 5492b7f5..db0bef6e 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -812,6 +812,39 @@ export class GiteaLifecycleProviderAdapter }; } + async tokenExists( + authority: ResolvedCredential, + identity: string, + name: string, + ): Promise { + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`, + { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'token absence read-back failed', + ); + } + const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'token absence read-back was malformed', + ); + } + return parsed.data.some((token): boolean => token.name === name); + } + async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise { const response = await this.request( `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`, diff --git a/packages/mosaic/src/credentials/lifecycle.spec.ts b/packages/mosaic/src/credentials/lifecycle.spec.ts index cce91727..a695cf17 100644 --- a/packages/mosaic/src/credentials/lifecycle.spec.ts +++ b/packages/mosaic/src/credentials/lifecycle.spec.ts @@ -81,6 +81,9 @@ function provider(): GiteaLifecycleProvider { }; }, async revokeToken(): Promise {}, + async tokenExists(): Promise { + return false; + }, }; } @@ -108,6 +111,42 @@ describe('credential lifecycle', (): void => { expect(JSON.stringify(result)).not.toContain('minted-token-canary'); }); + it('rolls back a minted token when exact scope read-back disagrees', async (): Promise => { + const { root, store, teaStore } = await fixture(); + let revoked = false; + const lifecycleProvider = provider(); + lifecycleProvider.readToken = async (_authority, _identity, name) => ({ + name, + scopes: ['admin'], + endpoint: 'GET /api/v1/users/seat/tokens', + contentType: 'application/json', + }); + lifecycleProvider.revokeToken = async (): Promise => { + revoked = true; + }; + lifecycleProvider.tokenExists = async (): Promise => false; + + const result = await provisionCredential( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + tokenName: 'mosaic-seat-bad', + scopes: ['write:repository'], + }, + authority, + lifecycleProvider, + store, + teaStore, + { stateRoot: root, actor: 'seat' }, + ); + + expect(result.outcome).toBe('error'); + expect(result.mutation).toBe('none'); + expect(revoked).toBe(true); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); + }); + it('revokes at provider before removing the local binding', async (): Promise => { const { root, store, teaStore } = await fixture(); await provisionCredential( @@ -140,4 +179,34 @@ describe('credential lifecycle', (): void => { expect(revoked).toBe(true); await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); }); + + it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise => { + const { root, store, teaStore } = await fixture(); + await provisionCredential( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + tokenName: 'mosaic-seat-1', + scopes: ['write:repository'], + }, + authority, + provider(), + store, + teaStore, + { stateRoot: root, actor: 'seat' }, + ); + const lifecycleProvider = provider(); + lifecycleProvider.tokenExists = async (): Promise => true; + const result = await revokeCredential( + { identity: 'seat', estate: 'homelab', host: 'git.example.invalid' }, + authority, + lifecycleProvider, + store, + { stateRoot: root, actor: 'seat' }, + ); + + expect(result.outcome).toBe('indeterminate'); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']); + }); }); diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 406ef554..3148e51b 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -31,6 +31,7 @@ export interface GiteaLifecycleProvider { name: string, ): Promise; revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise; + tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise; } export interface LifecycleRequest { @@ -48,6 +49,7 @@ export interface LifecycleOptions { readonly stateRoot: string; readonly actor: string; readonly now?: () => string; + readonly allowReplace?: boolean; } function lifecycleResult( @@ -114,6 +116,19 @@ export async function provisionCredential( const journal = await openLifecycleJournal('provision', request, options); let mutation: 'none' | 'unknown' | 'applied' = 'none'; let minted: MintedToken | undefined; + let failureCode = 'mutation-state-unknown'; + const prior = await store.snapshot(request.identity, request.estate, request.host); + if (prior !== undefined && options.allowReplace !== true) { + await journal.seal('refused', 'credential-already-exists'); + return lifecycleResult('provision', request, { + outcome: 'refused', + mutation: 'none', + code: 'credential-already-exists', + message: 'A governed credential already exists; use rotate.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } try { const identity = await provider.readBasicIdentity(authority); if (identity.login !== request.identity || authority.identity !== request.identity) { @@ -146,17 +161,8 @@ export async function provisionCredential( const expected = [...request.scopes].sort(); const actual = [...readBack.scopes].sort(); if (JSON.stringify(expected) !== JSON.stringify(actual)) { - await journal.seal('indeterminate', 'scope-not-evaluable'); - return lifecycleResult('provision', request, { - outcome: 'indeterminate', - mutation, - code: 'scope-not-evaluable', - message: 'Minted token scope read-back disagreed with the request.', - journalId: journal.journalId(), - auditState: 'sealed', - providerIdentity: identity.login, - token: readBack, - }); + failureCode = 'scope-not-evaluable'; + throw new Error('scope read-back disagreed'); } await store.put( { @@ -173,18 +179,12 @@ export async function provisionCredential( await journal.recordMutation('token-binding-stored'); await teaStore.put(request.identity, request.host, minted.secret); const teaLogin = teaStore.readBack(request.identity, request.host); - if (teaLogin === undefined) { - await journal.seal('indeterminate', 'tea-login-missing'); - return lifecycleResult('provision', request, { - outcome: 'indeterminate', - mutation: 'applied', - code: 'tea-login-missing', - message: 'Token was stored but the host-bound Tea login did not read back.', - journalId: journal.journalId(), - auditState: 'sealed', - providerIdentity: identity.login, - token: readBack, - }); + if ( + teaLogin === undefined || + !teaStore.matchesSecret(request.identity, request.host, minted.secret) + ) { + failureCode = 'tea-login-missing'; + throw new Error('Tea login did not resolve exactly'); } await journal.recordMutation('tea-login-stored'); await journal.recordProviderEvidence({ @@ -206,18 +206,52 @@ export async function provisionCredential( }); } catch (error: unknown) { if (error instanceof CredentialJournalError) throw error; - const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown'; - await journal.seal('indeterminate', code); + if (minted === undefined) { + await journal.seal('indeterminate', 'provider-unavailable'); + return lifecycleResult('provision', request, { + outcome: 'indeterminate', + mutation, + code: 'provider-unavailable', + message: 'Provider token mint did not complete.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } + let rollbackComplete = false; + try { + await provider.revokeToken(authority, request.identity, request.tokenName); + if (await provider.tokenExists(authority, request.identity, request.tokenName)) { + throw new Error('minted token still exists after rollback'); + } + if (prior === undefined) { + await store.remove(request.identity, request.estate, request.host); + await teaStore.remove(request.identity, request.host).catch((): void => undefined); + } else { + await store.put(prior.binding, prior.secret); + await teaStore.put(request.identity, request.host, prior.secret); + } + rollbackComplete = true; + await journal.recordMutation('provision-rollback-verified'); + } catch { + rollbackComplete = false; + } finally { + prior?.secret.fill(0); + } + const code = rollbackComplete ? failureCode : 'rollback-incomplete'; + await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code); return lifecycleResult('provision', request, { - outcome: 'indeterminate', - mutation, + outcome: rollbackComplete ? 'error' : 'indeterminate', + mutation: rollbackComplete ? 'none' : mutation, code, - message: 'Provisioning did not produce complete provider and storage evidence.', + message: rollbackComplete + ? 'Provisioning failed and every completed mutation was rolled back.' + : 'Provisioning failed and rollback could not be proven complete.', journalId: journal.journalId(), auditState: 'sealed', }); } finally { minted?.secret.fill(0); + prior?.secret.fill(0); } } @@ -247,6 +281,17 @@ export async function revokeCredential( await provider.revokeToken(authority, request.identity, binding.tokenName); mutation = 'applied'; await journal.recordMutation('token-revoke-applied'); + if (await provider.tokenExists(authority, request.identity, binding.tokenName)) { + await journal.seal('indeterminate', 'revoke-readback-missing'); + return lifecycleResult('revoke', request, { + outcome: 'indeterminate', + mutation, + code: 'revoke-readback-missing', + message: 'Provider still returned the token after revocation acknowledgement.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } await store.remove(request.identity, request.estate, request.host); await journal.seal('ok', 'revoke-verified'); return lifecycleResult('revoke', request, { diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 5fb3cda9..67cb3ee4 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -1,9 +1,10 @@ -import { randomUUID } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import { open, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; import { parse, stringify } from 'yaml'; import { z } from 'zod'; import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js'; +import type { ResolvedCredential } from './credential-provider.dto.js'; const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; @@ -91,6 +92,64 @@ export class TeaLoginStore { await rename(temp, this.configPath); } + resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined { + const directory = dirname(this.configPath); + let snapshot; + try { + snapshot = readRegularFileSecure(this.configPath, { + root: directory, + maxBytes: 1024 * 1024, + }); + } catch (error: unknown) { + if (missing(error)) return undefined; + throw error; + } + const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); + if (!decoded.success) return undefined; + const matches = decoded.data.logins.filter( + (login): boolean => + login.name === identity && login.url === `https://${host}` && login.user === identity, + ); + if (matches.length !== 1 || matches[0] === undefined) return undefined; + return Object.freeze({ + identity, + estate, + host, + resolutionId: randomUUID(), + secret: new TextEncoder().encode(matches[0].token), + }); + } + + matchesSecret(identity: string, host: string, secret: Uint8Array): boolean { + const resolved = this.resolve(identity, 'binding-check', host); + if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false; + return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret)); + } + + async remove(identity: string, host: string): Promise { + const directory = dirname(this.configPath); + const snapshot = readRegularFileSecure(this.configPath, { + root: directory, + maxBytes: 1024 * 1024, + }); + const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); + if (!decoded.success) { + throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); + } + const logins = decoded.data.logins.filter( + (login): boolean => !(login.name === identity && login.url === `https://${host}`), + ); + const temp = `${this.configPath}.${randomUUID()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(stringify({ logins }), 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, this.configPath); + } + readBack( identity: string, host: string, -- 2.54.0 From 59cde3d199ab5eb631d2b181a72ebba79a90f70b Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:57:19 -0500 Subject: [PATCH 05/13] fix(mosaic): bind lifecycle across storage and runtime --- .../framework/tools/git/detect-platform.sh | 22 +++ .../framework/tools/git/git-credential-mosaic | 22 +++ packages/mosaic/src/commands/cred.ts | 15 ++ .../mosaic/src/credentials/audit-journal.ts | 2 + .../src/credentials/file-credential-store.ts | 119 +++++++++++++--- .../mosaic/src/credentials/lifecycle.spec.ts | 2 + packages/mosaic/src/credentials/lifecycle.ts | 6 + .../src/credentials/tea-login-store.spec.ts | 35 +++++ .../mosaic/src/credentials/tea-login-store.ts | 131 +++++++++++------- 9 files changed, 286 insertions(+), 68 deletions(-) create mode 100644 packages/mosaic/src/credentials/tea-login-store.spec.ts diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 01b6f0c6..32b2cca4 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -529,7 +529,29 @@ get_gitea_token() { esac if [[ -n "$_idpfx" ]]; then local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" + local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json" + if [[ -r "$_idcred" ]]; then + local _resolved_token + _resolved_token=$(python3 - "$_idcred" <<'PY' +import json, sys +value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") +if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): + raise SystemExit(1) +print(value) +PY +) || return 1 + if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \ + "$_ident" "$host" "$_ident_src" >&2 + fi + printf '%s\n' "$_resolved_token" + return 0 + fi if [[ -r "$_idtok" ]]; then + if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \ + "$_ident" "$host" "$_ident_src" >&2 + fi cat "$_idtok" return 0 fi diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index a1a1048c..95e031c9 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -46,7 +46,29 @@ if [ -n "$ident" ]; then esac if [ -n "$idpfx" ]; then idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" + idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" + if [ -r "$idcred" ]; then + token=$(python3 - "$idcred" <<'PY' +import json, sys +value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") +if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): + raise SystemExit(1) +print(value) +PY +) || exit 1 + if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ + "$ident" "$host" >&2 + fi + echo "username=${ident}" + echo "password=${token}" + exit 0 + fi if [ -r "$idtok" ]; then + if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ + "$ident" "$host" >&2 + fi echo "username=${ident}" echo "password=$(cat "$idtok")" exit 0 diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 210eab98..318dacc5 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -462,6 +462,7 @@ export async function executeCredentialRevoke( authority, context.provider, context.store, + context.teaStore, { stateRoot: context.stateRoot, actor: options.actor }, ); } catch { @@ -660,6 +661,19 @@ export async function executeCredentialGet( }); await journal.recordIntent('get-requested'); try { + if ( + process.env['MOSAIC_AGENT_NAME'] === undefined || + process.env['MOSAIC_AGENT_NAME'] !== identity || + options.actor !== identity + ) { + await journal.seal('refused', 'provider-identity-mismatch'); + return localLifecycleResult('get', identity, options, { + outcome: 'refused', + code: 'provider-identity-mismatch', + message: 'Runtime fleet identity, explicit actor, and requested identity must match.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } const fd = Number(options.outputFd); const stat = fstatSync(fd); if ( @@ -687,6 +701,7 @@ export async function executeCredentialGet( const prefix = new TextEncoder().encode( `protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`, ); + await journal.recordMutation('credential-issuance-authorized'); writeSync(fd, prefix); writeSync(fd, resolved.secret); writeSync(fd, new TextEncoder().encode('\n\n')); diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index 71f75da1..ca85f923 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -48,10 +48,12 @@ const SAFE_DECISIONS = new Set([ 'token-mint-applied', 'token-binding-stored', 'tea-login-stored', + 'tea-login-removed', 'provision-rollback-verified', 'rotate-rollback-verified', 'token-revoke-applied', 'wire-applied', + 'credential-issuance-authorized', 'credential-issued', 'classification-correction', ]); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 9f0cb93c..fda7d725 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -30,6 +30,9 @@ const bindingSchema = z .optional(), }) .strict(); +const credentialEnvelopeSchema = bindingSchema.extend({ + token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/), +}); export class CredentialStoreError extends Error { constructor( @@ -102,6 +105,47 @@ export class FileCredentialResolver implements CredentialResolver { ); } + const envelopePath = join( + this.tokenDirectory, + `${hostConfig.tokenPrefix}-${identity}.credential.json`, + ); + try { + const envelopeSnapshot = readRegularFileSecure(envelopePath, { + root: this.tokenDirectory, + maxBytes: 64 * 1024, + }); + const envelope = credentialEnvelopeSchema.safeParse( + JSON.parse(envelopeSnapshot.content.toString('utf8')), + ); + if ( + !envelope.success || + envelopeSnapshot.uid !== process.getuid?.() || + (envelopeSnapshot.mode & 0o077) !== 0 + ) { + throw new CredentialStoreError( + 'invalid-binding', + 'credential envelope failed schema, owner, or mode validation', + ); + } + const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8')); + const digest = createHash('sha256').update(secret).digest('hex'); + if (envelope.data.tokenDigest !== digest) { + throw new CredentialStoreError( + 'credential-generation-mismatch', + 'credential envelope digest does not match its token', + ); + } + return Object.freeze({ + identity, + estate, + host, + resolutionId: randomUUID(), + secret, + }); + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + } + const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`); let snapshot: SecureFileSnapshot; try { @@ -182,6 +226,7 @@ export class FileCredentialStore { ): { readonly token: string; readonly binding: string; + readonly envelope: string; readonly prefix: string; } { if (!IDENTITY.test(identity)) { @@ -198,6 +243,7 @@ export class FileCredentialStore { return { token: join(this.tokenDirectory, `${prefix}.token`), binding: join(this.tokenDirectory, `${prefix}.binding.json`), + envelope: join(this.tokenDirectory, `${prefix}.credential.json`), prefix, }; } @@ -206,14 +252,14 @@ export class FileCredentialStore { const paths = this.paths(metadata.identity, metadata.estate, metadata.host); ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory); const token = validateSecret(Buffer.from(secret)); - const binding = bindingSchema.parse({ + const envelope = credentialEnvelopeSchema.parse({ ...metadata, schemaVersion: 1, tokenDigest: createHash('sha256').update(token).digest('hex'), + token: Buffer.from(token).toString('utf8'), }); const suffix = randomUUID(); - const tokenTemp = `${paths.token}.${suffix}.tmp`; - const bindingTemp = `${paths.binding}.${suffix}.tmp`; + const envelopeTemp = `${paths.envelope}.${suffix}.tmp`; const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`); let lock; try { @@ -224,23 +270,19 @@ export class FileCredentialStore { 'another mutation owns the identity lock', ); } - const tokenHandle = await open(tokenTemp, 'wx', 0o600); - const bindingHandle = await open(bindingTemp, 'wx', 0o600); try { - await tokenHandle.writeFile(token); - await tokenHandle.sync(); - await bindingHandle.writeFile(`${JSON.stringify(binding)}\n`, 'utf8'); - await bindingHandle.sync(); - } finally { - await tokenHandle.close(); - await bindingHandle.close(); - } - try { - await rename(bindingTemp, paths.binding); - await rename(tokenTemp, paths.token); + const handle = await open(envelopeTemp, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(envelopeTemp, paths.envelope); await syncDirectory(this.tokenDirectory); } finally { await lock.close(); + await unlink(envelopeTemp).catch((): void => undefined); await unlink(lockPath).catch((): void => undefined); await syncDirectory(this.tokenDirectory); } @@ -279,6 +321,31 @@ export class FileCredentialStore { ): Promise { const paths = this.paths(identity, estate, host); let snapshot: SecureFileSnapshot; + try { + const envelope = readRegularFileSecure(paths.envelope, { + root: this.tokenDirectory, + maxBytes: 64 * 1024, + }); + const parsed = credentialEnvelopeSchema.safeParse( + JSON.parse(envelope.content.toString('utf8')), + ); + if (!parsed.success) { + throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed'); + } + return { + schemaVersion: 1, + identity: parsed.data.identity, + estate: parsed.data.estate, + host: parsed.data.host, + providerLogin: parsed.data.providerLogin, + tokenName: parsed.data.tokenName, + scopes: parsed.data.scopes, + createdAt: parsed.data.createdAt, + tokenDigest: parsed.data.tokenDigest, + }; + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + } try { snapshot = readRegularFileSecure(paths.binding, { root: this.tokenDirectory, @@ -306,9 +373,20 @@ export class FileCredentialStore { if (config === undefined) return []; const names = await readdir(this.tokenDirectory); const prefix = `${config.tokenPrefix}-`; - return names - .filter((name): boolean => name.startsWith(prefix) && name.endsWith('.token')) - .map((name): string => name.slice(prefix.length, -'.token'.length)) + return [ + ...new Set( + names.flatMap((name): string[] => { + if (!name.startsWith(prefix)) return []; + if (name.endsWith('.token')) { + return [name.slice(prefix.length, -'.token'.length)]; + } + if (name.endsWith('.credential.json')) { + return [name.slice(prefix.length, -'.credential.json'.length)]; + } + return []; + }), + ), + ] .filter((identity): boolean => IDENTITY.test(identity)) .sort(); } @@ -321,6 +399,9 @@ export class FileCredentialStore { await unlink(paths.binding).catch((error: unknown): void => { if (!isMissingFile(error)) throw error; }); + await unlink(paths.envelope).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); await syncDirectory(this.tokenDirectory); } } diff --git a/packages/mosaic/src/credentials/lifecycle.spec.ts b/packages/mosaic/src/credentials/lifecycle.spec.ts index a695cf17..858b5c40 100644 --- a/packages/mosaic/src/credentials/lifecycle.spec.ts +++ b/packages/mosaic/src/credentials/lifecycle.spec.ts @@ -173,6 +173,7 @@ describe('credential lifecycle', (): void => { authority, lifecycleProvider, store, + teaStore, { stateRoot: root, actor: 'seat' }, ); expect(result.outcome).toBe('ok'); @@ -203,6 +204,7 @@ describe('credential lifecycle', (): void => { authority, lifecycleProvider, store, + teaStore, { stateRoot: root, actor: 'seat' }, ); diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 3148e51b..762efd48 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -260,6 +260,7 @@ export async function revokeCredential( authority: ResolvedCredential, provider: GiteaLifecycleProvider, store: FileCredentialStore, + teaStore: TeaLoginStore, options: LifecycleOptions, ): Promise { const journal = await openLifecycleJournal('revoke', request, options); @@ -292,6 +293,11 @@ export async function revokeCredential( auditState: 'sealed', }); } + await teaStore.remove(request.identity, request.host); + if (teaStore.readBack(request.identity, request.host) !== undefined) { + throw new Error('Tea login still exists after revocation'); + } + await journal.recordMutation('tea-login-removed'); await store.remove(request.identity, request.estate, request.host); await journal.seal('ok', 'revoke-verified'); return lifecycleResult('revoke', request, { diff --git a/packages/mosaic/src/credentials/tea-login-store.spec.ts b/packages/mosaic/src/credentials/tea-login-store.spec.ts new file mode 100644 index 00000000..6ab225cd --- /dev/null +++ b/packages/mosaic/src/credentials/tea-login-store.spec.ts @@ -0,0 +1,35 @@ +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 { TeaLoginStore } from './tea-login-store.js'; + +let root: string | undefined; +afterEach(async (): Promise => { + if (root !== undefined) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +describe('host-bound Tea login store', (): void => { + it('serializes concurrent updates and preserves the same identity on two hosts', async (): Promise => { + root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-')); + const store = new TeaLoginStore(join(root, 'tea', 'config.yml')); + await Promise.all([ + store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')), + store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')), + ]); + + expect( + store.matchesSecret('seat', 'git.one.invalid', new TextEncoder().encode('token-one')), + ).toBe(true); + expect( + store.matchesSecret('seat', 'git.two.invalid', new TextEncoder().encode('token-two')), + ).toBe(true); + await store.remove('seat', 'git.one.invalid'); + expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined(); + expect(store.readBack('seat', 'git.two.invalid')).toEqual({ + name: 'seat', + host: 'git.two.invalid', + }); + }); +}); diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 67cb3ee4..88173184 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -1,5 +1,5 @@ import { randomUUID, timingSafeEqual } from 'node:crypto'; -import { open, rename } from 'node:fs/promises'; +import { open, rename, unlink } from 'node:fs/promises'; import { dirname } from 'node:path'; import { parse, stringify } from 'yaml'; import { z } from 'zod'; @@ -41,6 +41,23 @@ const loginSchema = z .passthrough(); const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough(); +async function acquireLock(path: string): Promise>> { + for (let attempt = 0; attempt < 500; attempt += 1) { + try { + return await open(path, 'wx', 0o600); + } catch (error: unknown) { + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error; + await new Promise((resolve): void => { + setTimeout(resolve, 10); + }); + } + } + throw new TeaLoginStoreError( + 'conflicting-credential-mutation', + 'Tea configuration lock did not become available', + ); +} + function missing(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } @@ -54,42 +71,51 @@ export class TeaLoginStore { } const directory = dirname(this.configPath); ensureManagedDirectory(directory, directory); - let current: TeaConfig = { logins: [] }; + const lockPath = `${this.configPath}.lock`; + const lock = await acquireLock(lockPath); try { - const snapshot = readRegularFileSecure(this.configPath, { - root: directory, - maxBytes: 1024 * 1024, - }); - if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) { - throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private'); + let current: TeaConfig = { logins: [] }; + try { + const snapshot = readRegularFileSecure(this.configPath, { + root: directory, + maxBytes: 1024 * 1024, + }); + if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) { + throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private'); + } + const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); + if (!decoded.success) { + throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); + } + current = { logins: decoded.data.logins }; + } catch (error: unknown) { + if (!missing(error)) throw error; } - const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); - if (!decoded.success) { - throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); + const token = Buffer.from(secret).toString('utf8'); + const record: TeaLoginRecord = { + name: identity, + url: `https://${host}`, + token, + user: identity, + default: false, + }; + const logins = current.logins.filter( + (login): boolean => !(login.name === identity && login.url === `https://${host}`), + ); + logins.push(record); + const temp = `${this.configPath}.${randomUUID()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(stringify({ ...current, logins }), 'utf8'); + await handle.sync(); + } finally { + await handle.close(); } - current = { logins: decoded.data.logins }; - } catch (error: unknown) { - if (!missing(error)) throw error; - } - const token = Buffer.from(secret).toString('utf8'); - const record: TeaLoginRecord = { - name: identity, - url: `https://${host}`, - token, - user: identity, - default: false, - }; - const logins = current.logins.filter((login): boolean => login.name !== identity); - logins.push(record); - const temp = `${this.configPath}.${randomUUID()}.tmp`; - const handle = await open(temp, 'wx', 0o600); - try { - await handle.writeFile(stringify({ ...current, logins }), 'utf8'); - await handle.sync(); + await rename(temp, this.configPath); } finally { - await handle.close(); + await lock.close(); + await unlink(lockPath).catch((): void => undefined); } - await rename(temp, this.configPath); } resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined { @@ -128,26 +154,33 @@ export class TeaLoginStore { async remove(identity: string, host: string): Promise { const directory = dirname(this.configPath); - const snapshot = readRegularFileSecure(this.configPath, { - root: directory, - maxBytes: 1024 * 1024, - }); - const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); - if (!decoded.success) { - throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); - } - const logins = decoded.data.logins.filter( - (login): boolean => !(login.name === identity && login.url === `https://${host}`), - ); - const temp = `${this.configPath}.${randomUUID()}.tmp`; - const handle = await open(temp, 'wx', 0o600); + const lockPath = `${this.configPath}.lock`; + const lock = await acquireLock(lockPath); try { - await handle.writeFile(stringify({ logins }), 'utf8'); - await handle.sync(); + const snapshot = readRegularFileSecure(this.configPath, { + root: directory, + maxBytes: 1024 * 1024, + }); + const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); + if (!decoded.success) { + throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); + } + const logins = decoded.data.logins.filter( + (login): boolean => !(login.name === identity && login.url === `https://${host}`), + ); + const temp = `${this.configPath}.${randomUUID()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(stringify({ logins }), 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, this.configPath); } finally { - await handle.close(); + await lock.close(); + await unlink(lockPath).catch((): void => undefined); } - await rename(temp, this.configPath); } readBack( -- 2.54.0 From 178a7b7117359f5c006a3f7b6b41a407e1e8b3eb Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:10:47 -0500 Subject: [PATCH 06/13] fix(mosaic): close credential evidence gaps --- docs/PRD.md | 4 +- docs/credentials/GRANT-VALIDATE-CONTRACT.md | 24 ++- .../framework/tools/git/detect-platform.sh | 32 ++-- .../framework/tools/git/git-credential-mosaic | 31 ++-- .../tools/git/resolve-credential-envelope.py | 78 ++++++++++ .../tools/git/test-git-credential-mosaic.sh | 45 +++++- packages/mosaic/src/commands/cred.ts | 146 ++++++++++++++---- .../mosaic/src/credentials/audit-journal.ts | 1 + .../src/credentials/credential-result.dto.ts | 7 + .../credentials/file-credential-store.spec.ts | 14 +- .../src/credentials/file-credential-store.ts | 11 ++ packages/mosaic/src/credentials/grant.ts | 10 ++ .../src/credentials/tea-login-store.spec.ts | 21 ++- .../mosaic/src/credentials/tea-login-store.ts | 18 ++- packages/mosaic/src/credentials/team-grant.ts | 5 + packages/mosaic/src/credentials/validate.ts | 11 ++ 16 files changed, 373 insertions(+), 85 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/resolve-credential-envelope.py diff --git a/docs/PRD.md b/docs/PRD.md index e6981f75..981f70b7 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -470,8 +470,8 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist 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. +3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope and repo grant cases cannot return `ok`; organization/team membership and team-repository attachment are additionally acceptance-bearing for team grants. A direct collaborator grant reports organization membership but does not require it, because direct collaborator permission and organization membership are intentionally independent provider layers. +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, or provider outage returns `indeterminate`, never success or policy refusal. Runtime exact scope is reported independently as `not-measured` when the current seat credential is not authorized to read its provider token object; NOT-MEASURED is neither pass nor failure and does not erase confirmed repository capability. Exact scope is acceptance-bearing at provision/rotate time, where delegated mint authority can read the token object. 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. diff --git a/docs/credentials/GRANT-VALIDATE-CONTRACT.md b/docs/credentials/GRANT-VALIDATE-CONTRACT.md index 10133059..51a28cb7 100644 --- a/docs/credentials/GRANT-VALIDATE-CONTRACT.md +++ b/docs/credentials/GRANT-VALIDATE-CONTRACT.md @@ -69,7 +69,11 @@ Rules: "endpoint": "GET /api/v1/user", "contentType": "application/json" }, - "tokenCapabilities": [], + "tokenCapabilities": { + "state": "not-measured", + "scopes": [], + "source": "runtime-not-authorized" + }, "repositoryPermission": { "requested": "write", "effective": "write", @@ -109,12 +113,12 @@ Fields may be `null` only when their enclosing evidence state explains why. Miss ## 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. | +| 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, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. Runtime scope `not-measured` remains a separately reported axis and is neither pass nor failure. | `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. @@ -142,7 +146,11 @@ A refusal is intentionally recognizable without prose: }, "evidence": { "providerIdentity": null, - "tokenCapabilities": [], + "tokenCapabilities": { + "state": "not-measured", + "scopes": [], + "source": "runtime-not-authorized" + }, "repositoryPermission": null, "organizationMembership": null, "teamMembership": null, diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 32b2cca4..0048f682 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -499,6 +499,15 @@ get_gitea_url_for_host() { # Resolve a Gitea API token for the given host. # Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials +_trace_credential_resolution() { + [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]] || return 0 + local resolution_path="$1" reason="$2" identity="$3" host="$4" source="$5" + local shared_path_entered=false + [[ "$resolution_path" == shared ]] && shared_path_entered=true + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \ + "$reason" "$identity" "$host" "$resolution_path" "$shared_path_entered" "$source" >&2 +} + get_gitea_token() { local host="$1" local script_dir @@ -532,26 +541,14 @@ get_gitea_token() { local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json" if [[ -r "$_idcred" ]]; then local _resolved_token - _resolved_token=$(python3 - "$_idcred" <<'PY' -import json, sys -value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") -if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): - raise SystemExit(1) -print(value) -PY -) || return 1 - if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \ - "$_ident" "$host" "$_ident_src" >&2 - fi + _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \ + "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 + _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src" printf '%s\n' "$_resolved_token" return 0 fi if [[ -r "$_idtok" ]]; then - if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \ - "$_ident" "$host" "$_ident_src" >&2 - fi + _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src" cat "$_idtok" return 0 fi @@ -607,6 +604,7 @@ PY echo "${GITEA_TOKEN:-}" ) if [[ -n "$token" ]]; then + _trace_credential_resolution shared shared-credential-resolved '' "$host" credentials-loader echo "$token" return 0 fi @@ -615,6 +613,7 @@ PY # 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host) if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then + _trace_credential_resolution shared shared-credential-resolved '' "$host" environment echo "$GITEA_TOKEN" return 0 fi @@ -626,6 +625,7 @@ PY local token token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1) if [[ -n "$token" ]]; then + _trace_credential_resolution shared shared-credential-resolved '' "$host" git-credentials echo "$token" return 0 fi diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 95e031c9..4ad2d975 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -24,6 +24,15 @@ while IFS= read -r line; do username=*) username_in=${line#username=};; esac done +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +trace_resolution() { + [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ] || return 0 + resolution_path="$1" reason="$2" trace_identity="$3" trace_host="$4" source="$5" + shared_path_entered=false + [ "$resolution_path" = shared ] && shared_path_entered=true + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \ + "$reason" "$trace_identity" "$trace_host" "$resolution_path" "$shared_path_entered" "$source" >&2 +} # Per-agent identity resolution (Gate-16 author≠reviewer separation). # Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree, # survives across non-persistent shells) > git-supplied username (credential.username @@ -48,27 +57,15 @@ if [ -n "$ident" ]; then idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" if [ -r "$idcred" ]; then - token=$(python3 - "$idcred" <<'PY' -import json, sys -value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") -if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): - raise SystemExit(1) -print(value) -PY -) || exit 1 - if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ - "$ident" "$host" >&2 - fi + token=$(python3 "$script_dir/resolve-credential-envelope.py" \ + "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 + trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" echo "password=${token}" exit 0 fi if [ -r "$idtok" ]; then - if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ - "$ident" "$host" >&2 - fi + trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" echo "password=$(cat "$idtok")" exit 0 @@ -98,10 +95,10 @@ esac # Script-relative (not $HOME-absolute) so this resolves correctly regardless # of where the framework installer places tools/ under $HOME — mirrors # detect-platform.sh's own cred_loader resolution in this same directory. -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=../_lib/credentials.sh source "$script_dir/../_lib/credentials.sh" load_credentials "$svc" >/dev/null 2>&1 || exit 0 +trace_resolution shared shared-credential-resolved '' "$host" credentials-loader # GITEA_USER is not populated by load_credentials (it only exports # GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's # git-over-HTTP auth authenticates from the token itself (the password field), diff --git a/packages/mosaic/framework/tools/git/resolve-credential-envelope.py b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py new file mode 100644 index 00000000..50a5e4c9 --- /dev/null +++ b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Fail-closed reader for one governed Mosaic credential envelope.""" + +import hashlib +import json +import os +import stat +import sys + +MAX_BYTES = 64 * 1024 +EXPECTED_KEYS = { + "schemaVersion", + "identity", + "estate", + "host", + "providerLogin", + "tokenName", + "scopes", + "createdAt", + "tokenDigest", + "token", +} + + +def refuse(message: str) -> None: + print(f"credential envelope refused: {message}", file=sys.stderr) + raise SystemExit(1) + + +if len(sys.argv) != 5: + refuse("expected path, identity, estate, and host") +path, identity, estate, host = sys.argv[1:] +if not estate: + refuse("explicit estate is required") +parent = os.path.dirname(path) +try: + parent_stat = os.stat(parent, follow_symlinks=False) +except OSError: + refuse("credential directory unavailable") +if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode): + refuse("credential directory is not a real directory") +if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022: + refuse("credential directory owner or mode is unsafe") +try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) +except OSError: + refuse("credential file unavailable or symbolic") +try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + refuse("credential is not a regular file") + if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077: + refuse("credential owner or mode is unsafe") + content = os.read(fd, MAX_BYTES + 1) + if len(content) > MAX_BYTES: + refuse("credential exceeds size limit") +finally: + os.close(fd) +try: + value = json.loads(content) +except (UnicodeDecodeError, json.JSONDecodeError): + refuse("credential is not valid JSON") +if not isinstance(value, dict) or set(value) != EXPECTED_KEYS: + refuse("credential schema is not exact") +if ( + value.get("schemaVersion") != 1 + or value.get("identity") != identity + or value.get("estate") != estate + or value.get("host") != host + or value.get("providerLogin") != identity +): + refuse("credential binding does not match requested identity, estate, host, and principal") +token = value.get("token") +if not isinstance(token, str) or not token or any(ch.isspace() for ch in token): + refuse("credential token is invalid") +if value.get("tokenDigest") != hashlib.sha256(token.encode()).hexdigest(): + refuse("credential digest does not match token") +sys.stdout.write(token + "\n") diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index eb99cb86..3410cf01 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -34,6 +34,7 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \ "$REPO_DIR" cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER" +cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py" chmod +x "$HELPER" git -C "$REPO_DIR" init -q @@ -84,6 +85,12 @@ git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true out=$(run_helper "git.mosaicstack.dev" "") assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')" assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" +out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/shared-trace.stderr") +err=$(cat "$WORK_DIR/shared-trace.stderr") +if [[ "$err" != *"resolution_path=shared"* || "$err" != *"shared_path_entered=true"* ]]; then + echo "FAIL: shared credential materialization did not emit its computed path" >&2 + fail=1 +fi set +e out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr") @@ -103,6 +110,12 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to out=$(run_helper "git.mosaicstack.dev" "agentA") assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')" assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')" +out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentA MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/identity-trace.stderr") +err=$(cat "$WORK_DIR/identity-trace.stderr") +if [[ "$err" != *"resolution_path=identity"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: identity credential did not emit its computed path" >&2 + fail=1 +fi set +e out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr") rc=$? @@ -164,14 +177,42 @@ assert_eq "host-scoped token path (cross-host must not leak): username" "usernam assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" # --------------------------------------------------------------------------- -# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea +# 7. Governed envelopes use the same binding, owner, mode, and digest checks. +# --------------------------------------------------------------------------- +envelope="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.credential.json" +python3 - "$envelope" <<'PY' +import hashlib, json, sys +secret = "agentE-envelope-token" +json.dump({ + "schemaVersion": 1, "identity": "agentE", "estate": "homelab", + "host": "git.mosaicstack.dev", "providerLogin": "agentE", + "tokenName": "mosaic-agentE-1", "scopes": ["write:repository"], + "createdAt": "2026-08-05T00:00:00.000Z", + "tokenDigest": hashlib.sha256(secret.encode()).hexdigest(), "token": secret, +}, open(sys.argv[1], "w", encoding="utf-8")) +PY +chmod 600 "$envelope" +out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab) +assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')" +chmod 640 "$envelope" +set +e +out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr") +rc=$? +set -e +if [[ "$rc" -eq 0 || "$out" == *"password="* ]]; then + echo "FAIL: permissive envelope was consumed" >&2 + fail=1 +fi + +# --------------------------------------------------------------------------- +# 8. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea # remotes, e.g. github.com via a different credential helper). # --------------------------------------------------------------------------- out=$(run_helper "github.com" "agentA") assert_eq "unknown host: no output" "" "$out" # --------------------------------------------------------------------------- -# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential +# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential # protocol: this helper only implements get). # --------------------------------------------------------------------------- store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store < - !line.startsWith('MOSAIC_GIT_IDENTITY=') && !line.startsWith('GITEA_LOGIN='), + !line.startsWith('MOSAIC_GIT_IDENTITY=') && + !line.startsWith('MOSAIC_CREDENTIAL_ESTATE=') && + !line.startsWith('GITEA_LOGIN='), ); - lines.push(`MOSAIC_GIT_IDENTITY=${identity}`, `GITEA_LOGIN=${identity}`); + lines.push( + `MOSAIC_GIT_IDENTITY=${identity}`, + `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, + `GITEA_LOGIN=${identity}`, + ); const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const handle = await open(temp, 'wx', 0o600); try { @@ -724,43 +756,88 @@ export async function executeCredentialGet( } } +async function executeAuthorizedInventoryRead( + operation: 'list' | 'audit', + options: CredentialLifecycleCommandOptions, +): Promise { + const locations = lifecycleLocations(options); + const journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation, + actor: options.actor, + identity: options.actor, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent(`${operation}-requested`); + try { + if (options.authorityFd === undefined) { + await journal.seal('refused', 'authority-required'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'refused', + code: 'authority-required', + message: 'A protected delegated inventory authority is required.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } + const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(options.actor, options); + const providerIdentity = await context.provider.readIdentity(authority); + if (providerIdentity.login !== options.actor) { + await journal.seal('refused', 'provider-identity-mismatch'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'refused', + code: 'provider-identity-mismatch', + message: 'Delegated inventory authority did not match the explicit actor.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } + const identities = + operation === 'list' ? await context.store.list(options.estate, options.host) : []; + const journals = operation === 'audit' ? await listCredentialJournals(locations.stateRoot) : []; + await journal.recordProviderEvidence({ + endpoint: providerIdentity.endpoint, + contentType: providerIdentity.contentType, + decision: 'inventory-authority-verified', + }); + await journal.seal('ok', `${operation}-verified`); + return localLifecycleResult(operation, 'all', options, { + outcome: 'ok', + code: `${operation}-verified`, + message: + operation === 'list' + ? 'Governed credential bindings were listed under delegated authority.' + : 'Durable credential journal identifiers were read under delegated authority.', + evidence: { + providerIdentity: providerIdentity.login, + token: null, + teaLogin: null, + identities, + journalIds: journals.map((entry): string => entry.id), + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch { + await journal.seal('error', 'inventory-read-failed'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'error', + code: 'inventory-read-failed', + message: 'Authorized credential inventory read failed.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } +} + export async function executeCredentialList( options: CredentialLifecycleCommandOptions, ): Promise { - try { - const context = await lifecycleContext(options); - const identities = await context.store.list(options.estate, options.host); - return localLifecycleResult('list', 'all', options, { - outcome: 'ok', - code: 'list-verified', - message: 'Governed credential bindings were listed without secrets.', - evidence: { providerIdentity: null, token: null, teaLogin: null, identities, journalIds: [] }, - }); - } catch { - return localLifecycleResult('list', 'all', options, { - outcome: 'error', - code: 'internal-invariant', - message: 'Credential listing failed.', - }); - } + return executeAuthorizedInventoryRead('list', options); } export async function executeCredentialAudit( options: CredentialLifecycleCommandOptions, ): Promise { - const journals = await listCredentialJournals(lifecycleLocations(options).stateRoot); - return localLifecycleResult('audit', 'all', options, { - outcome: 'ok', - code: 'audit-verified', - message: 'Durable journal index was read.', - evidence: { - providerIdentity: null, - token: null, - teaLogin: null, - identities: [], - journalIds: journals.map((journal): string => journal.id), - }, - }); + return executeAuthorizedInventoryRead('audit', options); } type PrintableCredentialResult = Pick< @@ -885,7 +962,8 @@ export function registerCredentialCommand(parent: Command): void { .description('List governed identities without reading or printing secret material') .requiredOption('--estate ', 'Explicit target estate') .requiredOption('--host ', 'Explicit provider host') - .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--actor ', 'Explicit delegated inventory authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') .option('--registry ', 'Strict non-secret estate registry') .option('--token-dir ', 'Governed token directory') .option('--state-dir ', 'Durable credential journal root') @@ -901,7 +979,9 @@ export function registerCredentialCommand(parent: Command): void { .description('List durable credential journals without secret-bearing payloads') .requiredOption('--estate ', 'Explicit target estate') .requiredOption('--host ', 'Explicit provider host') - .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--actor ', 'Explicit delegated inventory authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .option('--registry ', 'Strict non-secret estate registry') .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (options: CredentialLifecycleCommandOptions): Promise => { diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index ca85f923..c7b3d579 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -25,6 +25,7 @@ const SAFE_DECISIONS = new Set([ 'permission-write', 'permission-admin', 'identity-verified', + 'inventory-authority-verified', 'scope-verified', 'grant-verified', 'revoke-verified', diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts index bb0f181c..3511e4a6 100644 --- a/packages/mosaic/src/credentials/credential-result.dto.ts +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -52,8 +52,15 @@ export interface WriteDifferentialEvidenceDto { readonly doesNotProve: string; } +export interface TokenCapabilitiesEvidenceDto { + readonly state: 'measured' | 'not-measured'; + readonly scopes: readonly string[]; + readonly source: 'provider-token-object' | 'runtime-not-authorized'; +} + export interface CredentialValidationEvidenceDto { readonly providerIdentity: ProviderIdentityEvidenceDto | null; + readonly tokenCapabilities: TokenCapabilitiesEvidenceDto; readonly repositoryPermission: RepositoryPermissionEvidenceDto | null; readonly writeDifferential: WriteDifferentialEvidenceDto | null; } diff --git a/packages/mosaic/src/credentials/file-credential-store.spec.ts b/packages/mosaic/src/credentials/file-credential-store.spec.ts index 7d456651..7f50b4a5 100644 --- a/packages/mosaic/src/credentials/file-credential-store.spec.ts +++ b/packages/mosaic/src/credentials/file-credential-store.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mkdtemp } from 'node:fs/promises'; @@ -132,6 +132,18 @@ describe('phase-1 governed file credential resolver', (): void => { 'git.example.invalid', ), ).resolves.toMatchObject({ identity: 'seat' }); + await copyFile( + join(root, 'gitea-example-seat.credential.json'), + join(root, 'gitea-example-other.credential.json'), + ); + await expect( + new FileCredentialResolver(root, registry()).resolve( + 'other', + 'homelab', + 'git.example.invalid', + ), + ).rejects.toThrow(/credential-binding-mismatch/); + await unlink(join(root, 'gitea-example-other.credential.json')); await store.remove('seat', 'homelab', 'git.example.invalid'); await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); }); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index fda7d725..cbb9d7fe 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -127,6 +127,17 @@ export class FileCredentialResolver implements CredentialResolver { 'credential envelope failed schema, owner, or mode validation', ); } + if ( + envelope.data.identity !== identity || + envelope.data.estate !== estate || + envelope.data.host !== host || + envelope.data.providerLogin !== identity + ) { + throw new CredentialStoreError( + 'credential-binding-mismatch', + 'credential envelope does not match the requested identity, estate, host, and principal', + ); + } const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8')); const digest = createHash('sha256').update(secret).digest('hex'); if (envelope.data.tokenDigest !== digest) { diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index fda7b5a8..195e3052 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -98,6 +98,11 @@ export async function grantDirectRepositoryPermission( }, evidence: { providerIdentity: authorityIdentity, + tokenCapabilities: { + state: 'not-measured', + scopes: [], + source: 'runtime-not-authorized', + }, repositoryPermission: null, writeDifferential: null, collaboratorPermission: null, @@ -241,6 +246,11 @@ export async function grantDirectRepositoryPermission( }, evidence: { providerIdentity: null, + tokenCapabilities: { + state: 'not-measured', + scopes: [], + source: 'runtime-not-authorized', + }, repositoryPermission: null, writeDifferential: null, collaboratorPermission: null, diff --git a/packages/mosaic/src/credentials/tea-login-store.spec.ts b/packages/mosaic/src/credentials/tea-login-store.spec.ts index 6ab225cd..efa9be6b 100644 --- a/packages/mosaic/src/credentials/tea-login-store.spec.ts +++ b/packages/mosaic/src/credentials/tea-login-store.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { chmod, 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'; @@ -32,4 +32,23 @@ describe('host-bound Tea login store', (): void => { host: 'git.two.invalid', }); }); + + it('preserves unrelated Tea configuration and rejects permissive secret reads', async (): Promise => { + root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-')); + const configPath = join(root, 'tea', 'config.yml'); + const store = new TeaLoginStore(configPath); + await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')); + const original = await readFile(configPath, 'utf8'); + await writeFile(configPath, `preferences:\n color: true\n${original}`, { mode: 0o600 }); + + await store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')); + await store.remove('seat', 'git.one.invalid'); + expect(await readFile(configPath, 'utf8')).toContain('color: true'); + + await chmod(configPath, 0o644); + expect(() => store.resolve('seat', 'homelab', 'git.two.invalid')).toThrow( + /tea-config-insecure/, + ); + expect(() => store.readBack('seat', 'git.two.invalid')).toThrow(/tea-config-insecure/); + }); }); diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 88173184..99cf4a7b 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -28,6 +28,7 @@ interface TeaLoginRecord { interface TeaConfig { readonly logins: TeaLoginRecord[]; + readonly [key: string]: unknown; } const loginSchema = z @@ -58,6 +59,12 @@ async function acquireLock(path: string): Promise Date: Wed, 5 Aug 2026 13:19:27 -0500 Subject: [PATCH 07/13] fix(mosaic): harden credential lifecycle boundaries --- .../framework/tools/git/detect-platform.sh | 2 +- .../framework/tools/git/git-credential-mosaic | 14 ++++---- .../tools/git/test-git-credential-mosaic.sh | 3 ++ packages/mosaic/src/commands/cred.ts | 36 ++++++++++++++----- .../src/credentials/audit-journal.spec.ts | 18 +++++++++- .../mosaic/src/credentials/audit-journal.ts | 21 +++++++++++ .../src/credentials/credential-result.dto.ts | 2 +- .../credential-validate-service.ts | 12 +++++-- .../src/credentials/estate-registry.spec.ts | 5 +++ .../mosaic/src/credentials/estate-registry.ts | 12 +++++++ packages/mosaic/src/credentials/lifecycle.ts | 11 ++++-- .../src/credentials/tea-login-store.spec.ts | 2 +- .../mosaic/src/credentials/tea-login-store.ts | 20 +++++++---- 13 files changed, 127 insertions(+), 31 deletions(-) diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 0048f682..ff86ef84 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -539,7 +539,7 @@ get_gitea_token() { if [[ -n "$_idpfx" ]]; then local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json" - if [[ -r "$_idcred" ]]; then + if [[ -e "$_idcred" || -L "$_idcred" ]]; then local _resolved_token _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \ "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 4ad2d975..348ff3dd 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -38,25 +38,25 @@ trace_resolution() { # survives across non-persistent shells) > git-supplied username (credential.username # / URL). When the resolved identity has a matching per-agent token, use it instead of # the shared account. Backward-compatible: nothing resolvable → shared token. +case "$host" in + git.uscllc.com) idpfx=gitea-usc;; + git.mosaicstack.dev) idpfx=gitea-mosaicstack;; + *) idpfx="";; +esac ident="$MOSAIC_GIT_IDENTITY" [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident="$username_in" -if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then +if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then echo "quit=true" printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ "$ident" "$MOSAIC_AGENT_NAME" "$host" >&2 exit 1 fi if [ -n "$ident" ]; then - case "$host" in - git.uscllc.com) idpfx=gitea-usc;; - git.mosaicstack.dev) idpfx=gitea-mosaicstack;; - *) idpfx="";; - esac if [ -n "$idpfx" ]; then idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" - if [ -r "$idcred" ]; then + if [ -e "$idcred" ] || [ -L "$idcred" ]; then token=$(python3 "$script_dir/resolve-credential-envelope.py" \ "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index 3410cf01..df852e24 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -194,6 +194,7 @@ PY chmod 600 "$envelope" out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab) assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')" +echo -n "must-not-fallback-legacy" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.token" chmod 640 "$envelope" set +e out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr") @@ -210,6 +211,8 @@ fi # --------------------------------------------------------------------------- out=$(run_helper "github.com" "agentA") assert_eq "unknown host: no output" "" "$out" +out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA) +assert_eq "unknown host in fleet context: no output" "" "$out" # --------------------------------------------------------------------------- # 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 3c332d58..c7c135a1 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -56,6 +56,7 @@ interface CredentialValidateCommandOptions { readonly mosaicHome?: string; readonly actor?: string; readonly json?: boolean; + readonly operation?: 'validate' | 'whoami'; } interface CredentialGrantCommandOptions { @@ -111,10 +112,11 @@ function readRegistrySource(path: string): string { function errorResult( request: GiteaWriteValidationRequestDto, code: string, + operation: 'validate' | 'whoami' = 'validate', ): CredentialValidationResultDto { return { schemaVersion: 1, - operation: 'validate', + operation, outcome: 'error', exitCode: 20, retryable: false, @@ -166,13 +168,13 @@ export async function executeCredentialValidate( try { if (!['read', 'write', 'admin'].includes(options.require)) { - return errorResult(request, 'invalid-input'); + return errorResult(request, 'invalid-input', options.operation); } const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath)); const hostConfig = registry.resolve(options.estate, options.host); if (hostConfig === undefined) { return { - ...errorResult(request, 'estate-host-mismatch'), + ...errorResult(request, 'estate-host-mismatch', options.operation), outcome: 'refused', exitCode: 10, reason: { @@ -188,19 +190,23 @@ export async function executeCredentialValidate( 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 }; + const serviceOptions = { + stateRoot, + actor: options.actor ?? identity, + operation: options.operation ?? 'validate', + }; 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); + return errorResult(request, error.code, options.operation); } if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) { - return errorResult(request, error.code); + return errorResult(request, error.code, options.operation); } - return errorResult(request, 'internal-invariant'); + return errorResult(request, 'internal-invariant', options.operation); } } @@ -505,6 +511,14 @@ export async function executeCredentialRotate( message: 'No existing binding can be rotated.', }); } + if (options.tokenName === old.binding.tokenName) { + old.secret.fill(0); + return localLifecycleResult('rotate', identity, options, { + outcome: 'refused', + code: 'replacement-token-name-conflict', + message: 'Replacement token name must differ from the active generation.', + }); + } const authority = await lifecycleAuthority(identity, options); const journal = await CredentialAuditJournal.open(context.stateRoot, { operation: 'rotate', @@ -646,7 +660,7 @@ export async function executeCredentialWire( lines.push( `MOSAIC_GIT_IDENTITY=${identity}`, `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, - `GITEA_LOGIN=${identity}`, + `GITEA_LOGIN=${identity}--${options.host}`, ); const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const handle = await open(temp, 'wx', 0o600); @@ -1051,7 +1065,11 @@ export function registerCredentialCommand(parent: Command): void { .option('--actor ', 'Explicit audit actor') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialValidateCommandOptions): Promise => { - const result = await executeCredentialValidate(identity, { ...options, require: 'read' }); + const result = await executeCredentialValidate(identity, { + ...options, + require: 'read', + operation: 'whoami', + }); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); diff --git a/packages/mosaic/src/credentials/audit-journal.spec.ts b/packages/mosaic/src/credentials/audit-journal.spec.ts index 010acc6a..e0805ee0 100644 --- a/packages/mosaic/src/credentials/audit-journal.spec.ts +++ b/packages/mosaic/src/credentials/audit-journal.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, 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'; @@ -107,6 +107,22 @@ describe('credential durable audit journal', (): void => { await journal.closeIncomplete(); }); + it('refuses a group-writable journal root before opening evidence', async (): Promise => { + const root = await stateRoot(); + await mkdir(root, { mode: 0o700 }); + await chmod(root, 0o770); + 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('supersedes a false sealed classification without editing the original journal', async (): Promise => { const root = await stateRoot(); const journal = await CredentialAuditJournal.open( diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index c7b3d579..f9f039d0 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { lstatSync } from 'node:fs'; import { open, readdir, rename } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; import { join } from 'node:path'; @@ -31,6 +32,7 @@ const SAFE_DECISIONS = new Set([ 'revoke-verified', 'rotate-verified', 'validation-requested', + 'whoami-requested', 'provision-requested', 'rotate-requested', 'revoke-requested', @@ -69,6 +71,21 @@ export class CredentialJournalError extends Error { } } +function assertPrivateDirectory(path: string): void { + const stat = lstatSync(path); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== process.getuid?.() || + (stat.mode & 0o022) !== 0 + ) { + throw new CredentialJournalError( + 'journal-unavailable', + 'journal directory owner or write permissions are unsafe', + ); + } +} + function assertContext(context: CredentialJournalContextDto): void { if ( !SAFE_NAME.test(context.actor) || @@ -132,6 +149,8 @@ export class CredentialAuditJournal { let handle: FileHandle | undefined; try { ensureManagedDirectory(stateRoot, journalsDirectory); + assertPrivateDirectory(stateRoot); + assertPrivateDirectory(journalsDirectory); const openPath = join(journalsDirectory, `${id}.open.jsonl`); handle = await open(openPath, 'wx', 0o600); const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now); @@ -270,6 +289,8 @@ export async function listCredentialJournals( const journalsDirectory = join(stateRoot, 'journals'); let names: string[]; try { + assertPrivateDirectory(stateRoot); + assertPrivateDirectory(journalsDirectory); names = await readdir(journalsDirectory); } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []; diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts index 3511e4a6..c9765a5c 100644 --- a/packages/mosaic/src/credentials/credential-result.dto.ts +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -72,7 +72,7 @@ export interface CredentialAuditResultDto { export interface CredentialValidationResultDto { readonly schemaVersion: 1; - readonly operation: 'validate'; + readonly operation: 'validate' | 'whoami'; readonly outcome: CredentialOutcome; readonly exitCode: 0 | 10 | 20 | 30; readonly retryable: boolean; diff --git a/packages/mosaic/src/credentials/credential-validate-service.ts b/packages/mosaic/src/credentials/credential-validate-service.ts index ad3f4a02..b7fb62a0 100644 --- a/packages/mosaic/src/credentials/credential-validate-service.ts +++ b/packages/mosaic/src/credentials/credential-validate-service.ts @@ -13,6 +13,7 @@ import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './val export interface CredentialValidationServiceOptions { readonly stateRoot: string; readonly actor: string; + readonly operation?: 'validate' | 'whoami'; } function permissionDecision(permission: RepositoryPermission): string { @@ -27,14 +28,16 @@ async function openValidationJournal( options: CredentialValidationServiceOptions, ): Promise { const journal = await CredentialAuditJournal.open(options.stateRoot, { - operation: 'validate', + operation: options.operation ?? 'validate', actor: options.actor, identity: request.identity, estate: request.estate, host: request.host, repo: request.repo, }); - await journal.recordIntent('validation-requested'); + await journal.recordIntent( + options.operation === 'whoami' ? 'whoami-requested' : 'validation-requested', + ); return journal; } @@ -70,7 +73,10 @@ export async function runCredentialReadValidation( ): Promise { const journal = await openValidationJournal(request, options); const validation = await evaluateGiteaReadValidation(request, dependencies); - return recordAndSealValidation(journal, validation); + return recordAndSealValidation(journal, { + ...validation, + operation: options.operation ?? 'validate', + }); } export async function runCredentialValidation( diff --git a/packages/mosaic/src/credentials/estate-registry.spec.ts b/packages/mosaic/src/credentials/estate-registry.spec.ts index a15b8602..4a0d78f6 100644 --- a/packages/mosaic/src/credentials/estate-registry.spec.ts +++ b/packages/mosaic/src/credentials/estate-registry.spec.ts @@ -26,6 +26,11 @@ describe('credential estate registry', (): void => { 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); + expect(registry.resolveByHost('git.example.invalid')).toMatchObject({ + estate: 'homelab', + host: { host: 'git.example.invalid', provider: 'gitea' }, + }); + expect(registry.resolveByHost('other.example.invalid')).toBeUndefined(); }); it('rejects a provider URL whose host differs from the declared host', (): void => { diff --git a/packages/mosaic/src/credentials/estate-registry.ts b/packages/mosaic/src/credentials/estate-registry.ts index 093397a3..f1349624 100644 --- a/packages/mosaic/src/credentials/estate-registry.ts +++ b/packages/mosaic/src/credentials/estate-registry.ts @@ -93,6 +93,18 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry ?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host); } + resolveByHost( + host: string, + ): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined { + for (const [estate, config] of this.estates) { + const match = config.hosts.find( + (candidate: CredentialHostConfigDto): boolean => candidate.host === host, + ); + if (match !== undefined) return { estate, host: match }; + } + return undefined; + } + readOnlyControl(estate: string): string { const identity = this.estates.get(estate)?.readOnlyControlIdentity; if (identity === undefined) { diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 762efd48..32296390 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -205,8 +205,9 @@ export async function provisionCredential( teaLogin: { ...teaLogin, state: 'registered' }, }); } catch (error: unknown) { - if (error instanceof CredentialJournalError) throw error; + const journalFailure = error instanceof CredentialJournalError; if (minted === undefined) { + if (journalFailure) throw error; await journal.seal('indeterminate', 'provider-unavailable'); return lifecycleResult('provision', request, { outcome: 'indeterminate', @@ -231,13 +232,19 @@ export async function provisionCredential( await teaStore.put(request.identity, request.host, prior.secret); } rollbackComplete = true; - await journal.recordMutation('provision-rollback-verified'); } catch { rollbackComplete = false; } finally { prior?.secret.fill(0); } + if (journalFailure) { + if (rollbackComplete) { + await journal.recordMutation('provision-rollback-verified').catch((): void => undefined); + } + throw error; + } const code = rollbackComplete ? failureCode : 'rollback-incomplete'; + if (rollbackComplete) await journal.recordMutation('provision-rollback-verified'); await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code); return lifecycleResult('provision', request, { outcome: rollbackComplete ? 'error' : 'indeterminate', diff --git a/packages/mosaic/src/credentials/tea-login-store.spec.ts b/packages/mosaic/src/credentials/tea-login-store.spec.ts index efa9be6b..512dabe5 100644 --- a/packages/mosaic/src/credentials/tea-login-store.spec.ts +++ b/packages/mosaic/src/credentials/tea-login-store.spec.ts @@ -28,7 +28,7 @@ describe('host-bound Tea login store', (): void => { await store.remove('seat', 'git.one.invalid'); expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined(); expect(store.readBack('seat', 'git.two.invalid')).toEqual({ - name: 'seat', + name: 'seat--git.two.invalid', host: 'git.two.invalid', }); }); diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 99cf4a7b..1b302d41 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -59,6 +59,10 @@ async function acquireLock(path: string): Promise !(login.name === identity && login.url === `https://${host}`), + (login): boolean => + !(login.name === loginName(identity, host) && login.url === `https://${host}`), ); logins.push(record); const temp = `${this.configPath}.${randomUUID()}.tmp`; @@ -140,7 +145,9 @@ export class TeaLoginStore { if (!decoded.success) return undefined; const matches = decoded.data.logins.filter( (login): boolean => - login.name === identity && login.url === `https://${host}` && login.user === identity, + login.name === loginName(identity, host) && + login.url === `https://${host}` && + login.user === identity, ); if (matches.length !== 1 || matches[0] === undefined) return undefined; return Object.freeze({ @@ -173,7 +180,8 @@ export class TeaLoginStore { throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); } const logins = decoded.data.logins.filter( - (login): boolean => !(login.name === identity && login.url === `https://${host}`), + (login): boolean => + !(login.name === loginName(identity, host) && login.url === `https://${host}`), ); const temp = `${this.configPath}.${randomUUID()}.tmp`; const handle = await open(temp, 'wx', 0o600); @@ -215,13 +223,13 @@ export class TeaLoginStore { if (typeof value !== 'object' || value === null) return false; return ( 'name' in value && - value.name === identity && + value.name === loginName(identity, host) && 'url' in value && value.url === `https://${host}` && 'user' in value && value.user === identity ); }); - return matches.length === 1 ? { name: identity, host } : undefined; + return matches.length === 1 ? { name: loginName(identity, host), host } : undefined; } } -- 2.54.0 From 6ca8758f8f91e58d7a58615d95381fdc579d0343 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:21:03 -0500 Subject: [PATCH 08/13] fix(mosaic): derive credential resolution traces --- .../framework/tools/git/detect-platform.sh | 23 +++++++++++-------- .../framework/tools/git/git-credential-mosaic | 12 ++++++---- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index ff86ef84..5519bd29 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -501,15 +501,15 @@ get_gitea_url_for_host() { # Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials _trace_credential_resolution() { [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]] || return 0 - local resolution_path="$1" reason="$2" identity="$3" host="$4" source="$5" + local reason="$1" identity="$2" host="$3" source="$4" local shared_path_entered=false - [[ "$resolution_path" == shared ]] && shared_path_entered=true + [[ "$_resolution_path" == shared ]] && shared_path_entered=true printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \ - "$reason" "$identity" "$host" "$resolution_path" "$shared_path_entered" "$source" >&2 + "$reason" "$identity" "$host" "$_resolution_path" "$shared_path_entered" "$source" >&2 } get_gitea_token() { - local host="$1" + local host="$1" _resolution_path=unresolved local script_dir script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" local cred_loader="$script_dir/../_lib/credentials.sh" @@ -543,12 +543,14 @@ get_gitea_token() { local _resolved_token _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \ "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 - _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src" + _resolution_path=identity + _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" printf '%s\n' "$_resolved_token" return 0 fi if [[ -r "$_idtok" ]]; then - _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src" + _resolution_path=identity + _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" cat "$_idtok" return 0 fi @@ -604,7 +606,8 @@ get_gitea_token() { echo "${GITEA_TOKEN:-}" ) if [[ -n "$token" ]]; then - _trace_credential_resolution shared shared-credential-resolved '' "$host" credentials-loader + _resolution_path=shared + _trace_credential_resolution shared-credential-resolved '' "$host" credentials-loader echo "$token" return 0 fi @@ -613,7 +616,8 @@ get_gitea_token() { # 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host) if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then - _trace_credential_resolution shared shared-credential-resolved '' "$host" environment + _resolution_path=shared + _trace_credential_resolution shared-credential-resolved '' "$host" environment echo "$GITEA_TOKEN" return 0 fi @@ -625,7 +629,8 @@ get_gitea_token() { local token token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1) if [[ -n "$token" ]]; then - _trace_credential_resolution shared shared-credential-resolved '' "$host" git-credentials + _resolution_path=shared + _trace_credential_resolution shared-credential-resolved '' "$host" git-credentials echo "$token" return 0 fi diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 348ff3dd..a8cce2a9 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -27,12 +27,13 @@ done script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" trace_resolution() { [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ] || return 0 - resolution_path="$1" reason="$2" trace_identity="$3" trace_host="$4" source="$5" + reason="$1" trace_identity="$2" trace_host="$3" source="$4" shared_path_entered=false [ "$resolution_path" = shared ] && shared_path_entered=true printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \ "$reason" "$trace_identity" "$trace_host" "$resolution_path" "$shared_path_entered" "$source" >&2 } +resolution_path=unresolved # Per-agent identity resolution (Gate-16 author≠reviewer separation). # Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree, # survives across non-persistent shells) > git-supplied username (credential.username @@ -59,13 +60,15 @@ if [ -n "$ident" ]; then if [ -e "$idcred" ] || [ -L "$idcred" ]; then token=$(python3 "$script_dir/resolve-credential-envelope.py" \ "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 - trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic + resolution_path=identity + trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" echo "password=${token}" exit 0 fi if [ -r "$idtok" ]; then - trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic + resolution_path=identity + trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" echo "password=$(cat "$idtok")" exit 0 @@ -98,7 +101,8 @@ esac # shellcheck source=../_lib/credentials.sh source "$script_dir/../_lib/credentials.sh" load_credentials "$svc" >/dev/null 2>&1 || exit 0 -trace_resolution shared shared-credential-resolved '' "$host" credentials-loader +resolution_path=shared +trace_resolution shared-credential-resolved '' "$host" credentials-loader # GITEA_USER is not populated by load_credentials (it only exports # GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's # git-over-HTTP auth authenticates from the token itself (the password field), -- 2.54.0 From b309896067a4e38dbe48b739932f76fabe47b69f Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:27:19 -0500 Subject: [PATCH 09/13] fix(mosaic): preserve credential transaction evidence --- packages/mosaic/src/commands/cred.ts | 89 ++++++++----- .../src/credentials/file-credential-store.ts | 8 ++ .../mosaic/src/credentials/validate.spec.ts | 24 +++- packages/mosaic/src/credentials/validate.ts | 119 +++++------------- 4 files changed, 120 insertions(+), 120 deletions(-) diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index c7c135a1..d6255b7a 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import { fstatSync, writeSync } from 'node:fs'; import { open, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -552,7 +553,18 @@ export async function executeCredentialRotate( audit: { journalId: journal.journalId(), state: 'sealed' }, }; } - await journal.recordMutation('token-mint-applied'); + try { + await journal.recordMutation('token-mint-applied'); + } catch (error: unknown) { + await context.provider.revokeToken(authority, identity, options.tokenName); + if (await context.provider.tokenExists(authority, identity, options.tokenName)) { + throw new Error('replacement rollback after journal failure could not be verified'); + } + await context.store.put(old.binding, old.secret); + await context.teaStore.put(identity, options.host, old.secret); + old.secret.fill(0); + throw error; + } try { await context.provider.revokeToken(authority, identity, old.binding.tokenName); if (await context.provider.tokenExists(authority, identity, old.binding.tokenName)) { @@ -560,20 +572,14 @@ export async function executeCredentialRotate( } await journal.recordMutation('token-revoke-applied'); } catch { - await context.provider.revokeToken(authority, identity, options.tokenName); - if (await context.provider.tokenExists(authority, identity, options.tokenName)) { - throw new Error('replacement rollback could not be verified'); - } - await context.store.put(old.binding, old.secret); - await context.teaStore.put(identity, options.host, old.secret); - await journal.recordMutation('rotate-rollback-verified'); - await journal.seal('indeterminate', 'old-credential-preserved'); + await journal.seal('indeterminate', 'old-credential-state-unknown'); old.secret.fill(0); return localLifecycleResult('rotate', identity, options, { outcome: 'indeterminate', - mutation: 'none', - code: 'old-credential-preserved', - message: 'Replacement failed; the previous credential remains the canonical binding.', + mutation: 'unknown', + code: 'old-credential-state-unknown', + message: + 'The verified replacement remains canonical, but old-token revocation state is unknown.', audit: { journalId: journal.journalId(), state: 'sealed' }, }); } @@ -707,16 +713,12 @@ export async function executeCredentialGet( }); await journal.recordIntent('get-requested'); try { - if ( - process.env['MOSAIC_AGENT_NAME'] === undefined || - process.env['MOSAIC_AGENT_NAME'] !== identity || - options.actor !== identity - ) { + if (options.authorityFd === undefined || options.actor !== identity) { await journal.seal('refused', 'provider-identity-mismatch'); return localLifecycleResult('get', identity, options, { outcome: 'refused', code: 'provider-identity-mismatch', - message: 'Runtime fleet identity, explicit actor, and requested identity must match.', + message: 'Protected authority, explicit actor, and requested identity must match.', audit: { journalId: journal.journalId(), state: 'sealed' }, }); } @@ -731,11 +733,27 @@ export async function executeCredentialGet( throw new Error('unsafe output fd'); } const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(identity, options); + const providerIdentity = await context.provider.readIdentity(authority); + if (providerIdentity.login !== identity) { + await journal.seal('refused', 'provider-identity-mismatch'); + return localLifecycleResult('get', identity, options, { + outcome: 'refused', + code: 'provider-identity-mismatch', + message: + 'Provider read-back did not bind the protected authority to the requested identity.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } const resolved = await new FileCredentialResolver( lifecycleLocations(options).tokenDirectory, context.registry, ).resolve(identity, options.estate, options.host); - if (resolved === undefined) { + if ( + resolved === undefined || + authority.secret.byteLength !== resolved.secret.byteLength || + !timingSafeEqual(Buffer.from(authority.secret), Buffer.from(resolved.secret)) + ) { await journal.seal('refused', 'no-token-for-identity'); return localLifecycleResult('get', identity, options, { outcome: 'refused', @@ -862,6 +880,17 @@ type PrintableCredentialResult = Pick< 'operation' | 'outcome' | 'exitCode' | 'reason' >; +function withParentMosaicHome( + parent: Command, + options: T, +): T { + const inherited = parent.opts<{ mosaicHome?: string }>(); + return { + ...options, + ...(inherited.mosaicHome === undefined ? {} : { mosaicHome: inherited.mosaicHome }), + }; +} + function printCredentialResult(result: PrintableCredentialResult, json: boolean): void { if (json) { process.stdout.write(`${JSON.stringify(result)}\n`); @@ -897,7 +926,10 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialProvision(identity, options); + const result = await executeCredentialProvision( + identity, + withParentMosaicHome(cred, options), + ); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -912,7 +944,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialWire(identity, options); + const result = await executeCredentialWire(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -922,14 +954,15 @@ export function registerCredentialCommand(parent: Command): void { .description('Emit one exact credential only to a protected inherited fd') .requiredOption('--estate ', 'Explicit target estate') .requiredOption('--host ', 'Explicit provider host') - .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--actor ', 'Explicit protected authority identity') + .requiredOption('--authority-fd ', 'Inherited protected credential authority fd') .requiredOption('--output-fd ', 'Protected inherited credential output fd') .option('--registry ', 'Strict non-secret estate registry') .option('--token-dir ', 'Governed token directory') .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one non-secret machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialGet(identity, options); + const result = await executeCredentialGet(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -949,7 +982,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialRotate(identity, options); + const result = await executeCredentialRotate(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -966,7 +999,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialRevoke(identity, options); + const result = await executeCredentialRevoke(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -983,7 +1016,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialList(options); + const result = await executeCredentialList(withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -999,7 +1032,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialAudit(options); + const result = await executeCredentialAudit(withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -1066,7 +1099,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialValidateCommandOptions): Promise => { const result = await executeCredentialValidate(identity, { - ...options, + ...withParentMosaicHome(cred, options), require: 'read', operation: 'whoami', }); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index cbb9d7fe..569e525c 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -343,6 +343,14 @@ export class FileCredentialStore { if (!parsed.success) { throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed'); } + const verified = await new FileCredentialResolver( + this.tokenDirectory, + this.estateRegistry, + ).resolve(identity, estate, host); + if (verified === undefined) { + throw new CredentialStoreError('invalid-binding', 'credential envelope was not resolvable'); + } + verified.secret.fill(0); return { schemaVersion: 1, identity: parsed.data.identity, diff --git a/packages/mosaic/src/credentials/validate.spec.ts b/packages/mosaic/src/credentials/validate.spec.ts index fe945f35..238e114c 100644 --- a/packages/mosaic/src/credentials/validate.spec.ts +++ b/packages/mosaic/src/credentials/validate.spec.ts @@ -239,11 +239,19 @@ describe('Gitea read validation', (): void => { describe('principal-bound Gitea write validation contract v1.1', (): void => { it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise => { const observed = fixture(); - observed.dependencies.provider.readIdentity = async (): Promise => { - throw new CredentialProviderEvidenceError( - 'identity-read-forbidden', - 'identity endpoint scope forbidden', - ); + const readIdentity = observed.dependencies.provider.readIdentity.bind( + observed.dependencies.provider, + ); + observed.dependencies.provider.readIdentity = async ( + resolved, + ): Promise => { + if (resolved.identity === SUBJECT) { + throw new CredentialProviderEvidenceError( + 'identity-read-forbidden', + 'identity endpoint scope forbidden', + ); + } + return readIdentity(resolved); }; const result = await evaluateGiteaWriteValidation( { @@ -259,7 +267,11 @@ describe('principal-bound Gitea write validation contract v1.1', (): void => { expect(result.outcome).toBe('indeterminate'); expect(result.reason.code).toBe('identity-not-measured'); expect(result.evidence.repositoryPermission?.effective).toBe('write'); - expect(observed.receivePackHandles).toEqual([expect.objectContaining({ identity: SUBJECT })]); + expect(observed.receivePackHandles).toEqual([ + expect.objectContaining({ identity: SUBJECT }), + expect.objectContaining({ identity: CONTROL }), + undefined, + ]); }); it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise => { const { result, observed } = await validate(); diff --git a/packages/mosaic/src/credentials/validate.ts b/packages/mosaic/src/credentials/validate.ts index 2affcd5b..a5932f3f 100644 --- a/packages/mosaic/src/credentials/validate.ts +++ b/packages/mosaic/src/credentials/validate.ts @@ -1,7 +1,6 @@ import { CredentialProviderEvidenceError } from './gitea-provider.js'; import type { CredentialValidationDependencies, - GiteaCredentialProvider, GiteaReadValidationRequestDto, GiteaWriteValidationRequestDto, ResolvedCredential, @@ -150,23 +149,9 @@ async function resolveCredential( 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, + subjectLogin: string, + subjectIdentity: ProviderIdentityEvidenceDto | null, subjectPermission: RepositoryPermissionEvidenceDto, subjectReceivePack: ReceivePackEvidenceDto, controlIdentity: ProviderIdentityEvidenceDto, @@ -176,7 +161,7 @@ function successfulEvidence( const writeDifferential: WriteDifferentialEvidenceDto = { state: 'can-write', credentialBinding: 'same-resolution', - transportPrincipal: subjectIdentity.login, + transportPrincipal: subjectLogin, authenticatedReceivePack: 'advertised', readOnlyControl: { identity: controlIdentity.login, @@ -353,69 +338,30 @@ async function evaluateGiteaWriteValidationUnsafe( }); } - let subjectEvidence: Awaited>; + let subjectIdentity: ProviderIdentityEvidenceDto | null = null; try { - subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); + subjectIdentity = await dependencies.provider.readIdentity(resolved); } catch (error: unknown) { if ( - error instanceof CredentialProviderEvidenceError && - error.code === 'identity-read-forbidden' + !(error instanceof CredentialProviderEvidenceError) || + error.code !== 'identity-read-forbidden' ) { - const permission = await dependencies.provider.readRepositoryPermission( - resolved, - request.repo, - ); - const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); - const evidence: CredentialValidationEvidenceDto = { - providerIdentity: null, - tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED, - repositoryPermission: permission, - writeDifferential: null, - }; - if (permission.effective === 'none' || permission.effective === 'read') { - return refused( - request, - { - code: 'permission-denied', - message: 'The in-scope provider object denies required write capability.', - }, - evidence, - ); - } - if ( - receivePack.principal !== request.identity || - receivePack.resolutionId !== resolved.resolutionId || - !advertised(receivePack) - ) { - return indeterminate( - request, - { - code: 'permission-evidence-disagrees', - message: 'In-scope repository and write transport evidence did not agree.', - }, - evidence, - ); - } - return indeterminate( - request, - { - code: 'identity-not-measured', - message: - 'Write capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.', - }, - evidence, - ); + throw error; } - throw error; } + const subjectPermission = await dependencies.provider.readRepositoryPermission( + resolved, + request.repo, + ); + const subjectReceivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); const baseEvidence: CredentialValidationEvidenceDto = { - providerIdentity: subjectEvidence.identity, + providerIdentity: subjectIdentity, tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED, - repositoryPermission: subjectEvidence.permission, + repositoryPermission: subjectPermission, writeDifferential: null, }; - if (!identityContentTypeValid(subjectEvidence.identity)) { + if (subjectIdentity !== null && !identityContentTypeValid(subjectIdentity)) { return indeterminate( request, { @@ -425,7 +371,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (subjectEvidence.identity.login !== request.identity) { + if (subjectIdentity !== null && subjectIdentity.login !== request.identity) { return refused( request, { @@ -435,7 +381,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (!permissionContentTypeValid(subjectEvidence.permission)) { + if (!permissionContentTypeValid(subjectPermission)) { return indeterminate( request, { @@ -445,7 +391,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (request.requiredPermission === 'admin' && subjectEvidence.permission.effective !== 'admin') { + if (request.requiredPermission === 'admin' && subjectPermission.effective !== 'admin') { return refused( request, { @@ -455,10 +401,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if ( - subjectEvidence.permission.effective === 'read' || - subjectEvidence.permission.effective === 'none' - ) { + if (subjectPermission.effective === 'read' || subjectPermission.effective === 'none') { return refused( request, { @@ -469,8 +412,8 @@ async function evaluateGiteaWriteValidationUnsafe( ); } if ( - subjectEvidence.receivePack.principal !== request.identity || - subjectEvidence.receivePack.resolutionId !== resolved.resolutionId + subjectReceivePack.principal !== request.identity || + subjectReceivePack.resolutionId !== resolved.resolutionId ) { return indeterminate( request, @@ -481,7 +424,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (!advertised(subjectEvidence.receivePack)) { + if (!advertised(subjectReceivePack)) { return indeterminate( request, { @@ -544,17 +487,21 @@ async function evaluateGiteaWriteValidationUnsafe( } const evidence = successfulEvidence( - subjectEvidence.identity, - subjectEvidence.permission, - subjectEvidence.receivePack, + request.identity, + subjectIdentity, + subjectPermission, + subjectReceivePack, controlIdentity, controlPermission, controlReceivePack, ); return result(request, { - outcome: 'ok', - code: 'validation-verified', - message: 'Every required provider evidence layer agreed.', + outcome: subjectIdentity === null ? 'indeterminate' : 'ok', + code: subjectIdentity === null ? 'identity-not-measured' : 'validation-verified', + message: + subjectIdentity === null + ? 'Write capability and both controls were confirmed, but identity was not measured because this least-privilege token cannot read /user.' + : 'Every required provider evidence layer agreed.', evidence, }); } -- 2.54.0 From 1391daae4c9d41d8ed6583ce64f86df8975e9fd2 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:32:57 -0500 Subject: [PATCH 10/13] fix(mosaic): serialize credential lifecycle mutations --- .../framework/tools/git/detect-platform.sh | 6 +- .../framework/tools/git/git-credential-mosaic | 5 +- .../tools/git/resolve-legacy-token.py | 49 +++++++++++++++ .../tools/git/test-git-credential-mosaic.sh | 2 + .../tools/git/test-gitea-token-identity.sh | 1 + packages/mosaic/src/commands/cred.ts | 50 +++++++++++++-- .../src/credentials/estate-registry.dto.ts | 1 + .../mosaic/src/credentials/estate-registry.ts | 12 ++++ .../src/credentials/file-credential-store.ts | 63 +++++++++++++++---- packages/mosaic/src/credentials/lifecycle.ts | 19 ++++-- 10 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/resolve-legacy-token.py diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 5519bd29..3baa13f7 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -548,10 +548,12 @@ get_gitea_token() { printf '%s\n' "$_resolved_token" return 0 fi - if [[ -r "$_idtok" ]]; then + if [[ -e "$_idtok" || -L "$_idtok" ]]; then + local _resolved_token + _resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" "$_idtok") || return 1 _resolution_path=identity _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" - cat "$_idtok" + printf '%s\n' "$_resolved_token" return 0 fi # FAIL LOUD: an explicit git identity was requested for a recognized Gitea host, diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index a8cce2a9..ec87df2b 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -66,11 +66,12 @@ if [ -n "$ident" ]; then echo "password=${token}" exit 0 fi - if [ -r "$idtok" ]; then + if [ -e "$idtok" ] || [ -L "$idtok" ]; then + token=$(python3 "$script_dir/resolve-legacy-token.py" "$idtok") || exit 1 resolution_path=identity trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" - echo "password=$(cat "$idtok")" + echo "password=${token}" exit 0 fi if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then diff --git a/packages/mosaic/framework/tools/git/resolve-legacy-token.py b/packages/mosaic/framework/tools/git/resolve-legacy-token.py new file mode 100644 index 00000000..2f445d5a --- /dev/null +++ b/packages/mosaic/framework/tools/git/resolve-legacy-token.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Fail-closed reader for one legacy per-seat token file.""" + +import os +import stat +import sys + +MAX_BYTES = 16 * 1024 + + +def refuse(message: str) -> None: + print(f"legacy credential refused: {message}", file=sys.stderr) + raise SystemExit(1) + + +if len(sys.argv) != 2: + refuse("expected token path") +path = sys.argv[1] +parent = os.path.dirname(path) +try: + parent_stat = os.stat(parent, follow_symlinks=False) +except OSError: + refuse("credential directory unavailable") +if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode): + refuse("credential directory is not a real directory") +if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022: + refuse("credential directory owner or mode is unsafe") +try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) +except OSError: + refuse("credential file unavailable or symbolic") +try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + refuse("credential is not a regular file") + if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077: + refuse("credential owner or mode is unsafe") + content = os.read(fd, MAX_BYTES + 1) + if len(content) > MAX_BYTES: + refuse("credential exceeds size limit") +finally: + os.close(fd) +try: + token = content.decode("utf-8").strip() +except UnicodeDecodeError: + refuse("credential is not UTF-8") +if not token or any(ch.isspace() for ch in token): + refuse("credential token is invalid") +sys.stdout.write(token + "\n") diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index df852e24..c765b9ae 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -16,6 +16,7 @@ # NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. set -euo pipefail +umask 077 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}" @@ -35,6 +36,7 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \ cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER" cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py" +cp "$SCRIPT_DIR/resolve-legacy-token.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-legacy-token.py" chmod +x "$HELPER" git -C "$REPO_DIR" init -q diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index 1db4bb39..fe39a53a 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -28,6 +28,7 @@ # HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. set -euo pipefail +umask 077 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}" diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index d6255b7a..fe20c2de 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,5 +1,5 @@ import { timingSafeEqual } from 'node:crypto'; -import { fstatSync, writeSync } from 'node:fs'; +import { fstatSync, lstatSync, writeSync } from 'node:fs'; import { open, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -9,7 +9,7 @@ import { CredentialJournalError, listCredentialJournals, } from '../credentials/audit-journal.js'; -import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js'; import { runCredentialReadValidation, runCredentialValidation, @@ -542,10 +542,15 @@ export async function executeCredentialRotate( context.provider, context.store, context.teaStore, - { stateRoot: context.stateRoot, actor: options.actor, allowReplace: true }, + { + stateRoot: context.stateRoot, + actor: options.actor, + allowReplace: true, + journal, + deferSuccessSeal: true, + }, ); if (provisioned.outcome !== 'ok') { - await journal.seal(provisioned.outcome, provisioned.reason.code); old.secret.fill(0); return { ...provisioned, @@ -616,7 +621,25 @@ export async function executeCredentialWire( }); } const locations = lifecycleLocations(options); + const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents'); + if (dirname(options.seatEnv) !== seatEnvironmentRoot) { + return localLifecycleResult('wire', identity, options, { + outcome: 'refused', + code: 'insecure-credential-destination', + message: 'Seat environment must be directly beneath the governed fleet agent directory.', + }); + } try { + ensureManagedDirectory(seatEnvironmentRoot, seatEnvironmentRoot); + const parent = lstatSync(seatEnvironmentRoot); + if ( + !parent.isDirectory() || + parent.isSymbolicLink() || + parent.uid !== process.getuid?.() || + (parent.mode & 0o022) !== 0 + ) { + throw new Error('seat environment directory is unsafe'); + } const context = await lifecycleContext(options); if (context.registry.resolve(options.estate, options.host) === undefined) { return localLifecycleResult('wire', identity, options, { @@ -668,6 +691,7 @@ export async function executeCredentialWire( `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, `GITEA_LOGIN=${identity}--${options.host}`, ); + const parentBefore = lstatSync(seatEnvironmentRoot); const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const handle = await open(temp, 'wx', 0o600); try { @@ -676,6 +700,15 @@ export async function executeCredentialWire( } finally { await handle.close(); } + const parentAfter = lstatSync(seatEnvironmentRoot); + if ( + parentAfter.dev !== parentBefore.dev || + parentAfter.ino !== parentBefore.ino || + parentAfter.uid !== process.getuid?.() || + (parentAfter.mode & 0o022) !== 0 + ) { + throw new Error('seat environment directory changed during mutation'); + } await rename(temp, options.seatEnv); await journal.recordMutation('wire-applied'); await journal.seal('ok', 'wire-verified'); @@ -813,6 +846,15 @@ async function executeAuthorizedInventoryRead( }); } const context = await lifecycleContext(options); + if (context.registry.inventoryAuthority(options.estate) !== options.actor) { + await journal.seal('refused', 'permission-denied'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'refused', + code: 'permission-denied', + message: 'Explicit actor is not the configured estate inventory authority.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } const authority = await lifecycleAuthority(options.actor, options); const providerIdentity = await context.provider.readIdentity(authority); if (providerIdentity.login !== options.actor) { diff --git a/packages/mosaic/src/credentials/estate-registry.dto.ts b/packages/mosaic/src/credentials/estate-registry.dto.ts index 60a7adb9..80e808a6 100644 --- a/packages/mosaic/src/credentials/estate-registry.dto.ts +++ b/packages/mosaic/src/credentials/estate-registry.dto.ts @@ -10,5 +10,6 @@ export interface CredentialHostConfigDto { export interface CredentialEstateConfigDto { readonly name: string; readonly readOnlyControlIdentity?: string; + readonly inventoryAuthorityIdentity?: string; readonly hosts: readonly CredentialHostConfigDto[]; } diff --git a/packages/mosaic/src/credentials/estate-registry.ts b/packages/mosaic/src/credentials/estate-registry.ts index f1349624..de3cedf2 100644 --- a/packages/mosaic/src/credentials/estate-registry.ts +++ b/packages/mosaic/src/credentials/estate-registry.ts @@ -19,6 +19,7 @@ const estateSchema = z .object({ name: z.string().regex(NAME), readOnlyControlIdentity: z.string().regex(IDENTITY).optional(), + inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(), hosts: z.array(hostSchema).min(1), }) .strict(); @@ -105,6 +106,17 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry return undefined; } + inventoryAuthority(estate: string): string { + const identity = this.estates.get(estate)?.inventoryAuthorityIdentity; + if (identity === undefined) { + throw new CredentialEstateRegistryError( + 'inventory-authority-missing', + `estate ${estate} has no delegated inventory authority identity`, + ); + } + return identity; + } + readOnlyControl(estate: string): string { const identity = this.estates.get(estate)?.readOnlyControlIdentity; if (identity === undefined) { diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 569e525c..9b5f04e3 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -180,10 +180,17 @@ export class FileCredentialResolver implements CredentialResolver { }); const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8'))); const digest = createHash('sha256').update(snapshot.content).digest('hex'); - if (!binding.success || binding.data.tokenDigest !== digest) { + if ( + !binding.success || + binding.data.tokenDigest !== digest || + binding.data.identity !== identity || + binding.data.estate !== estate || + binding.data.host !== host || + binding.data.providerLogin !== identity + ) { throw new CredentialStoreError( 'credential-generation-mismatch', - 'token and binding metadata are not one committed generation', + 'token and binding metadata are not one committed identity-bound generation', ); } } catch (error: unknown) { @@ -410,17 +417,47 @@ export class FileCredentialStore { .sort(); } - async remove(identity: string, estate: string, host: string): Promise { + async remove( + identity: string, + estate: string, + host: string, + expectedTokenDigest?: string, + ): Promise { const paths = this.paths(identity, estate, host); - await unlink(paths.token).catch((error: unknown): void => { - if (!isMissingFile(error)) throw error; - }); - await unlink(paths.binding).catch((error: unknown): void => { - if (!isMissingFile(error)) throw error; - }); - await unlink(paths.envelope).catch((error: unknown): void => { - if (!isMissingFile(error)) throw error; - }); - await syncDirectory(this.tokenDirectory); + const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`); + let lock; + try { + lock = await open(lockPath, 'wx', 0o600); + } catch { + throw new CredentialStoreError( + 'conflicting-credential-mutation', + 'another mutation owns the identity lock', + ); + } + try { + if (expectedTokenDigest !== undefined) { + const current = await this.readBinding(identity, estate, host); + if (current === undefined || current.tokenDigest !== expectedTokenDigest) { + throw new CredentialStoreError( + 'credential-generation-mismatch', + 'credential generation changed before removal', + ); + } + } + await unlink(paths.token).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await unlink(paths.binding).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await unlink(paths.envelope).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await syncDirectory(this.tokenDirectory); + } finally { + await lock.close(); + await unlink(lockPath).catch((): void => undefined); + await syncDirectory(this.tokenDirectory); + } } } diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 32296390..4ca8ebcb 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -50,6 +50,8 @@ export interface LifecycleOptions { readonly actor: string; readonly now?: () => string; readonly allowReplace?: boolean; + readonly journal?: CredentialAuditJournal; + readonly deferSuccessSeal?: boolean; } function lifecycleResult( @@ -113,7 +115,7 @@ export async function provisionCredential( teaStore: TeaLoginStore, options: LifecycleOptions, ): Promise { - const journal = await openLifecycleJournal('provision', request, options); + const journal = options.journal ?? (await openLifecycleJournal('provision', request, options)); let mutation: 'none' | 'unknown' | 'applied' = 'none'; let minted: MintedToken | undefined; let failureCode = 'mutation-state-unknown'; @@ -192,14 +194,19 @@ export async function provisionCredential( contentType: readBack.contentType, decision: 'scope-verified', }); - await journal.seal('ok', 'provision-verified'); + if (options.deferSuccessSeal !== true) { + await journal.seal('ok', 'provision-verified'); + } return lifecycleResult('provision', request, { outcome: 'ok', mutation: 'applied', - code: 'provision-verified', - message: 'Provider principal and exact token scopes were read back and stored.', + code: options.deferSuccessSeal === true ? 'replacement-staged' : 'provision-verified', + message: + options.deferSuccessSeal === true + ? 'Replacement was read back and staged under the open rotation transaction.' + : 'Provider principal and exact token scopes were read back and stored.', journalId: journal.journalId(), - auditState: 'sealed', + auditState: options.deferSuccessSeal === true ? 'open' : 'sealed', providerIdentity: identity.login, token: readBack, teaLogin: { ...teaLogin, state: 'registered' }, @@ -305,7 +312,7 @@ export async function revokeCredential( throw new Error('Tea login still exists after revocation'); } await journal.recordMutation('tea-login-removed'); - await store.remove(request.identity, request.estate, request.host); + await store.remove(request.identity, request.estate, request.host, binding.tokenDigest); await journal.seal('ok', 'revoke-verified'); return lifecycleResult('revoke', request, { outcome: 'ok', -- 2.54.0 From 389a8d36f216b1853d167cc8cbe15d0de43efb81 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:37:44 -0500 Subject: [PATCH 11/13] fix(mosaic): bind delegated lifecycle evidence --- .../framework/tools/git/detect-platform.sh | 10 ++- .../framework/tools/git/git-credential-mosaic | 10 ++- .../tools/git/resolve-credential-envelope.py | 8 ++- .../tools/git/resolve-legacy-token.py | 8 ++- .../credential-validate-service.ts | 67 ++++++++++++++++--- packages/mosaic/src/credentials/lifecycle.ts | 29 +++++++- .../mosaic/src/credentials/tea-login-store.ts | 11 +++ 7 files changed, 124 insertions(+), 19 deletions(-) diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 3baa13f7..08ab316f 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -525,6 +525,11 @@ get_gitea_token() { _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" _ident_src="git config mosaic.gitIdentity" fi + if [[ -n "$_ident" && ! "$_ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity= host=%s shared_path_entered=false source=%s\n' \ + "$host" "$_ident_src" >&2 + return 1 + fi if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \ "$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2 @@ -542,7 +547,7 @@ get_gitea_token() { if [[ -e "$_idcred" || -L "$_idcred" ]]; then local _resolved_token _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \ - "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 + "$HOME/.config/mosaic/secrets/gitea-tokens" "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 _resolution_path=identity _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" printf '%s\n' "$_resolved_token" @@ -550,7 +555,8 @@ get_gitea_token() { fi if [[ -e "$_idtok" || -L "$_idtok" ]]; then local _resolved_token - _resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" "$_idtok") || return 1 + _resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" \ + "$HOME/.config/mosaic/secrets/gitea-tokens" "$_idtok") || return 1 _resolution_path=identity _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" printf '%s\n' "$_resolved_token" diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index ec87df2b..3056f50f 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -47,6 +47,11 @@ esac ident="$MOSAIC_GIT_IDENTITY" [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident="$username_in" +if [[ -n "$ident" && ! "$ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity= host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2 + exit 1 +fi if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then echo "quit=true" printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ @@ -59,7 +64,7 @@ if [ -n "$ident" ]; then idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" if [ -e "$idcred" ] || [ -L "$idcred" ]; then token=$(python3 "$script_dir/resolve-credential-envelope.py" \ - "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 + "$HOME/.config/mosaic/secrets/gitea-tokens" "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 resolution_path=identity trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" @@ -67,7 +72,8 @@ if [ -n "$ident" ]; then exit 0 fi if [ -e "$idtok" ] || [ -L "$idtok" ]; then - token=$(python3 "$script_dir/resolve-legacy-token.py" "$idtok") || exit 1 + token=$(python3 "$script_dir/resolve-legacy-token.py" \ + "$HOME/.config/mosaic/secrets/gitea-tokens" "$idtok") || exit 1 resolution_path=identity trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" diff --git a/packages/mosaic/framework/tools/git/resolve-credential-envelope.py b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py index 50a5e4c9..c3313a80 100644 --- a/packages/mosaic/framework/tools/git/resolve-credential-envelope.py +++ b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py @@ -27,9 +27,11 @@ def refuse(message: str) -> None: raise SystemExit(1) -if len(sys.argv) != 5: - refuse("expected path, identity, estate, and host") -path, identity, estate, host = sys.argv[1:] +if len(sys.argv) != 6: + refuse("expected governed root, path, identity, estate, and host") +root, path, identity, estate, host = sys.argv[1:] +if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root): + refuse("credential is not a direct child of the governed root") if not estate: refuse("explicit estate is required") parent = os.path.dirname(path) diff --git a/packages/mosaic/framework/tools/git/resolve-legacy-token.py b/packages/mosaic/framework/tools/git/resolve-legacy-token.py index 2f445d5a..c861950f 100644 --- a/packages/mosaic/framework/tools/git/resolve-legacy-token.py +++ b/packages/mosaic/framework/tools/git/resolve-legacy-token.py @@ -13,9 +13,11 @@ def refuse(message: str) -> None: raise SystemExit(1) -if len(sys.argv) != 2: - refuse("expected token path") -path = sys.argv[1] +if len(sys.argv) != 3: + refuse("expected governed root and token path") +root, path = sys.argv[1:] +if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root): + refuse("credential is not a direct child of the governed root") parent = os.path.dirname(path) try: parent_stat = os.stat(parent, follow_symlinks=False) diff --git a/packages/mosaic/src/credentials/credential-validate-service.ts b/packages/mosaic/src/credentials/credential-validate-service.ts index b7fb62a0..ebead0e8 100644 --- a/packages/mosaic/src/credentials/credential-validate-service.ts +++ b/packages/mosaic/src/credentials/credential-validate-service.ts @@ -1,4 +1,4 @@ -import { CredentialAuditJournal } from './audit-journal.js'; +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; import type { CredentialValidationDependencies, GiteaReadValidationRequestDto, @@ -66,17 +66,61 @@ async function recordAndSealValidation( }; } +function journalFailureResult( + request: GiteaReadValidationRequestDto, + journal: CredentialAuditJournal, + error: CredentialJournalError, + operation: 'validate' | 'whoami', +): CredentialValidationResultDto { + return { + schemaVersion: 1, + operation, + outcome: 'error', + exitCode: 20, + retryable: false, + subject: { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }, + mutation: 'none', + reason: { + code: error.code, + message: 'Validation audit persistence failed; inspect the durable open journal.', + }, + evidence: { + providerIdentity: null, + tokenCapabilities: { + state: 'not-measured', + scopes: [], + source: 'runtime-not-authorized', + }, + repositoryPermission: null, + writeDifferential: null, + }, + audit: { journalId: journal.journalId(), state: 'open' }, + }; +} + 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, - operation: options.operation ?? 'validate', - }); + try { + const validation = await evaluateGiteaReadValidation(request, dependencies); + return await recordAndSealValidation(journal, { + ...validation, + operation: options.operation ?? 'validate', + }); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) { + return journalFailureResult(request, journal, error, options.operation ?? 'validate'); + } + throw error; + } } export async function runCredentialValidation( @@ -85,6 +129,13 @@ export async function runCredentialValidation( options: CredentialValidationServiceOptions, ): Promise { const journal = await openValidationJournal(request, options); - const validation = await evaluateGiteaWriteValidation(request, dependencies); - return recordAndSealValidation(journal, validation); + try { + const validation = await evaluateGiteaWriteValidation(request, dependencies); + return await recordAndSealValidation(journal, validation); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) { + return journalFailureResult(request, journal, error, 'validate'); + } + throw error; + } } diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 4ca8ebcb..4314d928 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -292,6 +292,24 @@ export async function revokeCredential( auditState: 'sealed', }); } + const identity = await provider.readBasicIdentity(authority); + if (identity.login !== request.identity || authority.identity !== request.identity) { + await journal.seal('refused', 'provider-identity-mismatch'); + return lifecycleResult('revoke', request, { + outcome: 'refused', + mutation: 'none', + code: 'provider-identity-mismatch', + message: 'Delegated Basic authority did not bind the requested principal.', + journalId: journal.journalId(), + auditState: 'sealed', + providerIdentity: identity.login, + }); + } + await journal.recordProviderEvidence({ + endpoint: identity.endpoint, + contentType: identity.contentType, + decision: 'identity-verified', + }); mutation = 'unknown'; await provider.revokeToken(authority, request.identity, binding.tokenName); mutation = 'applied'; @@ -323,7 +341,16 @@ export async function revokeCredential( auditState: 'sealed', }); } catch (error: unknown) { - if (error instanceof CredentialJournalError) throw error; + if (error instanceof CredentialJournalError) { + return lifecycleResult('revoke', request, { + outcome: 'indeterminate', + mutation, + code: error.code, + message: 'Audit persistence failed; inspect the durable open journal before recovery.', + journalId: journal.journalId(), + auditState: 'open', + }); + } await journal.seal('indeterminate', 'mutation-state-unknown'); return lifecycleResult('revoke', request, { outcome: 'indeterminate', diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 1b302d41..2eb4b109 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -42,6 +42,15 @@ const loginSchema = z .passthrough(); const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough(); +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + async function acquireLock(path: string): Promise>> { for (let attempt = 0; attempt < 500; attempt += 1) { try { @@ -122,6 +131,7 @@ export class TeaLoginStore { await handle.close(); } await rename(temp, this.configPath); + await syncDirectory(directory); } finally { await lock.close(); await unlink(lockPath).catch((): void => undefined); @@ -192,6 +202,7 @@ export class TeaLoginStore { await handle.close(); } await rename(temp, this.configPath); + await syncDirectory(directory); } finally { await lock.close(); await unlink(lockPath).catch((): void => undefined); -- 2.54.0 From b0bedfb73c2b73f44219496265105ead95fbde85 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:43:02 -0500 Subject: [PATCH 12/13] fix(mosaic): fail closed across lifecycle audit faults --- packages/mosaic/src/commands/cred.ts | 192 +++++++++++------- .../src/credentials/file-credential-store.ts | 41 ++++ .../mosaic/src/credentials/gitea-provider.ts | 39 +++- packages/mosaic/src/credentials/grant.spec.ts | 6 +- packages/mosaic/src/credentials/grant.ts | 4 +- .../mosaic/src/credentials/team-grant.spec.ts | 4 +- packages/mosaic/src/credentials/team-grant.ts | 4 +- 7 files changed, 206 insertions(+), 84 deletions(-) diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index fe20c2de..95cff93c 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -504,23 +504,6 @@ export async function executeCredentialRotate( }); } const context = await lifecycleContext(options); - const old = await context.store.snapshot(identity, options.estate, options.host); - if (old === undefined) { - return localLifecycleResult('rotate', identity, options, { - outcome: 'refused', - code: 'no-token-for-identity', - message: 'No existing binding can be rotated.', - }); - } - if (options.tokenName === old.binding.tokenName) { - old.secret.fill(0); - return localLifecycleResult('rotate', identity, options, { - outcome: 'refused', - code: 'replacement-token-name-conflict', - message: 'Replacement token name must differ from the active generation.', - }); - } - const authority = await lifecycleAuthority(identity, options); const journal = await CredentialAuditJournal.open(context.stateRoot, { operation: 'rotate', actor: options.actor, @@ -530,6 +513,25 @@ export async function executeCredentialRotate( repo: null, }); await journal.recordIntent('rotate-requested'); + const old = await context.store.snapshot(identity, options.estate, options.host); + if (old === undefined) { + await journal.seal('refused', 'no-token-for-identity'); + return localLifecycleResult('rotate', identity, options, { + outcome: 'refused', + code: 'no-token-for-identity', + message: 'No existing binding can be rotated.', + }); + } + if (options.tokenName === old.binding.tokenName) { + old.secret.fill(0); + await journal.seal('refused', 'replacement-token-name-conflict'); + return localLifecycleResult('rotate', identity, options, { + outcome: 'refused', + code: 'replacement-token-name-conflict', + message: 'Replacement token name must differ from the active generation.', + }); + } + const authority = await lifecycleAuthority(identity, options); const provisioned = await provisionCredential( { identity, @@ -655,16 +657,17 @@ export async function executeCredentialWire( message: 'Declared estate and host could not be resolved before mutation.', }); } - const journal = await CredentialAuditJournal.open(locations.stateRoot, { - operation: 'wire', - actor: options.actor, - identity, - estate: options.estate, - host: options.host, - repo: null, - }); - await journal.recordIntent('wire-requested'); + let journal: CredentialAuditJournal | undefined; try { + journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation: 'wire', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('wire-requested'); let existing = ''; try { const snapshot = readRegularFileSecure(options.seatEnv, { @@ -719,15 +722,32 @@ export async function executeCredentialWire( message: 'Both fleet identity axes were written to the explicit seat environment.', audit: { journalId: journal.journalId(), state: 'sealed' }, }); - } catch { - await journal.seal('error', 'wire-failed'); - return localLifecycleResult('wire', identity, options, { - outcome: 'error', - mutation: 'unknown', - code: 'wire-failed', - message: 'Seat environment wiring failed.', - audit: { journalId: journal.journalId(), state: 'sealed' }, - }); + } catch (error: unknown) { + if (journal === undefined) { + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + code: error instanceof CredentialJournalError ? error.code : 'wire-failed', + message: 'Seat environment journal could not be opened durably.', + }); + } + try { + await journal.seal('error', 'wire-failed'); + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + mutation: 'unknown', + code: 'wire-failed', + message: 'Seat environment wiring failed.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch (sealError: unknown) { + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + mutation: 'unknown', + code: sealError instanceof CredentialJournalError ? sealError.code : 'wire-failed', + message: 'Seat environment audit could not be sealed.', + audit: { journalId: journal.journalId(), state: 'open' }, + }); + } } } @@ -736,16 +756,17 @@ export async function executeCredentialGet( options: CredentialLifecycleCommandOptions, ): Promise { const locations = lifecycleLocations(options); - const journal = await CredentialAuditJournal.open(locations.stateRoot, { - operation: 'get', - actor: options.actor, - identity, - estate: options.estate, - host: options.host, - repo: null, - }); - await journal.recordIntent('get-requested'); + let journal: CredentialAuditJournal | undefined; try { + journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation: 'get', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('get-requested'); if (options.authorityFd === undefined || options.actor !== identity) { await journal.seal('refused', 'provider-identity-mismatch'); return localLifecycleResult('get', identity, options, { @@ -810,14 +831,30 @@ export async function executeCredentialGet( message: 'Credential was emitted only to the protected output fd.', audit: { journalId: journal.journalId(), state: 'sealed' }, }); - } catch { - await journal.seal('error', 'insecure-credential-destination'); - return localLifecycleResult('get', identity, options, { - outcome: 'error', - code: 'insecure-credential-destination', - message: 'Protected credential output fd was unavailable or unsafe.', - audit: { journalId: journal.journalId(), state: 'sealed' }, - }); + } catch (error: unknown) { + if (journal === undefined) { + return localLifecycleResult('get', identity, options, { + outcome: 'error', + code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable', + message: 'Credential access journal could not be opened durably.', + }); + } + try { + await journal.seal('error', 'insecure-credential-destination'); + return localLifecycleResult('get', identity, options, { + outcome: 'error', + code: 'insecure-credential-destination', + message: 'Protected credential output fd was unavailable or unsafe.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch (sealError: unknown) { + return localLifecycleResult('get', identity, options, { + outcome: 'error', + code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable', + message: 'Credential access audit could not be sealed.', + audit: { journalId: journal.journalId(), state: 'open' }, + }); + } } } @@ -826,16 +863,17 @@ async function executeAuthorizedInventoryRead( options: CredentialLifecycleCommandOptions, ): Promise { const locations = lifecycleLocations(options); - const journal = await CredentialAuditJournal.open(locations.stateRoot, { - operation, - actor: options.actor, - identity: options.actor, - estate: options.estate, - host: options.host, - repo: null, - }); - await journal.recordIntent(`${operation}-requested`); + let journal: CredentialAuditJournal | undefined; try { + journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation, + actor: options.actor, + identity: options.actor, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent(`${operation}-requested`); if (options.authorityFd === undefined) { await journal.seal('refused', 'authority-required'); return localLifecycleResult(operation, 'all', options, { @@ -891,14 +929,30 @@ async function executeAuthorizedInventoryRead( }, audit: { journalId: journal.journalId(), state: 'sealed' }, }); - } catch { - await journal.seal('error', 'inventory-read-failed'); - return localLifecycleResult(operation, 'all', options, { - outcome: 'error', - code: 'inventory-read-failed', - message: 'Authorized credential inventory read failed.', - audit: { journalId: journal.journalId(), state: 'sealed' }, - }); + } catch (error: unknown) { + if (journal === undefined) { + return localLifecycleResult(operation, 'all', options, { + outcome: 'error', + code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable', + message: 'Inventory journal could not be opened durably.', + }); + } + try { + await journal.seal('error', 'inventory-read-failed'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'error', + code: 'inventory-read-failed', + message: 'Authorized credential inventory read failed.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch (sealError: unknown) { + return localLifecycleResult(operation, 'all', options, { + outcome: 'error', + code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable', + message: 'Inventory audit could not be sealed.', + audit: { journalId: journal.journalId(), state: 'open' }, + }); + } } } diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 9b5f04e3..6ab27af2 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -222,6 +222,25 @@ export class FileCredentialResolver implements CredentialResolver { } } +function assertPrivateTokenDirectory(path: string): { + readonly dev: number | bigint; + readonly ino: number | bigint; +} { + const stat = lstatSync(path); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== process.getuid?.() || + (stat.mode & 0o022) !== 0 + ) { + throw new CredentialStoreError( + 'insecure-token-owner', + 'token directory ownership or write permissions are unsafe', + ); + } + return { dev: stat.dev, ino: stat.ino }; +} + async function syncDirectory(path: string): Promise { const handle = await open(path, 'r'); try { @@ -269,6 +288,7 @@ export class FileCredentialStore { async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise { const paths = this.paths(metadata.identity, metadata.estate, metadata.host); ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory); + const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory); const token = validateSecret(Buffer.from(secret)); const envelope = credentialEnvelopeSchema.parse({ ...metadata, @@ -296,6 +316,16 @@ export class FileCredentialStore { } finally { await handle.close(); } + const beforeCommit = assertPrivateTokenDirectory(this.tokenDirectory); + if ( + beforeCommit.dev !== directoryIdentity.dev || + beforeCommit.ino !== directoryIdentity.ino + ) { + throw new CredentialStoreError( + 'insecure-token-owner', + 'token directory changed during credential commit', + ); + } await rename(envelopeTemp, paths.envelope); await syncDirectory(this.tokenDirectory); } finally { @@ -424,6 +454,7 @@ export class FileCredentialStore { expectedTokenDigest?: string, ): Promise { const paths = this.paths(identity, estate, host); + const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory); const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`); let lock; try { @@ -444,6 +475,16 @@ export class FileCredentialStore { ); } } + const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory); + if ( + beforeRemoval.dev !== directoryIdentity.dev || + beforeRemoval.ino !== directoryIdentity.ino + ) { + throw new CredentialStoreError( + 'insecure-token-owner', + 'token directory changed during credential removal', + ); + } await unlink(paths.token).catch((error: unknown): void => { if (!isMissingFile(error)) throw error; }); diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index db0bef6e..18738b5b 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -389,6 +389,33 @@ export class GiteaGrantProviderAdapter extends GiteaCredentialProviderAdapter implements GiteaGrantProvider { + async readBasicIdentity(authority: 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: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'credential-rejected', + 'delegated Basic authority was rejected', + ); + } + const parsed = userSchema.safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'delegated Basic identity response was malformed', + ); + } + return { login: parsed.data.login, endpoint, contentType: contentType(response) }; + } + async grantCollaborator( authority: ResolvedCredential, identity: string, @@ -402,7 +429,7 @@ export class GiteaGrantProviderAdapter method: 'PUT', headers: { Accept: JSON_CONTENT_TYPE, - Authorization: apiAuthorization(authority), + Authorization: basicAuthorization(authority), 'Content-Type': JSON_CONTENT_TYPE, 'User-Agent': USER_AGENT, }, @@ -433,7 +460,7 @@ export class GiteaGrantProviderAdapter method: 'GET', headers: { Accept: JSON_CONTENT_TYPE, - Authorization: apiAuthorization(authority), + Authorization: basicAuthorization(authority), 'User-Agent': USER_AGENT, }, }, @@ -518,7 +545,7 @@ export class GiteaTeamGrantProviderAdapter method: 'GET', headers: { Accept: JSON_CONTENT_TYPE, - Authorization: apiAuthorization(authority), + Authorization: basicAuthorization(authority), 'User-Agent': USER_AGENT, }, }, @@ -557,7 +584,7 @@ export class GiteaTeamGrantProviderAdapter method: 'GET', headers: { Accept: JSON_CONTENT_TYPE, - Authorization: apiAuthorization(authority), + Authorization: basicAuthorization(authority), 'User-Agent': USER_AGENT, }, }, @@ -613,7 +640,7 @@ export class GiteaTeamGrantProviderAdapter method: 'PUT', headers: { Accept: JSON_CONTENT_TYPE, - Authorization: apiAuthorization(authority), + Authorization: basicAuthorization(authority), 'User-Agent': USER_AGENT, }, }); @@ -666,7 +693,7 @@ export class GiteaTeamGrantProviderAdapter method: 'GET', headers: { Accept: JSON_CONTENT_TYPE, - Authorization: apiAuthorization(authority), + Authorization: basicAuthorization(authority), 'User-Agent': USER_AGENT, }, }); diff --git a/packages/mosaic/src/credentials/grant.spec.ts b/packages/mosaic/src/credentials/grant.spec.ts index 8eb62b70..3a3b6c58 100644 --- a/packages/mosaic/src/credentials/grant.spec.ts +++ b/packages/mosaic/src/credentials/grant.spec.ts @@ -83,7 +83,7 @@ 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() { + async readBasicIdentity() { return { login: 'provisioner', endpoint: 'GET /api/v1/user', @@ -142,7 +142,7 @@ describe('direct repository grant', (): void => { it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise => { const root = await stateRoot(); const provider: GiteaGrantProvider = { - async readIdentity() { + async readBasicIdentity() { return { login: 'provisioner', endpoint: 'GET /api/v1/user', @@ -183,7 +183,7 @@ describe('direct repository grant', (): void => { it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise => { const root = await stateRoot(); const provider: GiteaGrantProvider = { - async readIdentity() { + async readBasicIdentity() { return { login: 'provisioner', endpoint: 'GET /api/v1/user', diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index 195e3052..d694a672 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -13,7 +13,7 @@ import type { import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js'; export interface GiteaGrantProvider { - readIdentity(authority: ResolvedCredential): Promise<{ + readBasicIdentity(authority: ResolvedCredential): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string; @@ -76,7 +76,7 @@ export async function grantDirectRepositoryPermission( await journal.recordIntent('provider-grant'); let mutation: 'none' | 'unknown' | 'applied' = 'none'; try { - const authorityIdentity = await grantProvider.readIdentity(authority); + const authorityIdentity = await grantProvider.readBasicIdentity(authority); if (authorityIdentity.login !== options.actor) { await journal.seal('refused', 'provider-identity-mismatch'); return { diff --git a/packages/mosaic/src/credentials/team-grant.spec.ts b/packages/mosaic/src/credentials/team-grant.spec.ts index b5da30ae..7ad141b8 100644 --- a/packages/mosaic/src/credentials/team-grant.spec.ts +++ b/packages/mosaic/src/credentials/team-grant.spec.ts @@ -74,7 +74,7 @@ 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() { + async readBasicIdentity() { return { login: 'provisioner', endpoint: 'GET /api/v1/user', @@ -148,7 +148,7 @@ describe('team repository grant', (): void => { cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); let mutated = false; const provider: GiteaTeamGrantProvider = { - async readIdentity() { + async readBasicIdentity() { return { login: 'provisioner', endpoint: 'GET /api/v1/user', diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts index 39f104a6..d7ad4d39 100644 --- a/packages/mosaic/src/credentials/team-grant.ts +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -41,7 +41,7 @@ export interface TeamGrantResult extends CredentialGrantResultDto { }; } export interface GiteaTeamGrantProvider { - readIdentity( + readBasicIdentity( authority: ResolvedCredential, ): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>; resolveTeam( @@ -93,7 +93,7 @@ export async function grantTeamRepositoryPermission( await journal.recordIntent('provider-grant'); let mutation: 'none' | 'unknown' | 'applied' = 'none'; try { - const authorityIdentity = await provider.readIdentity(authority); + const authorityIdentity = await provider.readBasicIdentity(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) { -- 2.54.0 From f2666d7da9d552d14d9b424eb606d0f835164f95 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 14:25:55 -0500 Subject: [PATCH 13/13] fix(mosaic): harden credential mutation boundaries --- docs/PRD.md | 4 +- docs/credentials/GRANT-VALIDATE-CONTRACT.md | 4 +- docs/scratchpads/1045-mosaic-cred.md | 19 +- packages/mosaic/src/commands/cred.spec.ts | 419 ++++++++++++++++++ packages/mosaic/src/commands/cred.ts | 211 ++++++--- .../mosaic/src/credentials/audit-journal.ts | 4 + .../src/credentials/gitea-provider.spec.ts | 11 + .../mosaic/src/credentials/gitea-provider.ts | 51 +++ .../mosaic/src/credentials/team-grant.spec.ts | 394 +++++++++++++++- packages/mosaic/src/credentials/team-grant.ts | 248 ++++++++++- 10 files changed, 1281 insertions(+), 84 deletions(-) create mode 100644 packages/mosaic/src/commands/cred.spec.ts diff --git a/docs/PRD.md b/docs/PRD.md index 981f70b7..9747fe30 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -461,7 +461,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist 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. +13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the exact roster-derived `.env.generated` fleet projection so both identity axes survive restart. It SHALL authenticate the same explicit seat through a protected delegated credential channel and provider identity read-back before mutation, refuse actor/identity/path/roster disagreements, and never authorize from the shared Unix account. 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. @@ -475,7 +475,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist 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. +8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the exact roster-derived generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. An unauthenticated caller, a caller authenticated as another seat, a caller-selected filename, or a file whose roster identity differs is refused before mutation. 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. diff --git a/docs/credentials/GRANT-VALIDATE-CONTRACT.md b/docs/credentials/GRANT-VALIDATE-CONTRACT.md index 51a28cb7..ee13a247 100644 --- a/docs/credentials/GRANT-VALIDATE-CONTRACT.md +++ b/docs/credentials/GRANT-VALIDATE-CONTRACT.md @@ -171,7 +171,7 @@ 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` +- 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`, `concurrent-mutation`, `mutation-lock-unavailable`, `team-scope-changed-during-grant`, `wire-audit-incomplete` `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. @@ -201,4 +201,4 @@ No ref is updated and no repository artifact is created. This proves that the de ## 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 enumerates the team's complete repository attachment set before mutation and refuses any team already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). It then 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. +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 serializes governed mutations per provider team and enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; the operation never reports success from the stale pre-check. The grant then 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 index 3d4eafad..ae5d7b4a 100644 --- a/docs/scratchpads/1045-mosaic-cred.md +++ b/docs/scratchpads/1045-mosaic-cred.md @@ -4,7 +4,7 @@ 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`**. +Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. The trunk-only ruling superseded the original `next` checkpoint: the branch is rebased onto `origin/main` and its PR target is `main`. Linked issues remain **believed-fixed, pending jarvis validation** after merge. ## Requirements sources @@ -22,8 +22,8 @@ Deliver the governed `mosaic cred` identity boundary for issue, scope, validatio 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. +7. Run baseline/situational tests, independent code review and mandatory independent security review, CI on the exact head, then open the PR directly against `main` without closing issues or claiming Jarvis validation. +8. C1 merges first. After any base/head move, re-derive merge-base, commit set, diff, CI, reviews, and provider measurements from the replacement SHA. ## Budget @@ -41,7 +41,7 @@ No explicit token cap supplied. Working cap: keep implementation in one package - [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] Target-branch conflict resolved by the trunk-only ruling; the lane was rebased from `next` onto `origin/main`. - [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`. @@ -51,9 +51,10 @@ No explicit token cap supplied. Working cap: keep implementation in one package - [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, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, and fleet fail-closed resolver paths implemented; final review hardening and live provider lifecycle evidence open). -- [ ] Independent code/security reviews. -- [ ] CI and provider evidence. +- [x] Implementation: validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, reverse registry, and fleet fail-closed resolver paths. +- [x] Review hardening: rotation returns visible open journals; team evidence records absent objects accurately; team scope is checked before/after under a host-qualified OS advisory lock with verified compensation; `wire` authenticates the exact seat/path/roster binding and preserves post-rename mutation semantics. +- [ ] Independent code/security approvals on the final exact head (Codex advisory iterations are not independent approval). +- [ ] Final exact-head CI and provider evidence. ## Tests and evidence @@ -65,7 +66,7 @@ Red-first evidence: - 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: 62/62 across 9 credential suites; package lint and typecheck green. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled. +Current focused evidence: 77/77 across 11 credential/command suites; package lint, typecheck, formatting, and build are green. Full package Vitest reached 1,578 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled. Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation. @@ -78,4 +79,4 @@ Write differential for be-coder-06 passed with the configured read-only control - 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 least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes. -- Branch model compatibility remains escalated above this lane. Do not claim completion at `next`. +- C1 PR #1054 must first be rebuilt from only its four commits on `main`; the retargeted head `8b067839` carries 13 unrelated `next` commits and is not merge-eligible. MC-CRED remains sequenced after the clean C1 merge. diff --git a/packages/mosaic/src/commands/cred.spec.ts b/packages/mosaic/src/commands/cred.spec.ts new file mode 100644 index 00000000..85a36e93 --- /dev/null +++ b/packages/mosaic/src/commands/cred.spec.ts @@ -0,0 +1,419 @@ +import { + chmod, + mkdtemp, + mkdir, + open, + readFile, + readdir, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CredentialAuditJournal, + CredentialJournalError, + listCredentialJournals, +} from '../credentials/audit-journal.js'; +import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js'; +import { FileCredentialStore } from '../credentials/file-credential-store.js'; +import { executeCredentialRotate, executeCredentialWire } from './cred.js'; + +let cleanup: string | undefined; +afterEach(async (): Promise => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +async function fixture(): Promise<{ + readonly mosaicHome: string; + readonly registryPath: string; + readonly tokenDirectory: string; + readonly stateRoot: string; +}> { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-command-')); + await chmod(cleanup, 0o700); + const mosaicHome = join(cleanup, 'mosaic'); + const credentialDirectory = join(mosaicHome, 'cred'); + await mkdir(credentialDirectory, { recursive: true, mode: 0o700 }); + const registryPath = join(credentialDirectory, 'estates.json'); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ); + return { + mosaicHome, + registryPath, + tokenDirectory: join(mosaicHome, 'secrets', 'gitea-tokens'), + stateRoot: join(cleanup, 'state'), + }; +} + +describe('credential lifecycle command controls', (): void => { + it('returns the visible open rotation journal when protected authority resolution fails', async (): Promise => { + const paths = await fixture(); + const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8')); + await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 }); + const store = new FileCredentialStore(paths.tokenDirectory, registry); + await store.put( + { + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + providerLogin: 'seat-name', + tokenName: 'old-generation', + scopes: ['write:repository'], + createdAt: '2026-08-05T00:00:00.000Z', + }, + new TextEncoder().encode('old-token-canary'), + ); + + const result = await executeCredentialRotate('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'seat-name', + authorityFd: '999', + tokenName: 'new-generation', + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + }); + + expect(result.outcome).toBe('error'); + expect(result.mutation).toBe('none'); + expect(result.audit.state).toBe('open'); + expect(result.audit.journalId).not.toBeNull(); + await expect(listCredentialJournals(paths.stateRoot)).resolves.toContainEqual( + expect.objectContaining({ id: result.audit.journalId, state: 'open' }), + ); + }); + + it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise => { + const paths = await fixture(); + const agents = join(paths.mosaicHome, 'fleet', 'agents'); + await mkdir(agents, { recursive: true, mode: 0o700 }); + const seatEnvironment = join(agents, 'seat-name.env.generated'); + const before = 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n'; + await writeFile(seatEnvironment, before, { mode: 0o600 }); + + const result = await executeCredentialWire('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'intruder-seat', + seatEnv: seatEnvironment, + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + }); + + expect(result.outcome).toBe('refused'); + expect(result.mutation).toBe('none'); + await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before); + }); + + it('authenticates the exact seat and rewrites its roster-derived projection idempotently', async (): Promise => { + const paths = await fixture(); + const agents = join(paths.mosaicHome, 'fleet', 'agents'); + await mkdir(agents, { recursive: true, mode: 0o700 }); + const seatEnvironment = join(agents, 'seat-name.env.generated'); + await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n', { + mode: 0o600, + }); + const authorityPath = join(cleanup!, 'authority.json'); + await writeFile( + authorityPath, + JSON.stringify({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + secret: 'authority-canary', + }), + { mode: 0o600 }, + ); + vi.stubGlobal( + 'fetch', + async (): Promise => + new Response(JSON.stringify({ id: 7, login: 'seat-name' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const invoke = async () => { + const authority = await open(authorityPath, 'r'); + try { + return await executeCredentialWire('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'seat-name', + authorityFd: authority.fd.toString(), + seatEnv: seatEnvironment, + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + }); + } finally { + await authority.close(); + } + }; + + const first = await invoke(); + const afterFirst = await readFile(seatEnvironment, 'utf8'); + const second = await invoke(); + const afterSecond = await readFile(seatEnvironment, 'utf8'); + + expect(first.outcome).toBe('ok'); + expect(second.outcome).toBe('ok'); + expect(afterSecond).toBe(afterFirst); + expect(afterSecond).toContain('MOSAIC_GIT_IDENTITY=seat-name\n'); + expect(afterSecond).toContain('MOSAIC_CREDENTIAL_ESTATE=homelab\n'); + expect(afterSecond).toContain('GITEA_LOGIN=seat-name--git.example.invalid\n'); + }); + + it.each(['recordMutation', 'seal'] as const)( + 'reports an applied wire as indeterminate when audit %s fails after rename', + async (method): Promise => { + const paths = await fixture(); + const agents = join(paths.mosaicHome, 'fleet', 'agents'); + await mkdir(agents, { recursive: true, mode: 0o700 }); + const seatEnvironment = join(agents, 'seat-name.env.generated'); + await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 }); + const authorityPath = join(cleanup!, 'authority.json'); + await writeFile( + authorityPath, + JSON.stringify({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + secret: 'authority-canary', + }), + { mode: 0o600 }, + ); + vi.stubGlobal( + 'fetch', + async (): Promise => + new Response(JSON.stringify({ id: 7, login: 'seat-name' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.spyOn(CredentialAuditJournal.prototype, method).mockRejectedValueOnce( + new CredentialJournalError('journal-unavailable', 'injected audit failure'), + ); + const authority = await open(authorityPath, 'r'); + try { + const result = await executeCredentialWire('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'seat-name', + authorityFd: authority.fd.toString(), + seatEnv: seatEnvironment, + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + }); + expect(result.outcome).toBe('indeterminate'); + expect(result.mutation).toBe('applied'); + expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name'); + } finally { + await authority.close(); + } + }, + ); + + it('refuses to overwrite a roster projection replaced after validation', async (): Promise => { + const paths = await fixture(); + const agents = join(paths.mosaicHome, 'fleet', 'agents'); + await mkdir(agents, { recursive: true, mode: 0o700 }); + const seatEnvironment = join(agents, 'seat-name.env.generated'); + await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 }); + const authorityPath = join(cleanup!, 'authority.json'); + await writeFile( + authorityPath, + JSON.stringify({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + secret: 'authority-canary', + }), + { mode: 0o600 }, + ); + vi.stubGlobal( + 'fetch', + async (): Promise => + new Response(JSON.stringify({ id: 7, login: 'seat-name' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const authority = await open(authorityPath, 'r'); + try { + const result = await executeCredentialWire('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'seat-name', + authorityFd: authority.fd.toString(), + seatEnv: seatEnvironment, + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + wireBeforeRename: async (): Promise => { + const replacement = join(agents, 'replacement'); + await writeFile(replacement, 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', { mode: 0o600 }); + await rename(replacement, seatEnvironment); + }, + }); + expect(result.outcome).toBe('error'); + expect(result.mutation).toBe('none'); + expect(await readFile(seatEnvironment, 'utf8')).toBe( + 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', + ); + } finally { + await authority.close(); + } + }); + + it('reports directory-sync failure after rename as applied and indeterminate', async (): Promise => { + const paths = await fixture(); + const agents = join(paths.mosaicHome, 'fleet', 'agents'); + await mkdir(agents, { recursive: true, mode: 0o700 }); + const seatEnvironment = join(agents, 'seat-name.env.generated'); + await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 }); + const authorityPath = join(cleanup!, 'authority.json'); + await writeFile( + authorityPath, + JSON.stringify({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + secret: 'authority-canary', + }), + { mode: 0o600 }, + ); + vi.stubGlobal( + 'fetch', + async (): Promise => + new Response(JSON.stringify({ id: 7, login: 'seat-name' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const authority = await open(authorityPath, 'r'); + try { + const result = await executeCredentialWire('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'seat-name', + authorityFd: authority.fd.toString(), + seatEnv: seatEnvironment, + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + wireDirectorySync: async (): Promise => { + throw new Error('injected directory sync failure'); + }, + }); + expect(result.outcome).toBe('indeterminate'); + expect(result.mutation).toBe('applied'); + expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name'); + } finally { + await authority.close(); + } + }); + + it('removes a temporary projection when directory revalidation fails before rename', async (): Promise => { + const paths = await fixture(); + const agents = join(paths.mosaicHome, 'fleet', 'agents'); + await mkdir(agents, { recursive: true, mode: 0o700 }); + const seatEnvironment = join(agents, 'seat-name.env.generated'); + await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 }); + const authorityPath = join(cleanup!, 'authority.json'); + await writeFile( + authorityPath, + JSON.stringify({ + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + secret: 'authority-canary', + }), + { mode: 0o600 }, + ); + vi.stubGlobal('fetch', async (): Promise => { + await chmod(agents, 0o777); + return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + const authority = await open(authorityPath, 'r'); + try { + const result = await executeCredentialWire('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'seat-name', + authorityFd: authority.fd.toString(), + seatEnv: seatEnvironment, + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + }); + expect(result.outcome).toBe('error'); + expect(await readdir(agents)).toEqual(['seat-name.env.generated']); + } finally { + await authority.close(); + await chmod(agents, 0o700); + } + }); + + it('refuses a caller-selected seat filename that is not bound to the requested identity', async (): Promise => { + const paths = await fixture(); + const agents = join(paths.mosaicHome, 'fleet', 'agents'); + await mkdir(agents, { recursive: true, mode: 0o700 }); + const seatEnvironment = join(agents, 'other-seat.env.generated'); + const before = 'MOSAIC_AGENT_NAME=other-seat\nMOSAIC_AGENT_CLASS=coder\n'; + await writeFile(seatEnvironment, before, { mode: 0o600 }); + + const result = await executeCredentialWire('seat-name', { + estate: 'homelab', + host: 'git.example.invalid', + actor: 'seat-name', + authorityFd: '999', + seatEnv: seatEnvironment, + mosaicHome: paths.mosaicHome, + registry: paths.registryPath, + tokenDir: paths.tokenDirectory, + stateDir: paths.stateRoot, + }); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('credential-binding-mismatch'); + await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before); + }); +}); diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 95cff93c..1f9877a7 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,6 +1,6 @@ import { timingSafeEqual } from 'node:crypto'; -import { fstatSync, lstatSync, writeSync } from 'node:fs'; -import { open, rename } from 'node:fs/promises'; +import { constants, fstatSync, lstatSync, writeSync } from 'node:fs'; +import { open, rename, unlink } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import type { Command } from 'commander'; @@ -92,6 +92,8 @@ interface CredentialLifecycleCommandOptions { readonly teaConfig?: string; readonly mosaicHome?: string; readonly json?: boolean; + readonly wireBeforeRename?: () => Promise; + readonly wireDirectorySync?: (path: string) => Promise; } function defaultMosaicHome(options: { readonly mosaicHome?: string }): string { @@ -495,6 +497,10 @@ export async function executeCredentialRotate( identity: string, options: CredentialLifecycleCommandOptions, ): Promise { + let journal: CredentialAuditJournal | undefined; + let old: Awaited> = undefined; + let authority: Awaited> | undefined; + let mutation: CredentialLifecycleResultDto['mutation'] = 'none'; try { if (options.authorityFd === undefined || options.tokenName === undefined) { return localLifecycleResult('rotate', identity, options, { @@ -504,7 +510,7 @@ export async function executeCredentialRotate( }); } const context = await lifecycleContext(options); - const journal = await CredentialAuditJournal.open(context.stateRoot, { + journal = await CredentialAuditJournal.open(context.stateRoot, { operation: 'rotate', actor: options.actor, identity, @@ -513,25 +519,27 @@ export async function executeCredentialRotate( repo: null, }); await journal.recordIntent('rotate-requested'); - const old = await context.store.snapshot(identity, options.estate, options.host); + old = await context.store.snapshot(identity, options.estate, options.host); if (old === undefined) { await journal.seal('refused', 'no-token-for-identity'); return localLifecycleResult('rotate', identity, options, { outcome: 'refused', code: 'no-token-for-identity', message: 'No existing binding can be rotated.', + audit: { journalId: journal.journalId(), state: 'sealed' }, }); } if (options.tokenName === old.binding.tokenName) { - old.secret.fill(0); await journal.seal('refused', 'replacement-token-name-conflict'); return localLifecycleResult('rotate', identity, options, { outcome: 'refused', code: 'replacement-token-name-conflict', message: 'Replacement token name must differ from the active generation.', + audit: { journalId: journal.journalId(), state: 'sealed' }, }); } - const authority = await lifecycleAuthority(identity, options); + authority = await lifecycleAuthority(identity, options); + mutation = 'unknown'; const provisioned = await provisionCredential( { identity, @@ -553,13 +561,13 @@ export async function executeCredentialRotate( }, ); if (provisioned.outcome !== 'ok') { - old.secret.fill(0); return { ...provisioned, operation: 'rotate', audit: { journalId: journal.journalId(), state: 'sealed' }, }; } + mutation = 'applied'; try { await journal.recordMutation('token-mint-applied'); } catch (error: unknown) { @@ -569,7 +577,7 @@ export async function executeCredentialRotate( } await context.store.put(old.binding, old.secret); await context.teaStore.put(identity, options.host, old.secret); - old.secret.fill(0); + mutation = 'none'; throw error; } try { @@ -580,7 +588,6 @@ export async function executeCredentialRotate( await journal.recordMutation('token-revoke-applied'); } catch { await journal.seal('indeterminate', 'old-credential-state-unknown'); - old.secret.fill(0); return localLifecycleResult('rotate', identity, options, { outcome: 'indeterminate', mutation: 'unknown', @@ -591,7 +598,6 @@ export async function executeCredentialRotate( }); } await journal.seal('ok', 'rotate-verified'); - old.secret.fill(0); return { ...provisioned, operation: 'rotate', @@ -601,13 +607,29 @@ export async function executeCredentialRotate( }, audit: { journalId: journal.journalId(), state: 'sealed' }, }; - } catch { + } catch (error: unknown) { + if (journal !== undefined) { + await journal.closeIncomplete().catch((): void => undefined); + } + const outcome = mutation === 'none' ? 'error' : 'indeterminate'; return localLifecycleResult('rotate', identity, options, { - outcome: 'indeterminate', - mutation: 'unknown', - code: 'mutation-state-unknown', + outcome, + mutation, + code: + error instanceof CredentialJournalError + ? error.code + : mutation === 'none' + ? 'internal-invariant' + : 'mutation-state-unknown', message: 'Rotation did not establish both new-token acceptance and old-token revocation.', + audit: + journal === undefined + ? { journalId: null, state: 'not-started' } + : { journalId: journal.journalId(), state: 'open' }, }); + } finally { + authority?.secret.fill(0); + old?.secret.fill(0); } } @@ -622,15 +644,31 @@ export async function executeCredentialWire( message: 'An explicit seat environment file is required.', }); } - const locations = lifecycleLocations(options); - const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents'); - if (dirname(options.seatEnv) !== seatEnvironmentRoot) { + if (options.actor !== identity) { return localLifecycleResult('wire', identity, options, { outcome: 'refused', - code: 'insecure-credential-destination', - message: 'Seat environment must be directly beneath the governed fleet agent directory.', + code: 'provider-identity-mismatch', + message: 'Wire authority must be the exact seat being projected.', }); } + const locations = lifecycleLocations(options); + const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents'); + const canonicalSeatEnvironment = join(seatEnvironmentRoot, `${identity}.env.generated`); + if (options.seatEnv !== canonicalSeatEnvironment) { + return localLifecycleResult('wire', identity, options, { + outcome: 'refused', + code: 'credential-binding-mismatch', + message: 'Seat environment path is not bound to the explicit identity.', + }); + } + if (options.authorityFd === undefined) { + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'A protected authority fd is required.', + }); + } + let context: Awaited>; try { ensureManagedDirectory(seatEnvironmentRoot, seatEnvironmentRoot); const parent = lstatSync(seatEnvironmentRoot); @@ -642,7 +680,7 @@ export async function executeCredentialWire( ) { throw new Error('seat environment directory is unsafe'); } - const context = await lifecycleContext(options); + context = await lifecycleContext(options); if (context.registry.resolve(options.estate, options.host) === undefined) { return localLifecycleResult('wire', identity, options, { outcome: 'refused', @@ -658,6 +696,7 @@ export async function executeCredentialWire( }); } let journal: CredentialAuditJournal | undefined; + let mutation: CredentialLifecycleResultDto['mutation'] = 'none'; try { journal = await CredentialAuditJournal.open(locations.stateRoot, { operation: 'wire', @@ -668,18 +707,46 @@ export async function executeCredentialWire( repo: null, }); await journal.recordIntent('wire-requested'); - let existing = ''; + const authority = await lifecycleAuthority(identity, options); + let providerIdentity: Awaited>; try { - const snapshot = readRegularFileSecure(options.seatEnv, { - root: dirname(options.seatEnv), - maxBytes: 1024 * 1024, + providerIdentity = await context.provider.readBasicIdentity(authority); + } finally { + authority.secret.fill(0); + } + if (providerIdentity.login !== identity) { + await journal.seal('refused', 'provider-identity-mismatch'); + return localLifecycleResult('wire', identity, options, { + outcome: 'refused', + code: 'provider-identity-mismatch', + message: 'Protected authority did not read back as the exact target seat.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } + await journal.recordProviderEvidence({ + endpoint: providerIdentity.endpoint, + contentType: providerIdentity.contentType, + decision: 'identity-verified', + }); + const snapshot = readRegularFileSecure(options.seatEnv, { + root: dirname(options.seatEnv), + maxBytes: 1024 * 1024, + }); + if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) { + throw new Error('seat environment ownership or mode is unsafe'); + } + const existing = snapshot.content.toString('utf8'); + const rosterBindings = existing + .split(/\r?\n/) + .filter((line): boolean => line.startsWith('MOSAIC_AGENT_NAME=')); + if (rosterBindings.length !== 1 || rosterBindings[0] !== `MOSAIC_AGENT_NAME=${identity}`) { + await journal.seal('refused', 'credential-binding-mismatch'); + return localLifecycleResult('wire', identity, options, { + outcome: 'refused', + code: 'credential-binding-mismatch', + message: 'Roster-derived seat projection did not bind the requested identity.', + audit: { journalId: journal.journalId(), state: 'sealed' }, }); - if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) { - throw new Error('seat environment ownership or mode is unsafe'); - } - existing = snapshot.content.toString('utf8'); - } catch (error: unknown) { - if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; } const lines = existing .split(/\r?\n/) @@ -697,22 +764,54 @@ export async function executeCredentialWire( const parentBefore = lstatSync(seatEnvironmentRoot); const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const handle = await open(temp, 'wx', 0o600); + let renamed = false; try { - await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8'); - await handle.sync(); + try { + await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await options.wireBeforeRename?.(); + const parentAfter = lstatSync(seatEnvironmentRoot); + if ( + parentAfter.dev !== parentBefore.dev || + parentAfter.ino !== parentBefore.ino || + parentAfter.uid !== process.getuid?.() || + (parentAfter.mode & 0o022) !== 0 + ) { + throw new Error('seat environment directory changed during mutation'); + } + const targetBeforeRename = readRegularFileSecure(options.seatEnv, { + root: dirname(options.seatEnv), + maxBytes: 1024 * 1024, + }); + if (targetBeforeRename.dev !== snapshot.dev || targetBeforeRename.ino !== snapshot.ino) { + throw new Error('seat environment changed during mutation'); + } + await rename(temp, options.seatEnv); + renamed = true; + mutation = 'applied'; + if (options.wireDirectorySync !== undefined) { + await options.wireDirectorySync(seatEnvironmentRoot); + } else { + const directory = await open( + seatEnvironmentRoot, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + const synced = await directory.stat(); + if (synced.dev !== parentBefore.dev || synced.ino !== parentBefore.ino) { + throw new Error('seat environment directory changed before durable sync'); + } + await directory.sync(); + } finally { + await directory.close(); + } + } } finally { - await handle.close(); + if (!renamed) await unlink(temp).catch((): void => undefined); } - const parentAfter = lstatSync(seatEnvironmentRoot); - if ( - parentAfter.dev !== parentBefore.dev || - parentAfter.ino !== parentBefore.ino || - parentAfter.uid !== process.getuid?.() || - (parentAfter.mode & 0o022) !== 0 - ) { - throw new Error('seat environment directory changed during mutation'); - } - await rename(temp, options.seatEnv); await journal.recordMutation('wire-applied'); await journal.seal('ok', 'wire-verified'); return localLifecycleResult('wire', identity, options, { @@ -730,20 +829,25 @@ export async function executeCredentialWire( message: 'Seat environment journal could not be opened durably.', }); } + const outcome = mutation === 'applied' ? 'indeterminate' : 'error'; + const reason = mutation === 'applied' ? 'wire-audit-incomplete' : 'wire-failed'; try { - await journal.seal('error', 'wire-failed'); + await journal.seal(outcome, reason); return localLifecycleResult('wire', identity, options, { - outcome: 'error', - mutation: 'unknown', - code: 'wire-failed', - message: 'Seat environment wiring failed.', + outcome, + mutation, + code: reason, + message: + mutation === 'applied' + ? 'Seat environment changed, but complete audit persistence was not established.' + : 'Seat environment wiring failed before mutation.', audit: { journalId: journal.journalId(), state: 'sealed' }, }); } catch (sealError: unknown) { return localLifecycleResult('wire', identity, options, { - outcome: 'error', - mutation: 'unknown', - code: sealError instanceof CredentialJournalError ? sealError.code : 'wire-failed', + outcome, + mutation, + code: sealError instanceof CredentialJournalError ? sealError.code : reason, message: 'Seat environment audit could not be sealed.', audit: { journalId: journal.journalId(), state: 'open' }, }); @@ -1035,8 +1139,9 @@ export function registerCredentialCommand(parent: Command): void { .description('Idempotently wire both explicit fleet identity axes into a seat environment') .requiredOption('--estate ', 'Explicit target estate') .requiredOption('--host ', 'Explicit provider host') - .requiredOption('--actor ', 'Explicit audit actor') - .requiredOption('--seat-env ', 'Explicit seat environment file') + .requiredOption('--actor ', 'Exact seat identity authorized to wire itself') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .requiredOption('--seat-env ', 'Exact roster-derived .env.generated file') .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index f9f039d0..f182fa6f 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -40,13 +40,17 @@ const SAFE_DECISIONS = new Set([ 'get-requested', 'validation-verified', 'team-member-present', + 'team-member-absent', 'team-repository-present', + 'team-repository-absent', 'team-repository-set-verified', 'organization-member-present', 'organization-member-absent', 'collaborator-grant-applied', 'team-member-applied', + 'team-member-rollback-applied', 'team-repository-applied', + 'team-repository-rollback-applied', 'transport-write-verified', 'token-mint-applied', 'token-binding-stored', diff --git a/packages/mosaic/src/credentials/gitea-provider.spec.ts b/packages/mosaic/src/credentials/gitea-provider.spec.ts index faa23cfc..225c3cd6 100644 --- a/packages/mosaic/src/credentials/gitea-provider.spec.ts +++ b/packages/mosaic/src/credentials/gitea-provider.spec.ts @@ -183,6 +183,8 @@ describe('Gitea credential provider transport', (): void => { }); it('reads team permission, member attachment, and repository attachment separately', async (): Promise => { + let memberRemoved = false; + let repositoryDetached = false; const adapter = new GiteaTeamGrantProviderAdapter( 'https://git.example.invalid', async (input: string | URL | Request, init?: RequestInit): Promise => { @@ -191,6 +193,11 @@ describe('Gitea credential provider transport', (): void => { return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]); } if (init?.method === 'PUT') return new Response(null, { status: 204 }); + if (init?.method === 'DELETE') { + if (url.includes('/members/')) memberRemoved = true; + if (url.includes('/repos/')) repositoryDetached = true; + return new Response(null, { status: 204 }); + } if (url.includes('/members/seat-name')) { return jsonResponse({ id: 21, login: 'seat-name' }); } @@ -214,6 +221,10 @@ describe('Gitea credential provider transport', (): void => { await expect( adapter.readTeamRepository(credential, team.id, 'owner/repo'), ).resolves.toMatchObject({ state: 'present' }); + await adapter.removeTeamMember(credential, team.id, 'seat-name'); + await adapter.detachTeamRepository(credential, team.id, 'owner/repo'); + expect(memberRemoved).toBe(true); + expect(repositoryDetached).toBe(true); expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' }); }); diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index 18738b5b..29a1310b 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -626,6 +626,31 @@ export class GiteaTeamGrantProviderAdapter ); } + async removeTeamMember( + authority: ResolvedCredential, + teamId: number, + identity: string, + ): Promise { + const response = await this.request( + `${this.origin}/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`, + { + method: 'DELETE', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + await boundedBody(response); + if (!response.ok) { + throw new CredentialProviderEvidenceError( + 'provider-unavailable', + 'team member rollback failed', + ); + } + } + async attachTeamRepository( authority: ResolvedCredential, teamId: number, @@ -635,6 +660,32 @@ export class GiteaTeamGrantProviderAdapter await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`); } + async detachTeamRepository( + authority: ResolvedCredential, + teamId: number, + repo: string, + ): Promise { + const { owner, name } = repoPath(repo); + const response = await this.request( + `${this.origin}/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`, + { + method: 'DELETE', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + await boundedBody(response); + if (!response.ok) { + throw new CredentialProviderEvidenceError( + 'provider-unavailable', + 'team repository rollback failed', + ); + } + } + private async putTeamPath(authority: ResolvedCredential, path: string): Promise { const response = await this.request(`${this.origin}${path}`, { method: 'PUT', diff --git a/packages/mosaic/src/credentials/team-grant.spec.ts b/packages/mosaic/src/credentials/team-grant.spec.ts index 7ad141b8..b5fefd7e 100644 --- a/packages/mosaic/src/credentials/team-grant.spec.ts +++ b/packages/mosaic/src/credentials/team-grant.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -73,6 +73,7 @@ function validation(): CredentialValidationDependencies { 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-')); + let repositoryReads = 0; const provider: GiteaTeamGrantProvider = { async readBasicIdentity() { return { @@ -91,14 +92,17 @@ describe('team repository grant', (): void => { }; }, async listTeamRepositories() { + repositoryReads += 1; return { - repositories: [], + repositories: repositoryReads === 1 ? [] : ['owner/repo'], endpoint: 'GET /api/v1/teams/7/repos', contentType: 'application/json', }; }, async addTeamMember(): Promise {}, + async removeTeamMember(): Promise {}, async attachTeamRepository(): Promise {}, + async detachTeamRepository(): Promise {}, async readTeamMember() { return { state: 'present', @@ -174,9 +178,15 @@ describe('team repository grant', (): void => { async addTeamMember(): Promise { mutated = true; }, + async removeTeamMember(): Promise { + mutated = true; + }, async attachTeamRepository(): Promise { mutated = true; }, + async detachTeamRepository(): Promise { + mutated = true; + }, async readTeamMember() { return { state: 'absent', @@ -220,4 +230,384 @@ describe('team repository grant', (): void => { expect(result.reason.code).toBe('team-scope-exceeds-request'); expect(mutated).toBe(false); }); + + it('journals absent team objects as absent rather than present', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); + let repositoryReads = 0; + const provider: GiteaTeamGrantProvider = { + async readBasicIdentity() { + 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 listTeamRepositories() { + repositoryReads += 1; + return { + repositories: repositoryReads === 1 ? [] : ['owner/repo'], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, + async addTeamMember(): Promise {}, + async removeTeamMember(): Promise {}, + async attachTeamRepository(): Promise {}, + async detachTeamRepository(): Promise {}, + async readTeamMember() { + return { + state: 'absent', + endpoint: 'GET /api/v1/teams/7/members/seat-name', + contentType: 'application/json', + }; + }, + async readTeamRepository() { + return { + state: 'absent', + 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 stateRoot = join(cleanup, 'state'); + 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, actor: 'provisioner' }, + ); + + expect(result.outcome).toBe('indeterminate'); + const [journalName] = await readdir(join(stateRoot, 'journals')); + const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8'); + expect(journal).toContain('team-member-absent'); + expect(journal).toContain('team-repository-absent'); + expect(journal).not.toContain('"decision":"team-member-present"'); + expect(journal).not.toContain('"decision":"team-repository-present"'); + }); + + it('fails closed and removes newly added membership when team scope changes during mutation', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); + let repositoryReads = 0; + let membershipReads = 0; + let removed = false; + let detached = false; + const provider: GiteaTeamGrantProvider = { + async readBasicIdentity() { + 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 listTeamRepositories() { + repositoryReads += 1; + return { + repositories: repositoryReads === 1 ? [] : ['owner/repo', 'owner/concurrent-attachment'], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, + async addTeamMember(): Promise {}, + async removeTeamMember(): Promise { + removed = true; + }, + async attachTeamRepository(): Promise {}, + async detachTeamRepository(): Promise { + detached = true; + }, + async readTeamMember() { + membershipReads += 1; + return { + state: membershipReads === 1 || removed ? 'absent' : 'present', + endpoint: 'GET /api/v1/teams/7/members/seat-name', + contentType: 'application/json', + }; + }, + async readTeamRepository() { + return { + state: detached ? 'absent' : '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 stateRoot = join(cleanup, 'state'); + 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, actor: 'provisioner' }, + ); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('team-scope-changed-during-grant'); + expect(result.evidence.teamRepositorySet?.repositories).toEqual([ + 'owner/repo', + 'owner/concurrent-attachment', + ]); + expect(removed).toBe(true); + expect(detached).toBe(true); + const [journalName] = await readdir(join(stateRoot, 'journals')); + const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8'); + expect(journal.indexOf('team-member-absent')).toBeLessThan( + journal.indexOf('team-member-applied'), + ); + expect(journal).toContain('team-repository-rollback-applied'); + expect(journal).toContain('team-repository-absent'); + }); + + it('compensates provider changes when repository attachment fails after applying', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); + let memberPresent = false; + let repositoryPresent = false; + let memberRemoved = false; + let repositoryDetached = false; + const provider: GiteaTeamGrantProvider = { + async readBasicIdentity() { + 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 listTeamRepositories() { + return { + repositories: repositoryPresent ? ['owner/repo'] : [], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, + async addTeamMember(): Promise { + memberPresent = true; + }, + async removeTeamMember(): Promise { + memberPresent = false; + memberRemoved = true; + }, + async attachTeamRepository(): Promise { + repositoryPresent = true; + throw new Error('provider response lost after attachment'); + }, + async detachTeamRepository(): Promise { + repositoryPresent = false; + repositoryDetached = true; + }, + async readTeamMember() { + return { + state: memberPresent ? 'present' : 'absent', + endpoint: 'GET /api/v1/teams/7/members/seat-name', + contentType: 'application/json', + }; + }, + async readTeamRepository() { + return { + state: repositoryPresent ? 'present' : 'absent', + 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('indeterminate'); + expect(memberPresent).toBe(false); + expect(repositoryPresent).toBe(false); + expect(memberRemoved).toBe(true); + expect(repositoryDetached).toBe(true); + }); + + it('refuses a second governed mutation while the same team lock is held', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); + let releaseFirst!: () => void; + const firstMayFinish = new Promise((resolve): void => { + releaseFirst = resolve; + }); + let markFirstEntered!: () => void; + const firstEntered = new Promise((resolve): void => { + markFirstEntered = resolve; + }); + let addCalls = 0; + let memberAdded = false; + let repositoryAttached = false; + const provider: GiteaTeamGrantProvider = { + async readBasicIdentity() { + 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 listTeamRepositories() { + return { + repositories: repositoryAttached ? ['owner/repo'] : [], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, + async addTeamMember(): Promise { + addCalls += 1; + markFirstEntered(); + await firstMayFinish; + memberAdded = true; + }, + async removeTeamMember(): Promise { + memberAdded = false; + }, + async attachTeamRepository(): Promise { + repositoryAttached = true; + }, + async detachTeamRepository(): Promise { + repositoryAttached = false; + }, + async readTeamMember() { + return { + state: memberAdded ? 'present' : 'absent', + endpoint: 'GET /api/v1/teams/7/members/seat-name', + contentType: 'application/json', + }; + }, + async readTeamRepository() { + return { + state: repositoryAttached ? 'present' : 'absent', + 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 request = { + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + permission: 'write' as const, + team: 'writers', + readOnlyControlIdentity: 'read-control', + }; + const options = { stateRoot: join(cleanup, 'state-one'), actor: 'provisioner' }; + const secondOptions = { stateRoot: join(cleanup, 'state-two'), actor: 'provisioner' }; + + const first = grantTeamRepositoryPermission( + request, + authority, + provider, + validation(), + options, + ); + await firstEntered; + const second = await grantTeamRepositoryPermission( + request, + authority, + provider, + validation(), + secondOptions, + ); + releaseFirst(); + const completedFirst = await first; + + expect(completedFirst.outcome).toBe('ok'); + expect(second.outcome).toBe('indeterminate'); + expect(second.reason.code).toBe('concurrent-mutation'); + expect(second.mutation).toBe('none'); + expect(addCalls).toBe(1); + }); }); diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts index d7ad4d39..d0edbd7b 100644 --- a/packages/mosaic/src/credentials/team-grant.ts +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -1,3 +1,8 @@ +import { spawnSync } from 'node:child_process'; +import { constants, lstatSync } from 'node:fs'; +import { open } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ensureManagedDirectory } from '../fleet/secure-file.js'; import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; import type { CredentialValidationDependencies, @@ -54,7 +59,9 @@ export interface GiteaTeamGrantProvider { teamId: number, ): Promise; addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise; + removeTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise; attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise; + detachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise; readTeamMember( authority: ResolvedCredential, teamId: number, @@ -75,6 +82,67 @@ export interface TeamGrantOptions { readonly actor: string; } +class TeamGrantLockError extends Error { + constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') { + super(code); + } +} + +async function acquireTeamGrantLock( + estate: string, + host: string, + teamId: number, +): Promise<() => Promise> { + const uid = process.getuid?.(); + if (uid === undefined) throw new TeamGrantLockError('mutation-lock-unavailable'); + const locksDirectory = `/tmp/mosaic-cred-team-locks-${uid.toString()}`; + ensureManagedDirectory(locksDirectory, locksDirectory); + const directory = lstatSync(locksDirectory); + if ( + !directory.isDirectory() || + directory.isSymbolicLink() || + directory.uid !== uid || + (directory.mode & 0o077) !== 0 + ) { + throw new TeamGrantLockError('mutation-lock-unavailable'); + } + const lockPath = join(locksDirectory, `${estate}--${host}--team-${teamId.toString()}.lock`); + let handle: Awaited>; + try { + handle = await open( + lockPath, + constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, + 0o600, + ); + } catch { + throw new TeamGrantLockError('mutation-lock-unavailable'); + } + try { + const file = await handle.stat(); + if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) { + throw new Error('team mutation lock file is unsafe'); + } + } catch { + await handle.close().catch((): void => undefined); + throw new TeamGrantLockError('mutation-lock-unavailable'); + } + // The child's fd 3 is a dup of the parent's open file description. Linux + // flock(2) associates the lock with that description, so it remains held + // after the helper exits until this process closes `handle` below. + const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], { + stdio: ['ignore', 'ignore', 'ignore', handle.fd], + }); + if (acquired.error !== undefined || acquired.status !== 0) { + await handle.close().catch((): void => undefined); + throw new TeamGrantLockError( + acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable', + ); + } + return async (): Promise => { + await handle.close(); + }; +} + export async function grantTeamRepositoryPermission( request: TeamGrantRequest, authority: ResolvedCredential, @@ -92,10 +160,17 @@ export async function grantTeamRepositoryPermission( }); await journal.recordIntent('provider-grant'); let mutation: 'none' | 'unknown' | 'applied' = 'none'; + let releaseTeamLock: (() => Promise) | undefined; + let rollbackTeam: TeamResolutionEvidence | undefined; + let membershipBeforeMutation: PresenceEvidence | undefined; + let repositoryAttachedBeforeMutation = false; + let memberMutationAttempted = false; + let repositoryMutationAttempted = false; try { const authorityIdentity = await provider.readBasicIdentity(authority); const organization = request.repo.split('/')[0] ?? ''; const team = await provider.resolveTeam(authority, organization, request.team); + rollbackTeam = team; if (authorityIdentity.login !== options.actor || team.permission !== request.permission) { await journal.seal('refused', 'provider-identity-mismatch'); return result( @@ -122,6 +197,25 @@ export async function grantTeamRepositoryPermission( contentType: team.contentType, decision: `permission-${team.permission}`, }); + try { + releaseTeamLock = await acquireTeamGrantLock(request.estate, request.host, team.id); + } catch (error: unknown) { + if (!(error instanceof TeamGrantLockError)) throw error; + await journal.seal('indeterminate', error.code); + return result( + request, + journal, + 'indeterminate', + 'none', + error.code, + null, + team, + null, + null, + null, + null, + ); + } const teamRepositorySet = await provider.listTeamRepositories(authority, team.id); await journal.recordProviderEvidence({ endpoint: teamRepositorySet.endpoint, @@ -144,14 +238,89 @@ export async function grantTeamRepositoryPermission( teamRepositorySet, ); } + const repositoryAttachedBefore = teamRepositorySet.repositories.includes(request.repo); + repositoryAttachedBeforeMutation = repositoryAttachedBefore; + const membershipBefore = await provider.readTeamMember(authority, team.id, request.identity); + membershipBeforeMutation = membershipBefore; + await journal.recordProviderEvidence({ + endpoint: membershipBefore.endpoint, + contentType: membershipBefore.contentType, + decision: membershipBefore.state === 'present' ? 'team-member-present' : 'team-member-absent', + }); mutation = 'unknown'; + memberMutationAttempted = true; await provider.addTeamMember(authority, team.id, request.identity); mutation = 'applied'; await journal.recordMutation('team-member-applied'); + repositoryMutationAttempted = true; await provider.attachTeamRepository(authority, team.id, request.repo); await journal.recordMutation('team-repository-applied'); - const teamMembership = await provider.readTeamMember(authority, team.id, request.identity); - const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo); + let teamMembership = await provider.readTeamMember(authority, team.id, request.identity); + let teamRepository = await provider.readTeamRepository(authority, team.id, request.repo); + const finalTeamRepositorySet = await provider.listTeamRepositories(authority, team.id); + await journal.recordProviderEvidence({ + endpoint: teamMembership.endpoint, + contentType: teamMembership.contentType, + decision: teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent', + }); + await journal.recordProviderEvidence({ + endpoint: teamRepository.endpoint, + contentType: teamRepository.contentType, + decision: + teamRepository.state === 'present' ? 'team-repository-present' : 'team-repository-absent', + }); + await journal.recordProviderEvidence({ + endpoint: finalTeamRepositorySet.endpoint, + contentType: finalTeamRepositorySet.contentType, + decision: 'team-repository-set-verified', + }); + const scopeRemainedExact = + finalTeamRepositorySet.repositories.length === 1 && + finalTeamRepositorySet.repositories[0] === request.repo; + if (!scopeRemainedExact) { + if (membershipBefore.state === 'absent') { + await provider.removeTeamMember(authority, team.id, request.identity); + await journal.recordMutation('team-member-rollback-applied'); + teamMembership = await provider.readTeamMember(authority, team.id, request.identity); + await journal.recordProviderEvidence({ + endpoint: teamMembership.endpoint, + contentType: teamMembership.contentType, + decision: + teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent', + }); + if (teamMembership.state !== 'absent') throw new Error('team member rollback disagreed'); + } + if (!repositoryAttachedBefore) { + await provider.detachTeamRepository(authority, team.id, request.repo); + await journal.recordMutation('team-repository-rollback-applied'); + teamRepository = await provider.readTeamRepository(authority, team.id, request.repo); + await journal.recordProviderEvidence({ + endpoint: teamRepository.endpoint, + contentType: teamRepository.contentType, + decision: + teamRepository.state === 'present' + ? 'team-repository-present' + : 'team-repository-absent', + }); + if (teamRepository.state !== 'absent') { + throw new Error('team repository rollback disagreed'); + } + } + await journal.seal('indeterminate', 'team-scope-changed-during-grant'); + return result( + request, + journal, + 'indeterminate', + 'applied', + 'team-scope-changed-during-grant', + null, + team, + teamMembership, + teamRepository, + null, + finalTeamRepositorySet, + ); + } const subject = await dependencies.resolver.resolve( request.identity, request.estate, @@ -198,16 +367,6 @@ export async function grantTeamRepositoryPermission( 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', @@ -223,13 +382,66 @@ export async function grantTeamRepositoryPermission( teamMembership, teamRepository, organizationMembership, - teamRepositorySet, + finalTeamRepositorySet, ); } catch (error: unknown) { - if (error instanceof CredentialJournalError) { - throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId()); + let compensationError: unknown; + try { + if ( + rollbackTeam !== undefined && + membershipBeforeMutation?.state === 'absent' && + memberMutationAttempted + ) { + let current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity); + if (current.state === 'present') { + await provider.removeTeamMember(authority, rollbackTeam.id, request.identity); + await journal.recordMutation('team-member-rollback-applied'); + current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity); + await journal.recordProviderEvidence({ + endpoint: current.endpoint, + contentType: current.contentType, + decision: current.state === 'present' ? 'team-member-present' : 'team-member-absent', + }); + if (current.state !== 'absent') throw new Error('team member rollback disagreed'); + } + } + if ( + rollbackTeam !== undefined && + !repositoryAttachedBeforeMutation && + repositoryMutationAttempted + ) { + let current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo); + if (current.state === 'present') { + await provider.detachTeamRepository(authority, rollbackTeam.id, request.repo); + await journal.recordMutation('team-repository-rollback-applied'); + current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo); + await journal.recordProviderEvidence({ + endpoint: current.endpoint, + contentType: current.contentType, + decision: + current.state === 'present' ? 'team-repository-present' : 'team-repository-absent', + }); + if (current.state !== 'absent') throw new Error('team repository rollback disagreed'); + } + } + } catch (rollbackError: unknown) { + compensationError = rollbackError; } - const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown'; + const auditError = + compensationError instanceof CredentialJournalError + ? compensationError + : error instanceof CredentialJournalError + ? error + : undefined; + if (auditError !== undefined) { + throw new CredentialGrantExecutionError(auditError.code, mutation, journal.journalId()); + } + const reasonCode = + compensationError !== undefined + ? 'rollback-incomplete' + : mutation === 'applied' + ? 'readback-missing' + : 'mutation-state-unknown'; try { await journal.seal('indeterminate', reasonCode); } catch (journalError: unknown) { @@ -251,6 +463,10 @@ export async function grantTeamRepositoryPermission( null, null, ); + } finally { + // The kernel also releases this advisory lock on process exit. A close + // cleanup fault must not contradict an already sealed provider verdict. + await releaseTeamLock?.().catch((): void => undefined); } } -- 2.54.0