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
|
## Architecture
|
||||||
|
|
||||||
### High-Level System Diagram
|
### 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 { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerInteractionCommand } from './commands/interaction.js';
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
|
import { registerCredentialCommand } from './commands/cred.js';
|
||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
@@ -371,6 +372,10 @@ registerInteractionCommand(program);
|
|||||||
|
|
||||||
registerFleetCommand(program);
|
registerFleetCommand(program);
|
||||||
|
|
||||||
|
// ─── credential governance ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerCredentialCommand(program);
|
||||||
|
|
||||||
// ─── config ────────────────────────────────────────────────────────────
|
// ─── config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerConfigCommand(program);
|
registerConfigCommand(program);
|
||||||
|
|||||||
@@ -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