feat(mosaic): add governed credential validation and grants

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent 85d2108e4e
commit ce0a8ad7d1
27 changed files with 4303 additions and 0 deletions
+53
View File
@@ -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 ## Architecture
### High-Level System Diagram ### High-Level System Diagram
+196
View File
@@ -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.
- `<identity>` 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 <identity> \
--estate <estate> \
--host <host> \
--repo <owner/repo> \
--permission <read|write|admin> \
[--via <collaborator|team>] \
[--team <team>] \
[--read-only-control <identity>] \
[--json]
mosaic cred validate <identity> \
--estate <estate> \
--host <host> \
[--repo <owner/repo>] \
[--require <read|write|admin>] \
[--read-only-control <identity>] \
[--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 `<identity>`;
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.
+79
View File
@@ -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`.
+5
View File
@@ -14,6 +14,7 @@ import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js'; import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js'; import { registerConfigCommand } from './commands/config.js';
import { registerCredentialCommand } from './commands/cred.js';
import { registerFleetCommand } from './commands/fleet.js'; import { registerFleetCommand } from './commands/fleet.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js'; import { registerUninstallCommand } from './commands/uninstall.js';
@@ -371,6 +372,10 @@ registerInteractionCommand(program);
registerFleetCommand(program); registerFleetCommand(program);
// ─── credential governance ─────────────────────────────────────────────
registerCredentialCommand(program);
// ─── config ──────────────────────────────────────────────────────────── // ─── config ────────────────────────────────────────────────────────────
registerConfigCommand(program); registerConfigCommand(program);
+348
View File
@@ -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<CredentialValidationResultDto> {
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<CredentialGrantResultDto | TeamGrantResult> {
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 <path>', 'Mosaic configuration root')
.configureHelp({ sortSubcommands: true })
.action((): void => {
cred.outputHelp();
});
cred
.command('grant <identity>')
.description('Grant repository permission and accept only provider object read-back')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--repo <owner/repo>', 'Target repository')
.requiredOption('--permission <permission>', 'Requested read, write, or admin permission')
.requiredOption('--actor <identity>', 'Explicit delegated authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected credential fd number')
.option('--via <mode>', 'Direct collaborator or team grant', 'collaborator')
.option('--team <team>', 'Exact team name for team grant')
.option('--read-only-control <identity>', 'Known read-only negative-control identity')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed phase-1 token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialGrantCommandOptions): Promise<void> => {
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 <identity>')
.description('Read back identity, permission layers, and side-effect-free write differential')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--repo <owner/repo>', 'Target repository')
.option('--require <permission>', 'Required effective permission', 'write')
.option('--read-only-control <identity>', 'Known read-only negative-control identity')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed phase-1 token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--actor <identity>', 'Explicit audit actor (defaults to subject)')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => {
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;
});
}
@@ -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;
}
@@ -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<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-journal-'));
return join(cleanup, 'state');
}
afterEach(async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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"');
});
});
@@ -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<string>([
'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<void> {
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<CredentialAuditJournal> {
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<void> {
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<void> {
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<void> {
assertEvidence(evidence);
await this.append({ phase: 'provider-evidence', at: this.now(), evidence });
}
async recordCorrection(correction: CredentialJournalCorrectionDto): Promise<void> {
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<void> {
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<string> {
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<void> {
if (this.closed) return;
await this.handle.close();
this.closed = true;
}
}
export async function listCredentialJournals(
stateRoot: string,
): Promise<readonly CredentialJournalSummaryDto[]> {
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),
};
});
}
@@ -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<ResolvedCredential | undefined>;
}
export interface GiteaCredentialProvider {
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
readRepositoryPermission(
resolved: ResolvedCredential,
repo: string,
): Promise<RepositoryPermissionEvidenceDto>;
probeReceivePack(
resolved: ResolvedCredential | undefined,
repo: string,
): Promise<ReceivePackEvidenceDto>;
}
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;
}
@@ -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;
}
@@ -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<CredentialAuditJournal> {
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<CredentialValidationResultDto> {
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<CredentialValidationResultDto> {
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<CredentialValidationResultDto> {
const journal = await openValidationJournal(request, options);
const validation = await evaluateGiteaWriteValidation(request, dependencies);
return recordAndSealValidation(journal, validation);
}
@@ -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<void> => {
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<void> => {
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<void> => {
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();
}
});
});
@@ -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<ResolvedCredential> {
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),
});
}
@@ -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[];
}
@@ -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://[email protected]',
'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/);
});
});
@@ -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<string, CredentialEstateConfigDto>;
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<string>();
const hostNames = new Set<string>();
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);
}
@@ -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<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-store-'));
const root = join(cleanup, 'tokens');
await mkdir(root, { mode: 0o700 });
return root;
}
function registry(): ReturnType<typeof parseCredentialEstateRegistry> {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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();
});
});
@@ -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<ResolvedCredential | undefined> {
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),
});
}
}
@@ -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<void> => {
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<Response> => {
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<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
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<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
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<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
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<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => 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<void> => {
let calls = 0;
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => {
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<void> => {
let call = 0;
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => {
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<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
new Response('<html>not an API object</html>', {
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<void> => {
const adapter = new GiteaTeamGrantProviderAdapter(
'https://git.example.invalid',
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
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<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => {
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');
});
});
@@ -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<Response>;
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<Uint8Array> {
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<unknown> {
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<Response> {
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<never> {
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<ProviderIdentityEvidenceDto> {
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<RepositoryPermissionEvidenceDto> {
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<ReceivePackEvidenceDto> {
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<void> {
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<CollaboratorPermissionEvidenceDto> {
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<OrganizationMembershipEvidenceDto> {
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<TeamResolutionEvidence> {
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<void> {
await this.putTeamPath(
authority,
`/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
);
}
async attachTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<void> {
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<void> {
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<PresenceEvidence> {
return this.readPresence(
authority,
`GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
);
}
async readTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<PresenceEvidence> {
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<PresenceEvidence> {
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) };
}
}
@@ -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;
}
@@ -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<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-grant-'));
return join(cleanup, 'state');
}
afterEach(async (): Promise<void> => {
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<ResolvedCredential | undefined> {
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<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async grantCollaborator(): Promise<void> {
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<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async grantCollaborator(): Promise<void> {},
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');
});
});
+163
View File
@@ -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<void>;
readCollaboratorPermission(
authority: ResolvedCredential,
identity: string,
repo: string,
): Promise<CollaboratorPermissionEvidenceDto>;
readOrganizationMembership(
subject: ResolvedCredential,
organization: string,
): Promise<OrganizationMembershipEvidenceDto>;
}
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<CredentialGrantResultDto> {
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' },
};
}
@@ -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<void> => {
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<void> => {
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<void> {},
async attachTeamRepository(): Promise<void> {},
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');
});
});
@@ -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<TeamResolutionEvidence>;
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
readTeamMember(
authority: ResolvedCredential,
teamId: number,
identity: string,
): Promise<PresenceEvidence>;
readTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<PresenceEvidence>;
readOrganizationMembership(
subject: ResolvedCredential,
organization: string,
): Promise<OrganizationMembershipEvidenceDto>;
}
export interface TeamGrantOptions {
readonly stateRoot: string;
readonly actor: string;
}
export async function grantTeamRepositoryPermission(
request: TeamGrantRequest,
authority: ResolvedCredential,
provider: GiteaTeamGrantProvider,
dependencies: CredentialValidationDependencies,
options: TeamGrantOptions,
): Promise<TeamGrantResult> {
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<ReturnType<typeof evaluateGiteaWriteValidation>> | 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' },
};
}
@@ -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<ResolvedCredential | undefined>;
}
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<ResolvedCredential | undefined> = [];
const resolver: CredentialResolver = {
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
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<ProviderIdentityEvidence> {
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<RepositoryPermissionEvidence> {
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<ReceivePackEvidence> {
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<ReturnType<typeof evaluateGiteaWriteValidation>>;
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<void> => {
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<void> => {
const observed = fixture({ subjectPermission: 'read' });
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
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<void> => {
const observed = fixture({ subjectPermission: 'write' });
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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');
});
});
+477
View File
@@ -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<Record<CredentialOutcome, 0 | 10 | 20 | 30>> = {
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<ResolvedCredential | undefined> {
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<CredentialValidationResultDto> {
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<CredentialValidationResultDto> {
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<CredentialValidationResultDto> {
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<CredentialValidationResultDto> {
try {
return await evaluateGiteaWriteValidationUnsafe(request, dependencies);
} catch (error: unknown) {
if (error instanceof CredentialProviderEvidenceError) {
return providerEvidenceFailure(request, error);
}
throw error;
}
}