feat(mosaic): add governed credential validation and grants
This commit is contained in:
+53
@@ -437,6 +437,59 @@ Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete
|
||||
|
||||
---
|
||||
|
||||
## Governed fleet credential lifecycle (`mosaic cred`, #1045)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Fleet credentials are issued, wired, resolved, granted, validated, rotated, and revoked through unrelated scripts and manual provider actions. The split has produced silent fallback to a human/shared principal, missing runtime identity, cross-estate login resolution, incomplete permission checks, and non-auditable grants. The objective is one mechanical, durable, systemic `mosaic cred` path that decides both what a fleet seat may do and which provider identity it acts as.
|
||||
|
||||
### Scope
|
||||
|
||||
Phase 1 governs the existing per-identity Gitea token store and Tea login registration. VaultWarden is explicitly out for the agent tier and is not a backend option in this workstream. Certificate-backed identity and short-lived broker-issued credentials remain later phases behind the same caller contract.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
|
||||
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
|
||||
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither, and SHALL read the provider `/user` object back through each path. A wrong-host or absent Tea login SHALL never fall back to a host default.
|
||||
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
|
||||
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
|
||||
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
|
||||
7. `CRED-REQ-07`: Permission grants SHALL be accepted only after provider read-back of the named direct collaborator permission or, for team grants, organization membership, team membership, team-repository attachment, and subject effective permission.
|
||||
8. `CRED-REQ-08`: `validate --repo` SHALL compute a side-effect-free write differential by result. One immutable credential resolution SHALL bind the declared subject's provider identity read-back, repository permission, and authenticated Git receive-pack advertisement. A distinct provider-confirmed read-only principal and an unauthenticated caller SHALL both be refused receive-pack in the same evaluation. Principal/handle disagreement SHALL be indeterminate, never refusal or success. The check SHALL create no ref or artifact and SHALL state that it does not prove a particular update will pass branch protection, hooks, races, or content policy.
|
||||
9. `CRED-REQ-09`: All provider HTTP calls SHALL share one transport implementation for URL/host binding, TLS, User-Agent, content-type, JSON-shape validation, redaction, and bounded responses. A 2xx status alone SHALL never establish identity, scope, permission, grant, or revocation.
|
||||
10. `CRED-REQ-10`: Operations SHALL return stable machine outcomes `ok`, `refused`, `error`, or `indeterminate`. Policy refusal, local operational failure, and incomplete/inconsistent evidence SHALL remain distinguishable. `provider-unavailable`, `identity-not-measured`, `identity-not-visible`, `identity-not-found`, and `credential-rejected` SHALL remain distinct diagnoses. Validation SHALL report capability from an in-scope probe separately from identity measurement. `/user` 401 is `credential-rejected`/refused; `/user` 403/404 plus successful in-scope capability is `identity-not-measured`, never a dead credential. A returned login mismatch is a binding refusal. No implemented operation may emit `identity-not-found`; that diagnosis requires a separately approved visibility-authorized inventory capability. Security callers SHALL fail closed on every outcome except `ok` without relabelling indeterminate evidence as a denial.
|
||||
11. `CRED-REQ-11`: No command SHALL print a token, password, authorization header, fingerprint, partial secret, or secret-bearing provider body, including error paths. Secrets SHALL not appear in process argv. Phase-1 file storage SHALL remain private, symlink-safe, regular-file-only, test-overridable, and compatible with existing managed token consumers.
|
||||
12. `CRED-REQ-12`: Every issue, provision, grant, rotate, revoke, and credential access SHALL be journaled with actor, subject, estate, host, repo/scope, operation, time, and non-secret provider evidence. The durable journal SHALL be opened and fsynced before the first mutation, append each mutation/read-back, and seal only after acceptance. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work.
|
||||
13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the authoritative fleet environment source/projection so both identity axes survive restart. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names.
|
||||
14. `CRED-REQ-14`: Rotate SHALL verify the new credential/provider identity before retiring the old credential. Revoke SHALL read back provider revocation/denial and preserve an auditable recovery record. A local file deletion or successful HTTP status is not revocation evidence.
|
||||
15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback.
|
||||
16. `CRED-REQ-16`: Provider claims SHALL record the estate, instance, endpoint, asserted content type, and decision-relevant object fields. Append-only provider status history SHALL be reduced to latest-per-context where current state is required.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-CRED-01`: Red-first tests prove unset identity, missing token, wrong estate, wrong host, wrong Tea login, and out-of-estate identity produce the same structured refusal class/reason on git and API resolution, with no shared credential read and no provider mutation.
|
||||
2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes register atomically, exact token scopes are read back from the provider token object, and rollback removes partial local registration.
|
||||
3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope, repo grant, org membership, and team membership cases cannot return `ok`.
|
||||
4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, provider outage, or unavailable exact scope returns `indeterminate`, never success or policy refusal.
|
||||
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back.
|
||||
6. `AC-CRED-06`: Adversarial output/argv tests seed distinct secret values through success, refusal, provider-error, parser-error, rollback, rotate, and revoke paths and find zero secret/partial/fingerprint occurrences in stdout, stderr, logs, audit, and child argv.
|
||||
7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, conflicting concurrent mutation, and production-store leakage into fixture tests. Existing canonical per-seat token consumers continue through the governed adapter.
|
||||
8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the authoritative generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched.
|
||||
9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes.
|
||||
10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing.
|
||||
11. `AC-CRED-11`: Baseline typecheck/lint/format/tests, focused auth/permission abuse cases, independent code review, independent security review, and terminal-green HOMELAB Woodpecker CI pass on the exact reviewed head.
|
||||
12. `AC-CRED-12`: Interim delivery to `next` is reported only as **believed-fixed, pending validation AND pending promotion to `main`**. Issues stay open until #1037 promotes the work and constitutional completion is independently verified.
|
||||
|
||||
### Constraints and dependencies
|
||||
|
||||
- C1 install-state-machine work merges first. This lane then re-takes base/head-bound measurements without redesigning or reworking code.
|
||||
- MB-BRAIN-01 (#1051) consumes the grant/validate contract and may proceed against the published interface before implementation merge.
|
||||
- The branch-model compatibility question for `next` remains escalated. No `done` claim, issue closure, or self-initiated promotion is permitted at the `next` checkpoint.
|
||||
- `ASSUMPTION:` Phase-1 Gitea support is the only provider implementation in this slice; provider-neutral types preserve later adapters without pretending unimplemented providers are supported.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level System Diagram
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -14,6 +14,7 @@ import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerCredentialCommand } from './commands/cred.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
@@ -371,6 +372,10 @@ registerInteractionCommand(program);
|
||||
|
||||
registerFleetCommand(program);
|
||||
|
||||
// ─── credential governance ─────────────────────────────────────────────
|
||||
|
||||
registerCredentialCommand(program);
|
||||
|
||||
// ─── config ────────────────────────────────────────────────────────────
|
||||
|
||||
registerConfigCommand(program);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user