Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbff4ffa75 | ||
|
|
7056ff6973 | ||
|
|
12958610cb | ||
|
|
593b5a1f45 | ||
|
|
f4efc311ab | ||
|
|
b3a6959e46 | ||
|
|
b3d84662f9 | ||
|
|
9512adcec2 | ||
|
|
46e65d9ad4 | ||
|
|
e46e114d3b | ||
|
|
26203bd92c | ||
|
|
d18e49e8f4 | ||
|
|
791c57157a | ||
|
|
fa301afb8c | ||
|
|
d5ed9cfdf1 | ||
|
|
ce0a8ad7d1 | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 |
+5
-5
@@ -451,7 +451,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
|
||||
|
||||
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
|
||||
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
|
||||
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
|
||||
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Rollback SHALL read provider, token-store, and Tea state back and may report complete only when the pre-operation state is established exactly; failed or unverified cleanup is `indeterminate`/`rollback-incomplete`. Provision, rotate, and revoke SHALL serialize the complete provider/token-store/Tea transaction between cooperating `mosaic cred` processes under one fixed estate/host/identity advisory lock that caller-selected state roots cannot bypass. That same-UID-replaceable filesystem lock and the file-store generation preconditions provide optimistic concurrency only for cooperating `mosaic cred` mutators; neither is an authorization boundary nor atomic CAS against a hostile same-UID filesystem writer. Provider authority is the authorization boundary. Hostile same-UID direct filesystem mutation is explicitly out of scope for this phase and deferred. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
|
||||
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
|
||||
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
|
||||
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
|
||||
@@ -460,7 +460,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
|
||||
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.
|
||||
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. Credential access SHALL durably record issuance start immediately before protected-fd disclosure; any partial write or post-write audit failure SHALL remain visibly `indeterminate` with mutation `unknown` or `applied`, never be relabelled as a pre-disclosure destination error. A journal seal SHALL remain staged and recovery-visible until its final rename and directory durability succeed; a final seal-commit fault SHALL revert to open classification and cannot leave an acceptance-bearing sealed-success object. 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 exact roster-derived `<identity>.env.generated` fleet projection so both identity axes survive restart. It SHALL authenticate the same explicit seat through a protected delegated credential channel and provider identity read-back before mutation, refuse actor/identity/path/roster disagreements, and never authorize from the shared Unix account. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names.
|
||||
14. `CRED-REQ-14`: Rotate SHALL verify the new credential/provider identity before retiring the old credential. Revoke SHALL read back provider revocation/denial and preserve an auditable recovery record. A local file deletion or successful HTTP status is not revocation evidence.
|
||||
15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback.
|
||||
@@ -469,12 +469,12 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
|
||||
### 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.
|
||||
2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes are established together or every completed provider/token-store/Tea change is compensated and read back to the exact pre-operation state, and exact token scopes are read back from the provider token object. Injected Tea cleanup failure returns `indeterminate`/`rollback-incomplete` and cannot claim mutation `none`; no cross-system atomic commit is claimed. A second cooperating same-identity transaction is refused across caller-selected state roots. A code-level security-model assertion pins that advisory flock as cooperative serialization, provider authority as the authorization boundary, generation preconditions as optimistic protection for cooperating mutators rather than atomic CAS, and hostile same-UID filesystem mutation as explicitly deferred.
|
||||
3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope and repo grant cases cannot return `ok`; organization/team membership and team-repository attachment are additionally acceptance-bearing for team grants. A direct collaborator grant reports organization membership but does not require it, because direct collaborator permission and organization membership are intentionally independent provider layers.
|
||||
4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, or provider outage returns `indeterminate`, never success or policy refusal. Runtime exact scope is reported independently as `not-measured` when the current seat credential is not authorized to read its provider token object; NOT-MEASURED is neither pass nor failure and does not erase confirmed repository capability. Exact scope is acceptance-bearing at provision/rotate time, where delegated mint authority can read the token object.
|
||||
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back.
|
||||
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. Protected credential output fault injection covers partial write, post-write append, and seal failure and preserves possibly-issued/applied truth in both DTO and durable journal.
|
||||
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.
|
||||
7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, stale generations from cooperating concurrent mutators, and production-store leakage into fixture tests. They do not claim atomic exclusion against hostile same-UID direct filesystem mutation. 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 exact roster-derived generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. An unauthenticated caller, a caller authenticated as another seat, a caller-selected filename, or a file whose roster identity differs is refused before mutation.
|
||||
9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes.
|
||||
10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing.
|
||||
|
||||
@@ -171,7 +171,7 @@ Stable v1 codes:
|
||||
|
||||
- refusal: `identity-required`, `estate-required`, `estate-host-mismatch`, `cross-estate-resolution`, `no-token-for-identity`, `tea-login-missing`, `tea-login-host-mismatch`, `provider-identity-mismatch`, `credential-rejected`, `permission-denied`, `organization-membership-required`, `team-membership-required`
|
||||
- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant`
|
||||
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
|
||||
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `mutation-lock-release-failed`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
|
||||
|
||||
`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds.
|
||||
|
||||
@@ -201,4 +201,4 @@ No ref is updated and no repository artifact is created. This proves that the de
|
||||
|
||||
## Grant read-back
|
||||
|
||||
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant serializes governed mutations per provider team and enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
|
||||
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 uses a same-UID-replaceable advisory lock to serialize cooperating `mosaic cred` mutations per provider team; this is optimistic coordination, not an authorization boundary or atomic CAS against direct filesystem mutation. Provider authority remains the authorization boundary. While holding that cooperative lock, the grant enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; this is explicit all-or-verified-compensation behavior, not a cross-system atomic commit, and the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# #1019 — Zero-timeout queue-guard harness race
|
||||
|
||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||
2. Every case that intends status classification positively proves provider observation.
|
||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||
|
||||
## Review remediation — semantic timeout vs. liveness bound
|
||||
|
||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||
|
||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||
|
||||
Remediation:
|
||||
|
||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||
|
||||
Post-review evidence:
|
||||
|
||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||
|
||||
## 60% context hold
|
||||
|
||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Last updated: 2026-08-05
|
||||
|
||||
## Successor remediation (`be-coder-07`, PR #1059 review id 89)
|
||||
|
||||
Objective: close the four exact-head `rev-974` blockers without weakening any assertion, repair canonical-image test portability, and refresh the narrowed cooperative-concurrency claims and PR metadata. The provider-fetched starting head is `12958610cbafaa54a3db95327a7c3453d9111669` at merge-base `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`.
|
||||
|
||||
Plan:
|
||||
|
||||
1. Preserve the journal-lock release assertion while replacing Alpine-invalid `/usr/bin/true` with canonical `/bin/true`.
|
||||
2. Replace cross-system atomicity claims with exact all-or-verified-compensation semantics; retain atomic wording only for explicitly scoped single-file rename/replacement primitives and qualify team locking as cooperative advisory serialization.
|
||||
3. Add red-first controls for complete journal writes under one-byte progress, rejection of zero/invalid progress, Tea generation equivalence under recursive key reorder, and durable indeterminate classification when team-lock release cannot be verified.
|
||||
4. Implement bounded journal write-all, recursive canonical generation serialization, and release-before-seal team-lock cleanup classification; then run focused and baseline gates plus code/security advisories.
|
||||
5. Commit with explicit `be-coder-07` identity, rebase rather than merge onto current `origin/main`, prove stable patch identity, run the required queue/direct-CI guards, and make one force-with-lease push pinned to the measured starting head.
|
||||
|
||||
Budget: hard context ceiling is 60%. Reuse predecessor evidence and avoid re-deriving unrelated 41-file history. Non-blocking secret zeroization is included only after all blockers are green. Stop and write a seam/report before the ceiling.
|
||||
|
||||
## Objective
|
||||
|
||||
Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. The trunk-only ruling superseded the original `next` checkpoint: the branch is rebased onto `origin/main` and its PR target is `main`. Linked issues remain **believed-fixed, pending jarvis validation** after merge.
|
||||
@@ -53,6 +67,9 @@ No explicit token cap supplied. Working cap: keep implementation in one package
|
||||
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
|
||||
- [x] Implementation: validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, reverse registry, and fleet fail-closed resolver paths.
|
||||
- [x] Review hardening: rotation returns visible open journals; team evidence records absent objects accurately; team scope is checked before/after under a host-qualified OS advisory lock with verified compensation; `wire` authenticates the exact seat/path/roster binding and preserves post-rename mutation semantics.
|
||||
- [x] Author advisory remediation: provisioning rollback verifies provider/token-store/Tea state and cannot suppress Tea cleanup failure; Tea put/remove always unlink secret-bearing temporary files; unrelated hosts exit before Mosaic identity grammar; `cred get` persists issuance start before disclosure, writes through a short-write-safe loop, and preserves partial/applied indeterminate semantics under write/append/seal faults; provision/rotate/revoke share a fixed cross-state-root lifecycle lock for cooperating processes only (provider authority is the authorization boundary; generation preconditions are optimistic for cooperating mutators; hostile same-UID filesystem mutation is explicitly deferred); Tea pre-state is snapshotted/restored independently; final journal seal faults revert to open classification.
|
||||
- [ ] Legacy-token estate binding is explicitly deferred: the legacy format contains no estate and the installed shell path has no distributed strict registry. Enabling refusal before registry distribution plus token-envelope migration would strand active legacy-only seats; governed envelopes already bind estate exactly.
|
||||
- [ ] Hostile same-UID direct filesystem mutation is explicitly deferred to #1065. The finding is copied verbatim there; PR #1059 links it. Phase 1 claims only optimistic generation preconditions and advisory locking for cooperating mutators, with provider authority as the authorization boundary.
|
||||
- [ ] Independent code/security approvals on the final exact head (Codex advisory iterations are not independent approval).
|
||||
- [ ] Final exact-head CI and provider evidence.
|
||||
|
||||
@@ -66,7 +83,7 @@ Red-first evidence:
|
||||
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
|
||||
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
|
||||
|
||||
Current focused evidence: 77/77 across 11 credential/command suites; package lint, typecheck, formatting, and build are green. Full package Vitest reached 1,578 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled.
|
||||
Pre-remediation focused evidence: 80/80 across 11 credential/command suites; package lint, typecheck, formatting, and build were green. The advisory remediation adds thirty-five focused regression cases for rollback cleanup, Tea temporary-file cleanup, unsupported-host passthrough, and credential issuance partial-write/post-write-audit truthfulness; focused reruns are green (latest credential/command set: 109/109). Final uncommitted author advisories report code `approve` with no findings and security `none` with no findings; they remain advisory rather than independent approval. Full package Vitest reached 1,614 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and use atomic rename only for each explicitly scoped single-file replacement primitive; cross-system lifecycle completion uses verified compensation rather than an atomic-commit claim; grant mutation/read-back state is journaled.
|
||||
|
||||
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
|
||||
|
||||
@@ -74,9 +91,20 @@ Live validation v1.4 (subject credential's own `/user`, no admin): population 13
|
||||
|
||||
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.
|
||||
|
||||
## Successor review-remediation evidence
|
||||
|
||||
- Canonical-image CI portability: `/usr/bin/true` was changed only to `/bin/true`; the second-process `flock` acquisition assertion remains `status === 0` and passes in the focused lifecycle suite.
|
||||
- Required red phase: five exact finding controls failed before implementation (journal one-byte/zero/oversized progress, recursively reordered Tea metadata, and lock-release failure). Review remediation added a sixth red control proving final-seal failure could compensate a later cooperating team mutation after lock release; a prototype-named Tea metadata control also failed before canonical object construction was hardened.
|
||||
- Focused final: 120/120 passed across all 11 credential and credential-command files.
|
||||
- Package lint and typecheck passed; dependency-aware workspace build passed 13/13 packages; repository format check and `git diff --check` passed; both git credential resolver shell regressions passed.
|
||||
- Full local package Vitest: 1,624 passed / 3 failed. The failures are the same installed-version update banner emitted to stderr by three `cli-smoke.spec.ts` cases; prior canonical pipeline #2223 ran all 22 CLI-smoke cases green. Exact-head canonical CI remains required after push.
|
||||
- First uncommitted advisories found a real post-release compensation race. Its red control observed one removal after lock release; the fix forbids compensation after successful release, and the control then passed without weakening an assertion.
|
||||
- Final uncommitted advisories: code `approve` with no findings; security risk `none` with no findings. Codex could not execute tests in its read-only sandbox (EROFS), so these verdicts rely on review only; the writable author runs above are the test evidence and independent reviewers remain required.
|
||||
|
||||
## Risks/blockers
|
||||
|
||||
- Hostile same-UID direct filesystem mutation is outside phase-1 and requires a transactional service, broker/distinct identity, or equivalent non-bypassable primitive; lifecycle flock and generation preconditions cover cooperating `mosaic cred` mutators only. Track the explicit security deferral linked from PR #1059.
|
||||
- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts.
|
||||
- Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope.
|
||||
- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes.
|
||||
- C1 PR #1054 must first be rebuilt from only its four commits on `main`; the retargeted head `8b067839` carries 13 unrelated `next` commits and is not merge-eligible. MC-CRED remains sequenced after the clean C1 merge.
|
||||
- C1 PR #1054 remains sequencing-prior and requires its own exact-head gates. MC-CRED PR #1059 must freeze only after a new advisory review, independent review, and exact-head CI.
|
||||
|
||||
@@ -42,7 +42,7 @@ resolution_path=unresolved
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) idpfx="";;
|
||||
*) exit 0;;
|
||||
esac
|
||||
ident="$MOSAIC_GIT_IDENTITY"
|
||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
|
||||
@@ -9,10 +9,51 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||
WATCHDOG_TIMEOUT_SEC=5
|
||||
WATCHDOG_EXIT=90
|
||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||
|
||||
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit(2)
|
||||
|
||||
timeout_seconds = float(sys.argv[1])
|
||||
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||
try:
|
||||
return_code = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
print(
|
||||
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||
"before completing its intended path",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(90)
|
||||
|
||||
if return_code < 0:
|
||||
raise SystemExit(128 - return_code)
|
||||
raise SystemExit(return_code)
|
||||
PY
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
@@ -33,6 +74,9 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||
while :; do :; done
|
||||
fi
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
@@ -44,6 +88,7 @@ case "$url" in
|
||||
fi
|
||||
;;
|
||||
*/status)
|
||||
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||
@@ -63,7 +108,31 @@ case "$url" in
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
cat > "$STUB_DIR/date" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||
echo "unexpected date invocation: $*" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1002\n'
|
||||
else
|
||||
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1000\n'
|
||||
fi
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/sleep" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||
|
||||
run_guard() {
|
||||
local status_mode="$1"
|
||||
@@ -83,13 +152,46 @@ run_guard() {
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||
fi
|
||||
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
||||
# Provider observation is the synchronization event. The one-second
|
||||
# timeout is subject semantics under virtual time, never a wall wait.
|
||||
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||
# the subject's isolated process group. Neither operation can resolve
|
||||
# to the virtual date/sleep stubs at the front of PATH.
|
||||
local subject_rc
|
||||
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||
subject_rc=0
|
||||
else
|
||||
subject_rc=$?
|
||||
fi
|
||||
return "$subject_rc"
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_provider_observed() {
|
||||
local name="$1" require_expiration="${2:-0}"
|
||||
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL $name: status provider was not observed" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$require_expiration" -eq 1 ]]; then
|
||||
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_assertion() {
|
||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||
local output rc
|
||||
@@ -124,6 +226,13 @@ run_assertion() {
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||
if [[ "$status_mode" == "pending" ]]; then
|
||||
assert_provider_observed "$name" 1
|
||||
else
|
||||
assert_provider_observed "$name"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
@@ -140,6 +249,27 @@ run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||
|
||||
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||
# can reach the status provider. Only the independent real-clock watchdog may
|
||||
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||
set +e
|
||||
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||
watchdog_rc=$?
|
||||
set -e
|
||||
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||
printf '%s\n' "$watchdog_output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||
failures=$((failures + 1))
|
||||
@@ -160,6 +290,7 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed merge-provider-unreachable
|
||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||
@@ -223,6 +354,7 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed audit-unavailable
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||
|
||||
@@ -213,6 +213,8 @@ fi
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "github.com" "agentA")
|
||||
assert_eq "unknown host: no output" "" "$out"
|
||||
out=$(run_helper "github.com" "[email protected]")
|
||||
assert_eq "unknown host with non-Mosaic username: no output" "" "$out"
|
||||
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
|
||||
assert_eq "unknown host in fleet context: no output" "" "$out"
|
||||
|
||||
|
||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
||||
# loop — which would make the control a false negative. A root dotfile is
|
||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||
# A bash signal trap that merely returns lets the script continue past the
|
||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||
# the trap and shows the buggy resume-to-success.
|
||||
make_term_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
# success. The earlier test used a child cp shim to signal its parent, making
|
||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||
TERM_MARKER='[test-control] TERM handler entered'
|
||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||
|
||||
make_signal_installer() {
|
||||
local output="$1" handler="$2"
|
||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||
local inject_close=' fi'
|
||||
|
||||
if ! awk \
|
||||
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||
-v handler="$handler" -v inject_open="$inject_open" \
|
||||
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||
$0 == target_cp {
|
||||
print inject_open
|
||||
print inject_kill
|
||||
print inject_close
|
||||
injection_sites++
|
||||
}
|
||||
{ print }
|
||||
$0 == target_trap {
|
||||
print handler
|
||||
handler_sites++
|
||||
}
|
||||
END {
|
||||
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||
}
|
||||
' "$INSTALL" > "$output"; then
|
||||
rm -f "$output"
|
||||
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$output"
|
||||
}
|
||||
|
||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||
signal_fixture_ready() {
|
||||
local fixture="$1" expected_handler="$2"
|
||||
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||
&& grep -Fqx "$expected_handler" "$fixture"
|
||||
}
|
||||
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||
"signaled_fixture_ready"
|
||||
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||
"noexit_fixture_ready"
|
||||
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||
|
||||
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||
run_signal_upgrade() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
local installer="$1" H OUT rc
|
||||
H=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
make_term_shim "$SHIM"
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" \
|
||||
PATH="$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||
chk "[signal] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||
"[ '$rcC' -ne 0 ]"
|
||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||
"$INSTALL" > "$NOEXIT"
|
||||
chk "[control] the exit-strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||
"[ '$rcD' -eq 0 ]"
|
||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||
"grep -q 'file phase complete' '$OUTD'"
|
||||
|
||||
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||
|
||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
||||
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -19,7 +22,9 @@ import {
|
||||
} from '../credentials/audit-journal.js';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
import { FileCredentialStore } from '../credentials/file-credential-store.js';
|
||||
import { executeCredentialRotate, executeCredentialWire } from './cred.js';
|
||||
import { credentialLifecycleLocksDirectory } from '../credentials/lifecycle.js';
|
||||
import { TeaLoginStore } from '../credentials/tea-login-store.js';
|
||||
import { executeCredentialGet, executeCredentialRotate, executeCredentialWire } from './cred.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
@@ -109,6 +114,377 @@ describe('credential lifecycle command controls', (): void => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['partialWrite', 'shortWrite', 'recordMutation', 'seal'] as const)(
|
||||
'handles %s without overstating credential disclosure',
|
||||
async (method): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'active-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('seat-token-canary'),
|
||||
);
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
const outputPath = join(cleanup!, 'credential.out');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'seat-token-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
if (method === 'recordMutation') {
|
||||
const original = CredentialAuditJournal.prototype.recordMutation;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
||||
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
||||
if (decision === 'credential-issued') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
|
||||
}
|
||||
await original.call(this, decision);
|
||||
},
|
||||
);
|
||||
} else if (method === 'seal') {
|
||||
const original = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reason,
|
||||
): Promise<string> {
|
||||
if (outcome === 'ok') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected seal failure');
|
||||
}
|
||||
return original.call(this, outcome, reason);
|
||||
});
|
||||
}
|
||||
let writes = 0;
|
||||
const authority = await open(authorityPath, 'r');
|
||||
const output = await open(outputPath, 'w+', 0o600);
|
||||
try {
|
||||
const result = await executeCredentialGet('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
outputFd: output.fd.toString(),
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
credentialWrite:
|
||||
method === 'partialWrite'
|
||||
? (fd, data): number => {
|
||||
writes += 1;
|
||||
if (writes === 2) throw new Error('injected partial write failure');
|
||||
return writeSync(fd, data);
|
||||
}
|
||||
: method === 'shortWrite'
|
||||
? (fd, data): number =>
|
||||
writeSync(fd, data.subarray(0, Math.max(1, Math.floor(data.byteLength / 2))))
|
||||
: undefined,
|
||||
});
|
||||
if (method === 'shortWrite') {
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'ok',
|
||||
mutation: 'none',
|
||||
reason: { code: 'get-verified' },
|
||||
});
|
||||
} else {
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: method === 'partialWrite' ? 'unknown' : 'applied',
|
||||
reason: { code: 'credential-issuance-indeterminate' },
|
||||
});
|
||||
}
|
||||
const emitted = await readFile(outputPath, 'utf8');
|
||||
if (method === 'partialWrite') {
|
||||
expect(emitted).toContain('username=seat-name');
|
||||
expect(emitted).not.toContain('seat-token-canary');
|
||||
} else {
|
||||
expect(emitted).toContain('password=seat-token-canary');
|
||||
}
|
||||
} finally {
|
||||
await output.close();
|
||||
await authority.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['absent', 'divergent'] as const)(
|
||||
'restores an independently %s Tea pre-state after rotation journal failure',
|
||||
async (teaState): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token'),
|
||||
);
|
||||
const teaConfig = join(cleanup!, 'tea', 'config.yml');
|
||||
const teaStore = new TeaLoginStore(teaConfig);
|
||||
if (teaState === 'divergent') {
|
||||
await teaStore.put(
|
||||
'seat-name',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
);
|
||||
}
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let replacementRevoked = false;
|
||||
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
||||
const method = init?.method ?? 'GET';
|
||||
if (url.pathname === '/api/v1/user') {
|
||||
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith('/tokens') && method === 'POST') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: 'new-generation',
|
||||
sha1: 'replacement-token',
|
||||
scopes: ['write:repository'],
|
||||
}),
|
||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
if (url.pathname.endsWith('/tokens') && method === 'GET') {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
replacementRevoked ? [] : [{ name: 'new-generation', scopes: ['write:repository'] }],
|
||||
),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
if (url.pathname.endsWith('/tokens/new-generation') && method === 'DELETE') {
|
||||
replacementRevoked = true;
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
throw new Error(`unexpected provider request: ${method} ${url.pathname}`);
|
||||
});
|
||||
let mintRecords = 0;
|
||||
const recordMutation = CredentialAuditJournal.prototype.recordMutation;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
||||
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
||||
if (decision === 'token-mint-applied') {
|
||||
mintRecords += 1;
|
||||
if (mintRecords === 2) {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected rotation failure');
|
||||
}
|
||||
}
|
||||
await recordMutation.call(this, decision);
|
||||
},
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
teaConfig,
|
||||
});
|
||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
||||
if (teaState === 'absent') {
|
||||
expect(teaStore.snapshot('seat-name', 'git.example.invalid')).toBeUndefined();
|
||||
} else {
|
||||
expect(
|
||||
teaStore.matchesSecret(
|
||||
'seat-name',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves an open nested provision journal in the rotate result', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token'),
|
||||
);
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const snapshot = TeaLoginStore.prototype.snapshot;
|
||||
let snapshotCalls = 0;
|
||||
vi.spyOn(TeaLoginStore.prototype, 'snapshot').mockImplementation(function (
|
||||
this: TeaLoginStore,
|
||||
identity,
|
||||
host,
|
||||
) {
|
||||
snapshotCalls += 1;
|
||||
if (snapshotCalls === 2) throw new Error('injected nested snapshot failure');
|
||||
return snapshot.call(this, identity, host);
|
||||
});
|
||||
const seal = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reasonCode,
|
||||
): Promise<string> {
|
||||
if (reasonCode === 'credential-snapshot-unavailable') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected nested seal failure');
|
||||
}
|
||||
return seal.call(this, outcome, reasonCode);
|
||||
});
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
operation: 'rotate',
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the journal failure diagnosis when rotate lock failure cannot be sealed', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token'),
|
||||
);
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--seat-name.lock');
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await symlink('/dev/null', lockPath);
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
|
||||
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
operation: 'rotate',
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'journal-recovery-required' },
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
|
||||
@@ -27,7 +27,13 @@ import {
|
||||
import type { CredentialGrantResultDto } from '../credentials/grant.dto.js';
|
||||
import { grantTeamRepositoryPermission } from '../credentials/team-grant.js';
|
||||
import type { TeamGrantResult } from '../credentials/team-grant.js';
|
||||
import { provisionCredential, revokeCredential } from '../credentials/lifecycle.js';
|
||||
import {
|
||||
acquireCredentialLifecycleLock,
|
||||
CredentialLifecycleLockError,
|
||||
type CredentialLifecycleLock,
|
||||
provisionCredential,
|
||||
revokeCredential,
|
||||
} from '../credentials/lifecycle.js';
|
||||
import type { CredentialLifecycleResultDto } from '../credentials/lifecycle.dto.js';
|
||||
import { TeaLoginStore } from '../credentials/tea-login-store.js';
|
||||
import {
|
||||
@@ -94,6 +100,7 @@ interface CredentialLifecycleCommandOptions {
|
||||
readonly json?: boolean;
|
||||
readonly wireBeforeRename?: () => Promise<void>;
|
||||
readonly wireDirectorySync?: (path: string) => Promise<void>;
|
||||
readonly credentialWrite?: (fd: number, data: Uint8Array) => number;
|
||||
}
|
||||
|
||||
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
|
||||
@@ -259,6 +266,7 @@ export async function executeCredentialGrant(
|
||||
process.env['MOSAIC_GITEA_TOKEN_DIR'] ??
|
||||
join(mosaicHome, 'secrets', 'gitea-tokens');
|
||||
const stateRoot = options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred');
|
||||
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
|
||||
if (
|
||||
!['read', 'write', 'admin'].includes(options.permission) ||
|
||||
!['collaborator', 'team'].includes(options.via) ||
|
||||
@@ -279,7 +287,7 @@ export async function executeCredentialGrant(
|
||||
};
|
||||
}
|
||||
const fd = Number(options.authorityFd);
|
||||
const authority = await readDelegatedCredentialFromFd(
|
||||
authority = await readDelegatedCredentialFromFd(
|
||||
fd,
|
||||
options.actor,
|
||||
options.estate,
|
||||
@@ -325,6 +333,8 @@ export async function executeCredentialGrant(
|
||||
return grantErrorResult(identity, options, error.code);
|
||||
}
|
||||
return grantErrorResult(identity, options, 'internal-invariant');
|
||||
} finally {
|
||||
authority?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +425,7 @@ export async function executeCredentialProvision(
|
||||
identity: string,
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
|
||||
try {
|
||||
if (options.authorityFd === undefined || options.tokenName === undefined) {
|
||||
return localLifecycleResult('provision', identity, options, {
|
||||
@@ -432,7 +443,7 @@ export async function executeCredentialProvision(
|
||||
});
|
||||
}
|
||||
const context = await lifecycleContext(options);
|
||||
const authority = await lifecycleAuthority(identity, options);
|
||||
authority = await lifecycleAuthority(identity, options);
|
||||
return await provisionCredential(
|
||||
{
|
||||
identity,
|
||||
@@ -459,6 +470,8 @@ export async function executeCredentialProvision(
|
||||
code,
|
||||
message: 'Provisioning control failed locally.',
|
||||
});
|
||||
} finally {
|
||||
authority?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +479,7 @@ export async function executeCredentialRevoke(
|
||||
identity: string,
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
|
||||
try {
|
||||
if (options.authorityFd === undefined) {
|
||||
return localLifecycleResult('revoke', identity, options, {
|
||||
@@ -475,7 +489,7 @@ export async function executeCredentialRevoke(
|
||||
});
|
||||
}
|
||||
const context = await lifecycleContext(options);
|
||||
const authority = await lifecycleAuthority(identity, options);
|
||||
authority = await lifecycleAuthority(identity, options);
|
||||
return await revokeCredential(
|
||||
{ identity, estate: options.estate, host: options.host },
|
||||
authority,
|
||||
@@ -490,6 +504,8 @@ export async function executeCredentialRevoke(
|
||||
code: 'internal-invariant',
|
||||
message: 'Revocation control failed locally.',
|
||||
});
|
||||
} finally {
|
||||
authority?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +515,11 @@ export async function executeCredentialRotate(
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let journal: CredentialAuditJournal | undefined;
|
||||
let old: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||
let oldTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
||||
let replacement: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||
let replacementTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
||||
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
|
||||
let lifecycleLock: CredentialLifecycleLock | undefined;
|
||||
let mutation: CredentialLifecycleResultDto['mutation'] = 'none';
|
||||
try {
|
||||
if (options.authorityFd === undefined || options.tokenName === undefined) {
|
||||
@@ -519,6 +539,32 @@ export async function executeCredentialRotate(
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('rotate-requested');
|
||||
try {
|
||||
lifecycleLock = await acquireCredentialLifecycleLock(identity, options.estate, options.host);
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
|
||||
try {
|
||||
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
|
||||
mutation: 'none',
|
||||
code,
|
||||
message: 'Credential lifecycle mutation lock could not be acquired.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
code:
|
||||
sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Mutation lock failure could not be sealed durably.',
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
}
|
||||
}
|
||||
old = await context.store.snapshot(identity, options.estate, options.host);
|
||||
if (old === undefined) {
|
||||
await journal.seal('refused', 'no-token-for-identity');
|
||||
@@ -529,6 +575,7 @@ export async function executeCredentialRotate(
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
oldTea = context.teaStore.snapshot(identity, options.host);
|
||||
if (options.tokenName === old.binding.tokenName) {
|
||||
await journal.seal('refused', 'replacement-token-name-conflict');
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
@@ -558,16 +605,24 @@ export async function executeCredentialRotate(
|
||||
allowReplace: true,
|
||||
journal,
|
||||
deferSuccessSeal: true,
|
||||
lifecycleLock,
|
||||
expectedStoreGeneration: old.generation,
|
||||
expectedTeaGeneration: oldTea?.generation ?? null,
|
||||
},
|
||||
);
|
||||
if (provisioned.outcome !== 'ok') {
|
||||
return {
|
||||
...provisioned,
|
||||
operation: 'rotate',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
return { ...provisioned, operation: 'rotate' };
|
||||
}
|
||||
mutation = 'applied';
|
||||
replacement = await context.store.snapshot(identity, options.estate, options.host);
|
||||
replacementTea = context.teaStore.snapshot(identity, options.host);
|
||||
if (
|
||||
replacement === undefined ||
|
||||
replacement.binding.tokenName !== options.tokenName ||
|
||||
replacementTea === undefined
|
||||
) {
|
||||
throw new Error('replacement generation could not be snapshotted exactly');
|
||||
}
|
||||
try {
|
||||
await journal.recordMutation('token-mint-applied');
|
||||
} catch (error: unknown) {
|
||||
@@ -575,8 +630,22 @@ export async function executeCredentialRotate(
|
||||
if (await context.provider.tokenExists(authority, identity, options.tokenName)) {
|
||||
throw new Error('replacement rollback after journal failure could not be verified');
|
||||
}
|
||||
await context.store.put(old.binding, old.secret);
|
||||
await context.teaStore.put(identity, options.host, old.secret);
|
||||
await context.store.put(old.binding, old.secret, replacement.generation);
|
||||
await context.teaStore.restore(identity, options.host, oldTea, replacementTea.generation);
|
||||
const restored = await context.store.snapshot(identity, options.estate, options.host);
|
||||
try {
|
||||
if (
|
||||
restored === undefined ||
|
||||
JSON.stringify(restored.binding) !== JSON.stringify(old.binding) ||
|
||||
restored.secret.byteLength !== old.secret.byteLength ||
|
||||
!timingSafeEqual(Buffer.from(restored.secret), Buffer.from(old.secret)) ||
|
||||
!context.teaStore.matchesSnapshot(identity, options.host, oldTea)
|
||||
) {
|
||||
throw new Error('rotation pre-operation state was not restored exactly');
|
||||
}
|
||||
} finally {
|
||||
restored?.secret.fill(0);
|
||||
}
|
||||
mutation = 'none';
|
||||
throw error;
|
||||
}
|
||||
@@ -630,6 +699,10 @@ export async function executeCredentialRotate(
|
||||
} finally {
|
||||
authority?.secret.fill(0);
|
||||
old?.secret.fill(0);
|
||||
oldTea?.secret.fill(0);
|
||||
replacement?.secret.fill(0);
|
||||
replacementTea?.secret.fill(0);
|
||||
await lifecycleLock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -861,6 +934,10 @@ export async function executeCredentialGet(
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const locations = lifecycleLocations(options);
|
||||
let journal: CredentialAuditJournal | undefined;
|
||||
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
|
||||
let resolved: Awaited<ReturnType<FileCredentialResolver['resolve']>> = undefined;
|
||||
let disclosureStarted = false;
|
||||
let disclosureCompleted = false;
|
||||
try {
|
||||
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'get',
|
||||
@@ -891,7 +968,7 @@ export async function executeCredentialGet(
|
||||
throw new Error('unsafe output fd');
|
||||
}
|
||||
const context = await lifecycleContext(options);
|
||||
const authority = await lifecycleAuthority(identity, options);
|
||||
authority = await lifecycleAuthority(identity, options);
|
||||
const providerIdentity = await context.provider.readIdentity(authority);
|
||||
if (providerIdentity.login !== identity) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
@@ -903,7 +980,7 @@ export async function executeCredentialGet(
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
const resolved = await new FileCredentialResolver(
|
||||
resolved = await new FileCredentialResolver(
|
||||
lifecycleLocations(options).tokenDirectory,
|
||||
context.registry,
|
||||
).resolve(identity, options.estate, options.host);
|
||||
@@ -924,9 +1001,26 @@ export async function executeCredentialGet(
|
||||
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
|
||||
);
|
||||
await journal.recordMutation('credential-issuance-authorized');
|
||||
writeSync(fd, prefix);
|
||||
writeSync(fd, resolved.secret);
|
||||
writeSync(fd, new TextEncoder().encode('\n\n'));
|
||||
await journal.recordMutation('credential-issuance-started');
|
||||
disclosureStarted = true;
|
||||
const writeCredential =
|
||||
options.credentialWrite ??
|
||||
((targetFd: number, data: Uint8Array): number => writeSync(targetFd, data));
|
||||
const writeAll = (data: Uint8Array): void => {
|
||||
let offset = 0;
|
||||
while (offset < data.byteLength) {
|
||||
const remaining = data.subarray(offset);
|
||||
const written = writeCredential(fd, remaining);
|
||||
if (!Number.isSafeInteger(written) || written <= 0 || written > remaining.byteLength) {
|
||||
throw new Error('credential output made invalid write progress');
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
};
|
||||
writeAll(prefix);
|
||||
writeAll(resolved.secret);
|
||||
writeAll(new TextEncoder().encode('\n\n'));
|
||||
disclosureCompleted = true;
|
||||
await journal.recordMutation('credential-issued');
|
||||
await journal.seal('ok', 'get-verified');
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
@@ -943,22 +1037,57 @@ export async function executeCredentialGet(
|
||||
message: 'Credential access journal could not be opened durably.',
|
||||
});
|
||||
}
|
||||
if (disclosureStarted) {
|
||||
try {
|
||||
await journal.recordMutation(
|
||||
disclosureCompleted ? 'credential-issued' : 'credential-issuance-possibly-issued',
|
||||
);
|
||||
await journal.seal('indeterminate', 'credential-issuance-indeterminate');
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'indeterminate',
|
||||
mutation: disclosureCompleted ? 'applied' : 'unknown',
|
||||
code: 'credential-issuance-indeterminate',
|
||||
message: disclosureCompleted
|
||||
? 'Credential disclosure completed, but its final audit transition failed.'
|
||||
: 'Credential disclosure began but could not be proven complete.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'indeterminate',
|
||||
mutation: disclosureCompleted ? 'applied' : 'unknown',
|
||||
code: 'credential-issuance-indeterminate',
|
||||
message: 'Credential disclosure may have occurred; inspect the durable open journal.',
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
}
|
||||
}
|
||||
const code =
|
||||
error instanceof CredentialJournalError ? error.code : 'insecure-credential-destination';
|
||||
try {
|
||||
await journal.seal('error', 'insecure-credential-destination');
|
||||
await journal.seal('error', code);
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'error',
|
||||
code: 'insecure-credential-destination',
|
||||
message: 'Protected credential output fd was unavailable or unsafe.',
|
||||
code,
|
||||
message:
|
||||
error instanceof CredentialJournalError
|
||||
? 'Credential issuance audit failed before disclosure.'
|
||||
: 'Protected credential output fd was unavailable or unsafe.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'error',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Credential access audit could not be sealed.',
|
||||
message: 'Credential access audit could not be sealed before disclosure.',
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
authority?.secret.fill(0);
|
||||
resolved?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
|
||||
export type CredentialJournalOperation =
|
||||
| 'provision'
|
||||
| 'wire'
|
||||
@@ -48,6 +50,14 @@ export interface CredentialPopulationCorrectionDto {
|
||||
export interface CredentialJournalRuntimeOptionsDto {
|
||||
readonly id?: string;
|
||||
readonly now?: () => string;
|
||||
readonly syncDirectory?: (path: string) => Promise<void>;
|
||||
readonly rename?: (source: string, destination: string) => Promise<void>;
|
||||
readonly write?: (
|
||||
handle: FileHandle,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
length: number,
|
||||
) => Promise<number>;
|
||||
}
|
||||
|
||||
export interface CredentialJournalSummaryDto {
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
truncate,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
@@ -49,6 +60,226 @@ describe('credential durable audit journal', (): void => {
|
||||
expect(records[3]).toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it('writes every journal record completely when each write makes one-byte progress', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let writeCalls = 0;
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'grant',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
},
|
||||
{
|
||||
id: 'short-write',
|
||||
now: (): string => '2026-08-05T00:00:00.000Z',
|
||||
write: async (handle, data, offset, length): Promise<number> => {
|
||||
writeCalls += 1;
|
||||
const result = await handle.write(data, offset, Math.min(1, length), null);
|
||||
return result.bytesWritten;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await journal.recordIntent('provider-grant');
|
||||
const sealedPath = await journal.seal('ok', 'grant-verified');
|
||||
const records = (await readFile(sealedPath, 'utf8')).trim().split('\n');
|
||||
|
||||
expect(writeCalls).toBeGreaterThan(3);
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0]).toContain('"phase":"opened"');
|
||||
expect(records[1]).toContain('"phase":"intent"');
|
||||
expect(records[2]).toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['zero', (_remaining: number): number => 0],
|
||||
['oversized', (remaining: number): number => remaining + 1],
|
||||
] as const)(
|
||||
'rejects %s journal write progress before reporting an opened journal',
|
||||
async (_label, progress): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
await expect(
|
||||
CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'grant',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
},
|
||||
{
|
||||
id: `invalid-progress-${_label}`,
|
||||
write: async (_handle, _data, _offset, length): Promise<number> => progress(length),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/journal-unavailable/);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a final seal non-accepting while directory durability is pending', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let directorySyncs = 0;
|
||||
let signalFinalSync: (() => void) | undefined;
|
||||
let releaseFinalSync: (() => void) | undefined;
|
||||
const finalSyncEntered = new Promise<void>((resolve): void => {
|
||||
signalFinalSync = resolve;
|
||||
});
|
||||
const finalSyncRelease = new Promise<void>((resolve): void => {
|
||||
releaseFinalSync = resolve;
|
||||
});
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'get',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
},
|
||||
{
|
||||
id: 'seal-pending',
|
||||
syncDirectory: async (path): Promise<void> => {
|
||||
directorySyncs += 1;
|
||||
if (directorySyncs === 3) {
|
||||
signalFinalSync?.();
|
||||
await finalSyncRelease;
|
||||
}
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sealing = journal.seal('ok', 'get-verified');
|
||||
await finalSyncEntered;
|
||||
await expect(listCredentialJournals(root)).resolves.toContainEqual(
|
||||
expect.objectContaining({ id: 'seal-pending', state: 'open' }),
|
||||
);
|
||||
releaseFinalSync?.();
|
||||
await sealing;
|
||||
await expect(listCredentialJournals(root)).resolves.toContainEqual(
|
||||
expect.objectContaining({ id: 'seal-pending', state: 'sealed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reverts a failed final seal commit to visible open state', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let directorySyncs = 0;
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'get',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
},
|
||||
{
|
||||
id: 'seal-fault',
|
||||
syncDirectory: async (path): Promise<void> => {
|
||||
directorySyncs += 1;
|
||||
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-unavailable/);
|
||||
const [entry] = await listCredentialJournals(root);
|
||||
expect(entry).toMatchObject({ id: 'seal-fault', state: 'open' });
|
||||
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it('uses a non-accepting recovery path when the compensating rename fails', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
let directorySyncs = 0;
|
||||
let renames = 0;
|
||||
const journal = await CredentialAuditJournal.open(
|
||||
root,
|
||||
{
|
||||
operation: 'get',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
},
|
||||
{
|
||||
id: 'seal-recovery-fault',
|
||||
syncDirectory: async (path): Promise<void> => {
|
||||
directorySyncs += 1;
|
||||
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
rename: async (source, destination): Promise<void> => {
|
||||
renames += 1;
|
||||
if (renames === 3) throw new Error('injected compensating rename failure');
|
||||
await rename(source, destination);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-recovery-required/);
|
||||
const [entry] = await listCredentialJournals(root);
|
||||
expect(entry).toMatchObject({ id: 'seal-recovery-fault', state: 'open' });
|
||||
expect(entry?.path).toMatch(/\.recovery\.jsonl$/);
|
||||
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
|
||||
});
|
||||
|
||||
it.each(['symlink', 'oversized'] as const)(
|
||||
'classifies an unsafe %s sealed-looking journal as open without consuming it',
|
||||
async (kind): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'audit',
|
||||
actor: 'seat-name',
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
});
|
||||
await journal.closeIncomplete();
|
||||
const journalsDirectory = join(root, 'journals');
|
||||
const candidate = join(journalsDirectory, `unsafe-${kind}.sealed.jsonl`);
|
||||
if (kind === 'symlink') {
|
||||
if (cleanup === undefined) throw new Error('test fixture root is unavailable');
|
||||
const outside = join(cleanup, 'outside-journal');
|
||||
await writeFile(outside, '{"phase":"sealed","outcome":"ok"}\n', { mode: 0o600 });
|
||||
await symlink(outside, candidate);
|
||||
} else {
|
||||
await writeFile(candidate, '', { mode: 0o600 });
|
||||
await truncate(candidate, 4 * 1024 * 1024 + 1);
|
||||
}
|
||||
|
||||
const entry = (await listCredentialJournals(root)).find(
|
||||
(value): boolean => value.id === `unsafe-${kind}`,
|
||||
);
|
||||
expect(entry).toMatchObject({ state: 'open' });
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
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 { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import type {
|
||||
CredentialJournalContextDto,
|
||||
CredentialJournalCorrectionDto,
|
||||
@@ -19,6 +20,7 @@ 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 MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
|
||||
const SAFE_DECISIONS = new Set<string>([
|
||||
'provider-grant',
|
||||
'permission-none',
|
||||
@@ -61,6 +63,8 @@ const SAFE_DECISIONS = new Set<string>([
|
||||
'token-revoke-applied',
|
||||
'wire-applied',
|
||||
'credential-issuance-authorized',
|
||||
'credential-issuance-started',
|
||||
'credential-issuance-possibly-issued',
|
||||
'credential-issued',
|
||||
'classification-correction',
|
||||
]);
|
||||
@@ -118,6 +122,16 @@ function assertEvidence(evidence: CredentialProviderJournalEvidenceDto): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJournalBytes(
|
||||
handle: FileHandle,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
length: number,
|
||||
): Promise<number> {
|
||||
const result = await handle.write(data, offset, length, null);
|
||||
return result.bytesWritten;
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const directory = await open(path, 'r');
|
||||
try {
|
||||
@@ -127,15 +141,49 @@ async function syncDirectory(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireJournalLock(
|
||||
path: string,
|
||||
mode: 'exclusive' | 'shared',
|
||||
): Promise<FileHandle | undefined> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
|
||||
throw new Error('journal lock file is unsafe');
|
||||
}
|
||||
const acquired = spawnSync('/usr/bin/flock', ['-n', mode === 'exclusive' ? '-x' : '-s', '3'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
||||
});
|
||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
||||
await handle.close();
|
||||
return undefined;
|
||||
}
|
||||
return handle;
|
||||
} catch {
|
||||
await handle?.close().catch((): void => undefined);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialAuditJournal {
|
||||
private closed = false;
|
||||
|
||||
private constructor(
|
||||
private readonly handle: FileHandle,
|
||||
private readonly lockHandle: FileHandle,
|
||||
private readonly openPath: string,
|
||||
private readonly journalsDirectory: string,
|
||||
private readonly id: string,
|
||||
private readonly now: () => string,
|
||||
private readonly syncJournalDirectory: (path: string) => Promise<void>,
|
||||
private readonly renameJournal: (source: string, destination: string) => Promise<void>,
|
||||
private readonly writeJournal: (
|
||||
handle: FileHandle,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
length: number,
|
||||
) => Promise<number>,
|
||||
) {}
|
||||
|
||||
static async open(
|
||||
@@ -150,19 +198,44 @@ export class CredentialAuditJournal {
|
||||
}
|
||||
const now = runtime.now ?? ((): string => new Date().toISOString());
|
||||
const journalsDirectory = join(stateRoot, 'journals');
|
||||
const locksDirectory = join(stateRoot, 'journal-locks');
|
||||
let handle: FileHandle | undefined;
|
||||
let lockHandle: FileHandle | undefined;
|
||||
try {
|
||||
ensureManagedDirectory(stateRoot, journalsDirectory);
|
||||
ensureManagedDirectory(stateRoot, locksDirectory);
|
||||
assertPrivateDirectory(stateRoot);
|
||||
assertPrivateDirectory(journalsDirectory);
|
||||
assertPrivateDirectory(locksDirectory);
|
||||
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
|
||||
const lockPath = join(locksDirectory, `${id}.lock`);
|
||||
handle = await open(openPath, 'wx', 0o600);
|
||||
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
|
||||
lockHandle = await acquireJournalLock(lockPath, 'exclusive');
|
||||
if (lockHandle === undefined) {
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
'journal lock could not be acquired',
|
||||
);
|
||||
}
|
||||
const syncJournalDirectory = runtime.syncDirectory ?? syncDirectory;
|
||||
const renameJournal = runtime.rename ?? rename;
|
||||
const journal = new CredentialAuditJournal(
|
||||
handle,
|
||||
lockHandle,
|
||||
openPath,
|
||||
journalsDirectory,
|
||||
id,
|
||||
now,
|
||||
syncJournalDirectory,
|
||||
renameJournal,
|
||||
runtime.write ?? writeJournalBytes,
|
||||
);
|
||||
await journal.append({ phase: 'opened', at: now(), context });
|
||||
await syncDirectory(journalsDirectory);
|
||||
await syncJournalDirectory(journalsDirectory);
|
||||
return journal;
|
||||
} catch (error: unknown) {
|
||||
if (handle !== undefined) await handle.close().catch((): void => undefined);
|
||||
if (lockHandle !== undefined) await lockHandle.close().catch((): void => undefined);
|
||||
if (error instanceof CredentialJournalError) throw error;
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
@@ -176,7 +249,16 @@ export class CredentialAuditJournal {
|
||||
throw new CredentialJournalError('journal-unavailable', 'journal is already closed');
|
||||
}
|
||||
try {
|
||||
await this.handle.write(`${JSON.stringify(record)}\n`);
|
||||
const data = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
|
||||
let offset = 0;
|
||||
while (offset < data.byteLength) {
|
||||
const remaining = data.byteLength - offset;
|
||||
const written = await this.writeJournal(this.handle, data, offset, remaining);
|
||||
if (!Number.isSafeInteger(written) || written <= 0 || written > remaining) {
|
||||
throw new Error('journal write made invalid progress');
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
await this.handle.sync();
|
||||
} catch {
|
||||
throw new CredentialJournalError(
|
||||
@@ -264,17 +346,55 @@ export class CredentialAuditJournal {
|
||||
'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 preSealSize = (await this.handle.stat()).size;
|
||||
const sealingPath = join(this.journalsDirectory, `${this.id}.sealing.jsonl`);
|
||||
const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`);
|
||||
const recoveryPath = join(this.journalsDirectory, `${this.id}.recovery.jsonl`);
|
||||
let currentPath = this.openPath;
|
||||
try {
|
||||
await rename(this.openPath, sealedPath);
|
||||
await syncDirectory(this.journalsDirectory);
|
||||
await this.renameJournal(currentPath, sealingPath);
|
||||
currentPath = sealingPath;
|
||||
await this.syncJournalDirectory(this.journalsDirectory);
|
||||
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
|
||||
await this.renameJournal(currentPath, sealedPath);
|
||||
currentPath = sealedPath;
|
||||
await this.syncJournalDirectory(this.journalsDirectory);
|
||||
await this.handle.close();
|
||||
await this.lockHandle.close();
|
||||
this.closed = true;
|
||||
return sealedPath;
|
||||
} catch {
|
||||
let recovered = true;
|
||||
try {
|
||||
await this.handle.truncate(preSealSize);
|
||||
await this.handle.sync();
|
||||
} catch {
|
||||
recovered = false;
|
||||
}
|
||||
if (currentPath !== this.openPath) {
|
||||
try {
|
||||
await this.renameJournal(currentPath, this.openPath);
|
||||
currentPath = this.openPath;
|
||||
} catch {
|
||||
recovered = false;
|
||||
try {
|
||||
await this.renameJournal(currentPath, recoveryPath);
|
||||
currentPath = recoveryPath;
|
||||
} catch {
|
||||
// The non-success return below remains authoritative; the path is reported by audit scan.
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.syncJournalDirectory(this.journalsDirectory);
|
||||
} catch {
|
||||
recovered = false;
|
||||
}
|
||||
await this.handle.close().catch((): void => undefined);
|
||||
await this.lockHandle.close().catch((): void => undefined);
|
||||
this.closed = true;
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
recovered ? 'journal-unavailable' : 'journal-recovery-required',
|
||||
'sealed journal could not be committed durably',
|
||||
);
|
||||
}
|
||||
@@ -283,6 +403,7 @@ export class CredentialAuditJournal {
|
||||
async closeIncomplete(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
await this.handle.close();
|
||||
await this.lockHandle.close();
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
@@ -291,24 +412,56 @@ export async function listCredentialJournals(
|
||||
stateRoot: string,
|
||||
): Promise<readonly CredentialJournalSummaryDto[]> {
|
||||
const journalsDirectory = join(stateRoot, 'journals');
|
||||
const locksDirectory = join(stateRoot, 'journal-locks');
|
||||
let names: string[];
|
||||
try {
|
||||
assertPrivateDirectory(stateRoot);
|
||||
assertPrivateDirectory(journalsDirectory);
|
||||
ensureManagedDirectory(stateRoot, locksDirectory);
|
||||
assertPrivateDirectory(locksDirectory);
|
||||
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),
|
||||
};
|
||||
});
|
||||
return Promise.all(
|
||||
names
|
||||
.filter((name: string): boolean => /\.(?:open|sealing|recovery|sealed)\.jsonl$/.test(name))
|
||||
.sort()
|
||||
.map(async (name: string): Promise<CredentialJournalSummaryDto> => {
|
||||
const path = join(journalsDirectory, name);
|
||||
const id = name.replace(/\.(?:open|sealing|recovery|sealed)\.jsonl$/, '');
|
||||
let committedSeal = false;
|
||||
let scanLock: FileHandle | undefined;
|
||||
if (name.endsWith('.sealed.jsonl') && SAFE_NAME.test(id)) {
|
||||
try {
|
||||
scanLock = await acquireJournalLock(join(locksDirectory, `${id}.lock`), 'shared');
|
||||
if (scanLock === undefined) throw new Error('journal seal is still in progress');
|
||||
const snapshot = readRegularFileSecure(path, {
|
||||
root: journalsDirectory,
|
||||
maxBytes: MAX_JOURNAL_BYTES,
|
||||
});
|
||||
if (snapshot.uid !== process.getuid?.() || (snapshot.mode & 0o077) !== 0) {
|
||||
throw new Error('journal owner or mode is unsafe');
|
||||
}
|
||||
const records = snapshot.content.toString('utf8').trim().split('\n');
|
||||
const finalRecord: unknown = JSON.parse(records.at(-1) ?? 'null');
|
||||
committedSeal =
|
||||
typeof finalRecord === 'object' &&
|
||||
finalRecord !== null &&
|
||||
'phase' in finalRecord &&
|
||||
finalRecord.phase === 'sealed';
|
||||
} catch {
|
||||
committedSeal = false;
|
||||
} finally {
|
||||
await scanLock?.close().catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
state: committedSeal ? 'sealed' : 'open',
|
||||
path,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('phase-1 governed file credential resolver', (): void => {
|
||||
);
|
||||
});
|
||||
|
||||
it('atomically stores, lists, reads binding metadata, and removes a governed credential', async (): Promise<void> => {
|
||||
it('stores one governed envelope, lists and reads it, then removes all credential artifacts', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
const store = new FileCredentialStore(root, registry());
|
||||
await store.put(
|
||||
@@ -148,6 +148,57 @@ describe('phase-1 governed file credential resolver', (): void => {
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('changes generation for metadata-only rebinding and rejects stale replacement or removal', async (): Promise<void> => {
|
||||
const root = await fixtureRoot();
|
||||
const store = new FileCredentialStore(root, registry());
|
||||
const secret = new TextEncoder().encode('same-private-token');
|
||||
const originalBinding = {
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat',
|
||||
tokenName: 'mosaic-seat-original',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
};
|
||||
await store.put(originalBinding, secret);
|
||||
const original = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
||||
if (original === undefined) throw new Error('original generation was not stored');
|
||||
try {
|
||||
await store.put(
|
||||
{
|
||||
...originalBinding,
|
||||
tokenName: 'mosaic-seat-rebound',
|
||||
createdAt: '2026-08-05T00:00:01.000Z',
|
||||
},
|
||||
secret,
|
||||
original.generation,
|
||||
);
|
||||
const rebound = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
||||
try {
|
||||
expect(rebound?.generation).not.toBe(original.generation);
|
||||
await expect(
|
||||
store.put(
|
||||
{ ...originalBinding, tokenName: 'mosaic-seat-stale' },
|
||||
secret,
|
||||
original.generation,
|
||||
),
|
||||
).rejects.toThrow(/credential-generation-mismatch/);
|
||||
await expect(
|
||||
store.remove('seat', 'homelab', 'git.example.invalid', original.generation),
|
||||
).rejects.toThrow(/credential-generation-mismatch/);
|
||||
await expect(
|
||||
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
||||
).resolves.toMatchObject({ tokenName: 'mosaic-seat-rebound' });
|
||||
} finally {
|
||||
rebound?.secret.fill(0);
|
||||
}
|
||||
} finally {
|
||||
original.secret.fill(0);
|
||||
secret.fill(0);
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -53,6 +53,25 @@ function isMissingFile(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function credentialBindingGeneration(
|
||||
metadata: CredentialBindingMetadataDto,
|
||||
secret: Uint8Array,
|
||||
): string {
|
||||
const tokenDigest = createHash('sha256').update(secret).digest('hex');
|
||||
const canonicalState = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
identity: metadata.identity,
|
||||
estate: metadata.estate,
|
||||
host: metadata.host,
|
||||
providerLogin: metadata.providerLogin,
|
||||
tokenName: metadata.tokenName,
|
||||
scopes: [...metadata.scopes].sort(),
|
||||
createdAt: metadata.createdAt,
|
||||
tokenDigest,
|
||||
});
|
||||
return createHash('sha256').update(canonicalState).digest('hex');
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -285,7 +304,11 @@ export class FileCredentialStore {
|
||||
};
|
||||
}
|
||||
|
||||
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
|
||||
async put(
|
||||
metadata: CredentialBindingMetadataDto,
|
||||
secret: Uint8Array,
|
||||
expectedGeneration?: string | null,
|
||||
): Promise<void> {
|
||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
||||
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
@@ -309,6 +332,20 @@ export class FileCredentialStore {
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (expectedGeneration !== undefined) {
|
||||
const current = await this.snapshot(metadata.identity, metadata.estate, metadata.host);
|
||||
try {
|
||||
const actualGeneration = current?.generation ?? null;
|
||||
if (actualGeneration !== expectedGeneration) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential generation changed before replacement',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
const handle = await open(envelopeTemp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
|
||||
@@ -344,6 +381,7 @@ export class FileCredentialStore {
|
||||
| {
|
||||
readonly binding: CredentialBindingMetadataDto;
|
||||
readonly secret: Uint8Array;
|
||||
readonly generation: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
@@ -359,7 +397,10 @@ export class FileCredentialStore {
|
||||
'binding metadata exists without its token generation',
|
||||
);
|
||||
}
|
||||
return { binding, secret: new Uint8Array(resolved.secret) };
|
||||
const secret = new Uint8Array(resolved.secret);
|
||||
const generation = credentialBindingGeneration(binding, resolved.secret);
|
||||
resolved.secret.fill(0);
|
||||
return { binding, secret, generation };
|
||||
}
|
||||
|
||||
async readBinding(
|
||||
@@ -467,12 +508,16 @@ export class FileCredentialStore {
|
||||
}
|
||||
try {
|
||||
if (expectedTokenDigest !== undefined) {
|
||||
const current = await this.readBinding(identity, estate, host);
|
||||
if (current === undefined || current.tokenDigest !== expectedTokenDigest) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential generation changed before removal',
|
||||
);
|
||||
const current = await this.snapshot(identity, estate, host);
|
||||
try {
|
||||
if (current === undefined || current.generation !== expectedTokenDigest) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential generation changed before removal',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||
import { FileCredentialStore } from './file-credential-store.js';
|
||||
import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js';
|
||||
import {
|
||||
acquireCredentialLifecycleLock,
|
||||
CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL,
|
||||
credentialLifecycleLocksDirectory,
|
||||
provisionCredential,
|
||||
revokeCredential,
|
||||
type GiteaLifecycleProvider,
|
||||
} from './lifecycle.js';
|
||||
import { TeaLoginStore } from './tea-login-store.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
@@ -88,6 +98,181 @@ function provider(): GiteaLifecycleProvider {
|
||||
}
|
||||
|
||||
describe('credential lifecycle', (): void => {
|
||||
it('pins lifecycle flock as cooperative serialization rather than authorization', (): void => {
|
||||
expect(CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL).toStrictEqual({
|
||||
purpose: 'cooperative-serialization',
|
||||
authorizationBoundary: 'provider-authority',
|
||||
generationPreconditions: 'optimistic-cooperating-mutators',
|
||||
hostileSameUidFilesystem: 'out-of-scope-deferred',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a concurrent same-identity lifecycle mutation across state roots', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const lock = await acquireCredentialLifecycleLock('seat', 'homelab', 'git.example.invalid');
|
||||
try {
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-contended',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{
|
||||
stateRoot: `${root}-other`,
|
||||
actor: 'seat',
|
||||
lifecycleLock: {
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
async release(): Promise<void> {},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
reason: { code: 'concurrent-mutation' },
|
||||
});
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps cooperative lifecycle locks below the validated user home, not shared tmp', (): void => {
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
expect(locksDirectory).not.toMatch(/^\/tmp(?:\/|$)/);
|
||||
expect(locksDirectory).toMatch(/\/\.local\/state\/mosaic\/credential-lifecycle-locks$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['../seat', 'homelab', 'git.example.invalid'],
|
||||
['seat', '../homelab', 'git.example.invalid'],
|
||||
['seat', 'homelab', '../git.example.invalid'],
|
||||
])(
|
||||
'rejects traversal before constructing a lifecycle lock path',
|
||||
async (identity, estate, host): Promise<void> => {
|
||||
await expect(acquireCredentialLifecycleLock(identity, estate, host)).rejects.toMatchObject({
|
||||
code: 'mutation-lock-unavailable',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('returns a structured error when the lifecycle lock path cannot be opened', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--open-failure-seat.lock');
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await symlink('/dev/null', lockPath);
|
||||
try {
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'open-failure-seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-open-failure-seat',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'open-failure-seat' },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'mutation-lock-unavailable' },
|
||||
});
|
||||
} finally {
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['provision', 'revoke'] as const)(
|
||||
'returns an open structured %s result when lock failure cannot be sealed',
|
||||
async (operation): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const identity = `seal-failure-${operation}`;
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(locksDirectory, `homelab--git.example.invalid--${identity}.lock`);
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await symlink('/dev/null', lockPath);
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
|
||||
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
|
||||
);
|
||||
try {
|
||||
const result =
|
||||
operation === 'provision'
|
||||
? await provisionCredential(
|
||||
{
|
||||
identity,
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: `mosaic-${identity}`,
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
{ ...authority, identity },
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: identity },
|
||||
)
|
||||
: await revokeCredential(
|
||||
{ identity, estate: 'homelab', host: 'git.example.invalid' },
|
||||
{ ...authority, identity },
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: identity },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'journal-recovery-required' },
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
} finally {
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('returns a sealed structured error when the Tea pre-state cannot be snapshotted', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await teaStore.put('seat', 'git.example.invalid', new TextEncoder().encode('prior-tea-token'));
|
||||
await writeFile(join(cleanup!, 'tea', 'config.yml'), 'logins: not-an-array\n', { mode: 0o600 });
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-snapshot-failure',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
reason: { code: 'credential-snapshot-unavailable' },
|
||||
audit: { state: 'sealed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts provision only after exact principal and scope read-back', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const result = await provisionCredential(
|
||||
@@ -147,6 +332,209 @@ describe('credential lifecycle', (): void => {
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['store', 'tea'] as const)(
|
||||
'removes %s state committed before a post-commit storage failure',
|
||||
async (target): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
if (target === 'store') {
|
||||
const put = store.put.bind(store);
|
||||
store.put = async (binding, secret): Promise<void> => {
|
||||
await put(binding, secret);
|
||||
throw new Error('injected store post-commit failure');
|
||||
};
|
||||
} else {
|
||||
const put = teaStore.put.bind(teaStore);
|
||||
teaStore.put = async (identity, host, secret): Promise<void> => {
|
||||
await put(identity, host, secret);
|
||||
throw new Error('injected Tea post-commit failure');
|
||||
};
|
||||
}
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: `mosaic-seat-${target}-fault`,
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
||||
await expect(
|
||||
store.snapshot('seat', 'homelab', 'git.example.invalid'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a stale lifecycle write when a competing generation bypasses serialization', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const put = store.put.bind(store);
|
||||
let injected = false;
|
||||
store.put = async (binding, secret, expectedGeneration): Promise<void> => {
|
||||
if (!injected) {
|
||||
injected = true;
|
||||
await put(
|
||||
{
|
||||
...binding,
|
||||
tokenName: 'competing-generation',
|
||||
createdAt: '2026-08-05T00:00:01.000Z',
|
||||
},
|
||||
new TextEncoder().encode('competing-token'),
|
||||
expectedGeneration,
|
||||
);
|
||||
}
|
||||
await put(binding, secret, expectedGeneration);
|
||||
};
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'stale-generation',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'applied',
|
||||
reason: { code: 'rollback-incomplete' },
|
||||
});
|
||||
const current = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
||||
try {
|
||||
expect(current?.binding.tokenName).toBe('competing-generation');
|
||||
expect(Buffer.from(current?.secret ?? []).toString('utf8')).toBe('competing-token');
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['absent', 'divergent'] as const)(
|
||||
'restores an independently %s pre-operation Tea state exactly',
|
||||
async (teaState): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-store-token'),
|
||||
);
|
||||
if (teaState === 'divergent') {
|
||||
await teaStore.put(
|
||||
'seat',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
);
|
||||
}
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'provision',
|
||||
actor: 'seat',
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
});
|
||||
let evidenceWrites = 0;
|
||||
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
|
||||
journal.recordProviderEvidence = async (evidence): Promise<void> => {
|
||||
evidenceWrites += 1;
|
||||
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
|
||||
await recordProviderEvidence(evidence);
|
||||
};
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'replacement-generation',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat', journal, allowReplace: true },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
||||
if (teaState === 'absent') {
|
||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
|
||||
} else {
|
||||
expect(
|
||||
teaStore.matchesSecret(
|
||||
'seat',
|
||||
'git.example.invalid',
|
||||
new TextEncoder().encode('divergent-tea-token'),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('reports an incomplete rollback when Tea removal cannot be verified', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'provision',
|
||||
actor: 'seat',
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: null,
|
||||
});
|
||||
let evidenceWrites = 0;
|
||||
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
|
||||
journal.recordProviderEvidence = async (evidence): Promise<void> => {
|
||||
evidenceWrites += 1;
|
||||
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
|
||||
await recordProviderEvidence(evidence);
|
||||
};
|
||||
teaStore.restore = async (): Promise<void> => {
|
||||
throw new Error('injected Tea restoration failure');
|
||||
};
|
||||
|
||||
const result = await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-rollback',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat', journal },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'applied',
|
||||
reason: { code: 'rollback-incomplete' },
|
||||
});
|
||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeDefined();
|
||||
});
|
||||
|
||||
it('revokes at provider before removing the local binding', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await provisionCredential(
|
||||
@@ -181,6 +569,49 @@ describe('credential lifecycle', (): void => {
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('releases an internally owned journal lock after an append failure', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await provisionCredential(
|
||||
{
|
||||
identity: 'seat',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
tokenName: 'mosaic-seat-lock-release',
|
||||
scopes: ['write:repository'],
|
||||
},
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
const recordProviderEvidence = CredentialAuditJournal.prototype.recordProviderEvidence;
|
||||
CredentialAuditJournal.prototype.recordProviderEvidence = async (): Promise<void> => {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
|
||||
};
|
||||
try {
|
||||
const result = await revokeCredential(
|
||||
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
|
||||
authority,
|
||||
provider(),
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'none',
|
||||
audit: { state: 'open' },
|
||||
});
|
||||
if (result.audit.journalId === null) throw new Error('journal id was not returned');
|
||||
const lockPath = join(root, 'journal-locks', `${result.audit.journalId}.lock`);
|
||||
const lockProbe = spawnSync('/usr/bin/flock', ['-n', lockPath, '/bin/true']);
|
||||
expect(lockProbe.status).toBe(0);
|
||||
} finally {
|
||||
CredentialAuditJournal.prototype.recordProviderEvidence = recordProviderEvidence;
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
|
||||
const { root, store, teaStore } = await fixture();
|
||||
await provisionCredential(
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ensureManagedDirectory } from '../fleet/secure-file.js';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import type { FileCredentialStore } from './file-credential-store.js';
|
||||
import { credentialBindingGeneration, type FileCredentialStore } from './file-credential-store.js';
|
||||
import type { TeaLoginStore } from './tea-login-store.js';
|
||||
import type {
|
||||
CredentialLifecycleOperation,
|
||||
@@ -52,6 +59,137 @@ export interface LifecycleOptions {
|
||||
readonly allowReplace?: boolean;
|
||||
readonly journal?: CredentialAuditJournal;
|
||||
readonly deferSuccessSeal?: boolean;
|
||||
readonly lifecycleLock?: CredentialLifecycleLock;
|
||||
readonly expectedStoreGeneration?: string | null;
|
||||
readonly expectedTeaGeneration?: string | null;
|
||||
}
|
||||
|
||||
export interface CredentialLifecycleLock {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
const activeLifecycleLocks = new WeakSet<CredentialLifecycleLock>();
|
||||
|
||||
/**
|
||||
* This flock coordinates cooperating `mosaic cred` processes only. A process
|
||||
* sharing the Unix uid can replace the pathname/inode, so the lock is never
|
||||
* authorization evidence. Provider authority is the authorization boundary;
|
||||
* generation preconditions are optimistic concurrency for cooperating store
|
||||
* mutators, not atomic CAS against a hostile same-uid filesystem writer.
|
||||
*/
|
||||
export const CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL = Object.freeze({
|
||||
purpose: 'cooperative-serialization',
|
||||
authorizationBoundary: 'provider-authority',
|
||||
generationPreconditions: 'optimistic-cooperating-mutators',
|
||||
hostileSameUidFilesystem: 'out-of-scope-deferred',
|
||||
} as const);
|
||||
|
||||
export class CredentialLifecycleLockError extends Error {
|
||||
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
|
||||
super(code);
|
||||
this.name = 'CredentialLifecycleLockError';
|
||||
}
|
||||
}
|
||||
|
||||
// Do not promote this advisory lock into a same-uid authorization boundary;
|
||||
// CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL is a tested public invariant.
|
||||
export function credentialLifecycleLocksDirectory(): string {
|
||||
return join(homedir(), '.local', 'state', 'mosaic', 'credential-lifecycle-locks');
|
||||
}
|
||||
|
||||
export async function acquireCredentialLifecycleLock(
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): Promise<CredentialLifecycleLock> {
|
||||
if (
|
||||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(identity) ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(estate) ||
|
||||
!/^[a-z0-9][a-z0-9.-]*$/.test(host)
|
||||
) {
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const uid = process.getuid?.();
|
||||
if (uid === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
const home = homedir();
|
||||
const homeDirectory = lstatSync(home);
|
||||
if (
|
||||
!homeDirectory.isDirectory() ||
|
||||
homeDirectory.isSymbolicLink() ||
|
||||
homeDirectory.uid !== uid ||
|
||||
(homeDirectory.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
||||
ensureManagedDirectory(home, locksDirectory);
|
||||
const directory = lstatSync(locksDirectory);
|
||||
if (
|
||||
!directory.isDirectory() ||
|
||||
directory.isSymbolicLink() ||
|
||||
directory.uid !== uid ||
|
||||
(directory.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const lockPath = join(locksDirectory, `${estate}--${host}--${identity}.lock`);
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(
|
||||
lockPath,
|
||||
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const file = await handle.stat();
|
||||
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
|
||||
throw new Error('credential lifecycle lock file is unsafe');
|
||||
}
|
||||
} catch {
|
||||
await handle?.close().catch((): void => undefined);
|
||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
}
|
||||
if (handle === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
||||
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
||||
});
|
||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
||||
await handle.close().catch((): void => undefined);
|
||||
throw new CredentialLifecycleLockError(
|
||||
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
|
||||
);
|
||||
}
|
||||
let released = false;
|
||||
const lock: CredentialLifecycleLock = {
|
||||
identity,
|
||||
estate,
|
||||
host,
|
||||
async release(): Promise<void> {
|
||||
if (released) return;
|
||||
released = true;
|
||||
activeLifecycleLocks.delete(lock);
|
||||
await handle.close();
|
||||
},
|
||||
};
|
||||
activeLifecycleLocks.add(lock);
|
||||
return lock;
|
||||
}
|
||||
|
||||
function holdsCredentialLifecycleLock(
|
||||
lock: CredentialLifecycleLock | undefined,
|
||||
identity: string,
|
||||
estate: string,
|
||||
host: string,
|
||||
): boolean {
|
||||
return (
|
||||
lock !== undefined &&
|
||||
activeLifecycleLocks.has(lock) &&
|
||||
lock.identity === identity &&
|
||||
lock.estate === estate &&
|
||||
lock.host === host
|
||||
);
|
||||
}
|
||||
|
||||
function lifecycleResult(
|
||||
@@ -90,6 +228,35 @@ function lifecycleResult(
|
||||
};
|
||||
}
|
||||
|
||||
async function lifecycleLockFailureResult(
|
||||
operation: 'provision' | 'revoke',
|
||||
request: LifecycleRequest,
|
||||
journal: CredentialAuditJournal,
|
||||
code: 'concurrent-mutation' | 'mutation-lock-unavailable',
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
try {
|
||||
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
|
||||
return lifecycleResult(operation, request, {
|
||||
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
|
||||
mutation: 'none',
|
||||
code,
|
||||
message: 'Credential lifecycle mutation lock could not be acquired.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return lifecycleResult(operation, request, {
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Mutation lock failure could not be sealed durably.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'open',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openLifecycleJournal(
|
||||
operation: 'provision' | 'rotate' | 'revoke',
|
||||
request: LifecycleRequest,
|
||||
@@ -103,8 +270,13 @@ async function openLifecycleJournal(
|
||||
host: request.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent(`${operation}-requested`);
|
||||
return journal;
|
||||
try {
|
||||
await journal.recordIntent(`${operation}-requested`);
|
||||
return journal;
|
||||
} catch (error: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionCredential(
|
||||
@@ -115,22 +287,124 @@ export async function provisionCredential(
|
||||
teaStore: TeaLoginStore,
|
||||
options: LifecycleOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const ownsJournal = options.journal === undefined;
|
||||
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
|
||||
let ownedLock: CredentialLifecycleLock | undefined;
|
||||
if (
|
||||
!holdsCredentialLifecycleLock(
|
||||
options.lifecycleLock,
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
ownedLock = await acquireCredentialLifecycleLock(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
|
||||
return lifecycleLockFailureResult('provision', request, journal, code);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await provisionCredentialLocked(
|
||||
request,
|
||||
authority,
|
||||
provider,
|
||||
store,
|
||||
teaStore,
|
||||
options,
|
||||
journal,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (ownsJournal) await journal.closeIncomplete().catch((): void => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await ownedLock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionCredentialLocked(
|
||||
request: ProvisionRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
options: LifecycleOptions,
|
||||
journal: CredentialAuditJournal,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let minted: MintedToken | undefined;
|
||||
let failureCode = 'mutation-state-unknown';
|
||||
const prior = await store.snapshot(request.identity, request.estate, request.host);
|
||||
if (prior !== undefined && options.allowReplace !== true) {
|
||||
await journal.seal('refused', 'credential-already-exists');
|
||||
let mintedStoreGeneration: string | undefined;
|
||||
let prior: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||
let priorTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
||||
try {
|
||||
prior = await store.snapshot(request.identity, request.estate, request.host);
|
||||
priorTea = teaStore.snapshot(request.identity, request.host);
|
||||
} catch {
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
try {
|
||||
await journal.seal('error', 'credential-snapshot-unavailable');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
code: 'credential-snapshot-unavailable',
|
||||
message: 'Pre-operation credential state could not be snapshotted safely.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'error',
|
||||
mutation: 'none',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Credential snapshot failure could not be sealed durably.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'open',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
(options.expectedStoreGeneration !== undefined &&
|
||||
(prior?.generation ?? null) !== options.expectedStoreGeneration) ||
|
||||
(options.expectedTeaGeneration !== undefined &&
|
||||
(priorTea?.generation ?? null) !== options.expectedTeaGeneration)
|
||||
) {
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
await journal.seal('refused', 'concurrent-mutation');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
code: 'credential-already-exists',
|
||||
message: 'A governed credential already exists; use rotate.',
|
||||
code: 'concurrent-mutation',
|
||||
message: 'Credential generation changed before the lifecycle transaction began.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
if (prior !== undefined && options.allowReplace !== true) {
|
||||
try {
|
||||
await journal.seal('refused', 'credential-already-exists');
|
||||
return lifecycleResult('provision', request, {
|
||||
outcome: 'refused',
|
||||
mutation: 'none',
|
||||
code: 'credential-already-exists',
|
||||
message: 'A governed credential already exists; use rotate.',
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} finally {
|
||||
prior.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const identity = await provider.readBasicIdentity(authority);
|
||||
if (identity.login !== request.identity || authority.identity !== request.identity) {
|
||||
@@ -166,20 +440,19 @@ export async function provisionCredential(
|
||||
failureCode = 'scope-not-evaluable';
|
||||
throw new Error('scope read-back disagreed');
|
||||
}
|
||||
await store.put(
|
||||
{
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
providerLogin: identity.login,
|
||||
tokenName: request.tokenName,
|
||||
scopes: readBack.scopes,
|
||||
createdAt: options.now?.() ?? new Date().toISOString(),
|
||||
},
|
||||
minted.secret,
|
||||
);
|
||||
const mintedBinding = {
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
providerLogin: identity.login,
|
||||
tokenName: request.tokenName,
|
||||
scopes: readBack.scopes,
|
||||
createdAt: options.now?.() ?? new Date().toISOString(),
|
||||
};
|
||||
mintedStoreGeneration = credentialBindingGeneration(mintedBinding, minted.secret);
|
||||
await store.put(mintedBinding, minted.secret, prior?.generation ?? null);
|
||||
await journal.recordMutation('token-binding-stored');
|
||||
await teaStore.put(request.identity, request.host, minted.secret);
|
||||
await teaStore.put(request.identity, request.host, minted.secret, priorTea?.generation ?? null);
|
||||
const teaLogin = teaStore.readBack(request.identity, request.host);
|
||||
if (
|
||||
teaLogin === undefined ||
|
||||
@@ -231,18 +504,67 @@ export async function provisionCredential(
|
||||
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
|
||||
throw new Error('minted token still exists after rollback');
|
||||
}
|
||||
if (prior === undefined) {
|
||||
await store.remove(request.identity, request.estate, request.host);
|
||||
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
|
||||
} else {
|
||||
await store.put(prior.binding, prior.secret);
|
||||
await teaStore.put(request.identity, request.host, prior.secret);
|
||||
const expectedStoreGeneration = prior?.generation ?? null;
|
||||
const current = await store.snapshot(request.identity, request.estate, request.host);
|
||||
try {
|
||||
if ((current?.generation ?? null) !== expectedStoreGeneration) {
|
||||
if (
|
||||
mintedStoreGeneration === undefined ||
|
||||
current?.generation !== mintedStoreGeneration
|
||||
) {
|
||||
throw new Error('credential generation changed during rollback');
|
||||
}
|
||||
if (prior === undefined) {
|
||||
await store.remove(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
mintedStoreGeneration,
|
||||
);
|
||||
} else {
|
||||
await store.put(prior.binding, prior.secret, mintedStoreGeneration);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
const expectedTeaGeneration = priorTea?.generation ?? null;
|
||||
const currentTea = teaStore.snapshot(request.identity, request.host);
|
||||
try {
|
||||
if ((currentTea?.generation ?? null) !== expectedTeaGeneration) {
|
||||
if (
|
||||
currentTea === undefined ||
|
||||
currentTea.secret.byteLength !== minted.secret.byteLength ||
|
||||
!timingSafeEqual(Buffer.from(currentTea.secret), Buffer.from(minted.secret))
|
||||
) {
|
||||
throw new Error('Tea login generation changed during rollback');
|
||||
}
|
||||
await teaStore.restore(request.identity, request.host, priorTea, currentTea.generation);
|
||||
}
|
||||
} finally {
|
||||
currentTea?.secret.fill(0);
|
||||
}
|
||||
const restored = await store.snapshot(request.identity, request.estate, request.host);
|
||||
try {
|
||||
const storeRestored =
|
||||
prior === undefined
|
||||
? restored === undefined
|
||||
: restored !== undefined &&
|
||||
JSON.stringify(restored.binding) === JSON.stringify(prior.binding) &&
|
||||
restored.secret.byteLength === prior.secret.byteLength &&
|
||||
timingSafeEqual(Buffer.from(restored.secret), Buffer.from(prior.secret));
|
||||
if (!storeRestored || !teaStore.matchesSnapshot(request.identity, request.host, priorTea)) {
|
||||
throw new Error('pre-operation credential state was not restored exactly');
|
||||
}
|
||||
} finally {
|
||||
restored?.secret.fill(0);
|
||||
}
|
||||
rollbackComplete = true;
|
||||
} catch {
|
||||
rollbackComplete = false;
|
||||
} finally {
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
}
|
||||
if (journalFailure) {
|
||||
if (rollbackComplete) {
|
||||
@@ -266,6 +588,7 @@ export async function provisionCredential(
|
||||
} finally {
|
||||
minted?.secret.fill(0);
|
||||
prior?.secret.fill(0);
|
||||
priorTea?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,10 +601,48 @@ export async function revokeCredential(
|
||||
options: LifecycleOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const journal = await openLifecycleJournal('revoke', request, options);
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let ownedLock: CredentialLifecycleLock | undefined;
|
||||
if (
|
||||
!holdsCredentialLifecycleLock(
|
||||
options.lifecycleLock,
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
ownedLock = await acquireCredentialLifecycleLock(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
|
||||
return lifecycleLockFailureResult('revoke', request, journal, code);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const binding = await store.readBinding(request.identity, request.estate, request.host);
|
||||
if (binding === undefined) {
|
||||
return await revokeCredentialLocked(request, authority, provider, store, teaStore, journal);
|
||||
} finally {
|
||||
await ownedLock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeCredentialLocked(
|
||||
request: LifecycleRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
journal: CredentialAuditJournal,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let credentialSnapshot: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||
let teaSnapshot: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
||||
try {
|
||||
credentialSnapshot = await store.snapshot(request.identity, request.estate, request.host);
|
||||
if (credentialSnapshot === undefined) {
|
||||
await journal.seal('refused', 'no-token-for-identity');
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'refused',
|
||||
@@ -292,6 +653,8 @@ export async function revokeCredential(
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
const binding = credentialSnapshot.binding;
|
||||
teaSnapshot = teaStore.snapshot(request.identity, request.host);
|
||||
const identity = await provider.readBasicIdentity(authority);
|
||||
if (identity.login !== request.identity || authority.identity !== request.identity) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
@@ -325,12 +688,17 @@ export async function revokeCredential(
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
await teaStore.remove(request.identity, request.host);
|
||||
await teaStore.remove(request.identity, request.host, teaSnapshot?.generation ?? null);
|
||||
if (teaStore.readBack(request.identity, request.host) !== undefined) {
|
||||
throw new Error('Tea login still exists after revocation');
|
||||
}
|
||||
await journal.recordMutation('tea-login-removed');
|
||||
await store.remove(request.identity, request.estate, request.host, binding.tokenDigest);
|
||||
await store.remove(
|
||||
request.identity,
|
||||
request.estate,
|
||||
request.host,
|
||||
credentialSnapshot.generation,
|
||||
);
|
||||
await journal.seal('ok', 'revoke-verified');
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'ok',
|
||||
@@ -342,6 +710,7 @@ export async function revokeCredential(
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialJournalError) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
return lifecycleResult('revoke', request, {
|
||||
outcome: 'indeterminate',
|
||||
mutation,
|
||||
@@ -360,5 +729,8 @@ export async function revokeCredential(
|
||||
journalId: journal.journalId(),
|
||||
auditState: 'sealed',
|
||||
});
|
||||
} finally {
|
||||
credentialSnapshot?.secret.fill(0);
|
||||
teaSnapshot?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { TeaLoginStore } from './tea-login-store.js';
|
||||
import { TeaLoginStore, TeaLoginStoreError } from './tea-login-store.js';
|
||||
|
||||
let root: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
@@ -11,7 +11,7 @@ afterEach(async (): Promise<void> => {
|
||||
});
|
||||
|
||||
describe('host-bound Tea login store', (): void => {
|
||||
it('serializes concurrent updates and preserves the same identity on two hosts', async (): Promise<void> => {
|
||||
it('coordinates concurrent cooperating updates and preserves one identity on two hosts', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
|
||||
await Promise.all([
|
||||
@@ -33,6 +33,141 @@ describe('host-bound Tea login store', (): void => {
|
||||
});
|
||||
});
|
||||
|
||||
it('snapshots and restores the exact host-bound Tea record fields', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('original-token'));
|
||||
const customized = (await readFile(configPath, 'utf8')).replace(
|
||||
'default: false',
|
||||
'default: true\n extension-field: preserved',
|
||||
);
|
||||
await writeFile(configPath, customized, { mode: 0o600 });
|
||||
const snapshot = store.snapshot('seat', 'git.one.invalid');
|
||||
expect(snapshot).toBeDefined();
|
||||
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('replacement-token'));
|
||||
await store.restore('seat', 'git.one.invalid', snapshot);
|
||||
|
||||
expect(store.matchesSnapshot('seat', 'git.one.invalid', snapshot)).toBe(true);
|
||||
expect(await readFile(configPath, 'utf8')).toContain('extension-field: preserved');
|
||||
snapshot?.secret.fill(0);
|
||||
});
|
||||
|
||||
it('keeps generation stable across recursive metadata key reordering', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
const baseline = await readFile(configPath, 'utf8');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'default: false\n extension:\n zebra: last\n __proto__: one\n nested:\n second: 2\n first: 1',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const first = store.snapshot('seat', 'git.one.invalid');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'extension:\n nested:\n first: 1\n second: 2\n __proto__: one\n zebra: last\n default: false',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const reordered = store.snapshot('seat', 'git.one.invalid');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'extension:\n nested:\n first: 9\n second: 2\n zebra: last\n default: false',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const changed = store.snapshot('seat', 'git.one.invalid');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
'extension:\n nested:\n first: 1\n second: 2\n __proto__: two\n zebra: last\n default: false',
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const prototypeNamedFieldChanged = store.snapshot('seat', 'git.one.invalid');
|
||||
|
||||
expect(reordered?.generation).toBe(first?.generation);
|
||||
expect(changed?.generation).not.toBe(first?.generation);
|
||||
expect(prototypeNamedFieldChanged?.generation).not.toBe(first?.generation);
|
||||
first?.secret.fill(0);
|
||||
reordered?.secret.fill(0);
|
||||
changed?.secret.fill(0);
|
||||
prototypeNamedFieldChanged?.secret.fill(0);
|
||||
});
|
||||
|
||||
it.each(['.nan', '.inf', '-.inf'] as const)(
|
||||
'rejects non-finite YAML metadata value %s while accepting explicit null',
|
||||
async (nonFiniteValue): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
const baseline = await readFile(configPath, 'utf8');
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace(
|
||||
'default: false',
|
||||
`default: false\n extension-field: ${nonFiniteValue}`,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrowError(TeaLoginStoreError);
|
||||
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrow(/code=tea-config-invalid/);
|
||||
|
||||
await writeFile(
|
||||
configPath,
|
||||
baseline.replace('default: false', 'default: false\n extension-field: null'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const explicitNull = store.snapshot('seat', 'git.one.invalid');
|
||||
expect(explicitNull).toBeDefined();
|
||||
explicitNull?.secret.fill(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['put', 'remove'] as const)(
|
||||
'removes secret-bearing temporary files when %s fails before rename',
|
||||
async (operation): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
const baseline = new TeaLoginStore(configPath);
|
||||
if (operation === 'remove') {
|
||||
await baseline.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
}
|
||||
const store = new TeaLoginStore(configPath, {
|
||||
beforeRename: async (candidate): Promise<void> => {
|
||||
if (candidate === operation) throw new Error('injected pre-rename failure');
|
||||
},
|
||||
});
|
||||
|
||||
if (operation === 'put') {
|
||||
await expect(
|
||||
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||
).rejects.toThrow('injected pre-rename failure');
|
||||
} else {
|
||||
await expect(store.remove('seat', 'git.one.invalid')).rejects.toThrow(
|
||||
'injected pre-rename failure',
|
||||
);
|
||||
}
|
||||
|
||||
expect((await readdir(join(root, 'tea'))).filter((name) => name.endsWith('.tmp'))).toEqual(
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves unrelated Tea configuration and rejects permissive secret reads', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const configPath = join(root, 'tea', 'config.yml');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { open, rename, unlink } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { parse, stringify } from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
@@ -24,6 +25,7 @@ interface TeaLoginRecord {
|
||||
readonly token: string;
|
||||
readonly user: string;
|
||||
readonly default: boolean;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TeaConfig {
|
||||
@@ -82,10 +84,139 @@ function missing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
export class TeaLoginStore {
|
||||
constructor(private readonly configPath: string) {}
|
||||
export interface TeaLoginSnapshot {
|
||||
readonly fields: Readonly<Record<string, unknown>>;
|
||||
readonly secret: Uint8Array;
|
||||
readonly generation: string;
|
||||
}
|
||||
|
||||
async put(identity: string, host: string, secret: Uint8Array): Promise<void> {
|
||||
type CanonicalJsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| CanonicalJsonValue[]
|
||||
| CanonicalJsonObject;
|
||||
|
||||
interface CanonicalJsonObject {
|
||||
[key: string]: CanonicalJsonValue;
|
||||
}
|
||||
|
||||
function canonicalizeGenerationValue(value: unknown): CanonicalJsonValue {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-config-invalid',
|
||||
'Tea login metadata contains a non-finite number',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry: unknown): CanonicalJsonValue => canonicalizeGenerationValue(entry));
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.sort(([left], [right]): number => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.flatMap(([key, entry]): [string, CanonicalJsonValue][] =>
|
||||
entry === undefined ? [] : [[key, canonicalizeGenerationValue(entry)]],
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-config-invalid',
|
||||
'Tea login metadata is outside canonical JSON values',
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalGenerationJson(value: object): string {
|
||||
return JSON.stringify(canonicalizeGenerationValue(value));
|
||||
}
|
||||
|
||||
function snapshotFromConfig(
|
||||
config: TeaConfig,
|
||||
identity: string,
|
||||
host: string,
|
||||
): TeaLoginSnapshot | undefined {
|
||||
const matches = config.logins.filter(
|
||||
(login): boolean => login.name === loginName(identity, host) && login.url === `https://${host}`,
|
||||
);
|
||||
if (matches.length === 0) return undefined;
|
||||
if (matches.length !== 1 || matches[0] === undefined) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea login binding is ambiguous');
|
||||
}
|
||||
const { token, ...fields } = matches[0];
|
||||
const secret = new TextEncoder().encode(token);
|
||||
const generation = createHash('sha256')
|
||||
.update(canonicalGenerationJson(fields))
|
||||
.update('\0')
|
||||
.update(secret)
|
||||
.digest('hex');
|
||||
return { fields: structuredClone(fields), secret, generation };
|
||||
}
|
||||
|
||||
function assertExpectedGeneration(
|
||||
config: TeaConfig,
|
||||
identity: string,
|
||||
host: string,
|
||||
expectedGeneration: string | null | undefined,
|
||||
): void {
|
||||
if (expectedGeneration === undefined) return;
|
||||
const current = snapshotFromConfig(config, identity, host);
|
||||
try {
|
||||
if ((current?.generation ?? null) !== expectedGeneration) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-generation-mismatch',
|
||||
'Tea login generation changed before mutation',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
current?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeaLoginStoreRuntimeOptions {
|
||||
readonly beforeRename?: (
|
||||
operation: 'put' | 'remove' | 'restore',
|
||||
tempPath: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class TeaLoginStore {
|
||||
constructor(
|
||||
private readonly configPath: string,
|
||||
private readonly runtime: TeaLoginStoreRuntimeOptions = {},
|
||||
) {}
|
||||
|
||||
private async commit(
|
||||
operation: 'put' | 'remove' | 'restore',
|
||||
config: TeaConfig,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify(config), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await this.runtime.beforeRename?.(operation, temp);
|
||||
await rename(temp, this.configPath);
|
||||
await syncDirectory(directory);
|
||||
} finally {
|
||||
await unlink(temp).catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async put(
|
||||
identity: string,
|
||||
host: string,
|
||||
secret: Uint8Array,
|
||||
expectedGeneration?: string | null,
|
||||
): Promise<void> {
|
||||
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
||||
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
|
||||
}
|
||||
@@ -109,6 +240,7 @@ export class TeaLoginStore {
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
}
|
||||
assertExpectedGeneration(current, identity, host, expectedGeneration);
|
||||
const token = Buffer.from(secret).toString('utf8');
|
||||
const record: TeaLoginRecord = {
|
||||
name: loginName(identity, host),
|
||||
@@ -122,16 +254,100 @@ export class TeaLoginStore {
|
||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||
);
|
||||
logins.push(record);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
await this.commit('put', { ...current, logins }, directory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(identity: string, host: string): TeaLoginSnapshot | undefined {
|
||||
const directory = dirname(this.configPath);
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
return snapshotFromConfig(decoded.data, identity, host);
|
||||
}
|
||||
|
||||
matchesSnapshot(identity: string, host: string, expected: TeaLoginSnapshot | undefined): boolean {
|
||||
const actual = this.snapshot(identity, host);
|
||||
try {
|
||||
if (actual === undefined || expected === undefined) return actual === expected;
|
||||
return (
|
||||
isDeepStrictEqual(actual.fields, expected.fields) &&
|
||||
actual.secret.byteLength === expected.secret.byteLength &&
|
||||
timingSafeEqual(Buffer.from(actual.secret), Buffer.from(expected.secret))
|
||||
);
|
||||
} finally {
|
||||
actual?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async restore(
|
||||
identity: string,
|
||||
host: string,
|
||||
snapshot: TeaLoginSnapshot | undefined,
|
||||
expectedGeneration?: string | null,
|
||||
): Promise<void> {
|
||||
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
||||
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
|
||||
}
|
||||
const directory = dirname(this.configPath);
|
||||
ensureManagedDirectory(directory, directory);
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
try {
|
||||
let current: TeaConfig = { logins: [] };
|
||||
try {
|
||||
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
const currentSnapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
assertPrivate(currentSnapshot);
|
||||
const decoded = configSchema.safeParse(parse(currentSnapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
current = decoded.data;
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
if (
|
||||
snapshot === undefined &&
|
||||
(expectedGeneration === undefined || expectedGeneration === null)
|
||||
)
|
||||
return;
|
||||
}
|
||||
await rename(temp, this.configPath);
|
||||
await syncDirectory(directory);
|
||||
assertExpectedGeneration(current, identity, host, expectedGeneration);
|
||||
const logins = current.logins.filter(
|
||||
(login): boolean =>
|
||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||
);
|
||||
if (snapshot !== undefined) {
|
||||
const restored = loginSchema.parse({
|
||||
...structuredClone(snapshot.fields),
|
||||
token: Buffer.from(snapshot.secret).toString('utf8'),
|
||||
});
|
||||
if (restored.name !== loginName(identity, host) || restored.url !== `https://${host}`) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-config-invalid',
|
||||
'Tea snapshot does not match the requested identity and host',
|
||||
);
|
||||
}
|
||||
logins.push(restored);
|
||||
}
|
||||
await this.commit('restore', { ...current, logins }, directory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
@@ -175,34 +391,40 @@ export class TeaLoginStore {
|
||||
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
|
||||
}
|
||||
|
||||
async remove(identity: string, host: string): Promise<void> {
|
||||
async remove(identity: string, host: string, expectedGeneration?: string | null): Promise<void> {
|
||||
const directory = dirname(this.configPath);
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
try {
|
||||
const snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (missing(error)) {
|
||||
if (expectedGeneration !== undefined && expectedGeneration !== null) {
|
||||
throw new TeaLoginStoreError(
|
||||
'tea-generation-mismatch',
|
||||
'Tea login generation changed before removal',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
assertExpectedGeneration(decoded.data, identity, host, expectedGeneration);
|
||||
const logins = decoded.data.logins.filter(
|
||||
(login): boolean =>
|
||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||
);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temp, this.configPath);
|
||||
await syncDirectory(directory);
|
||||
await this.commit('remove', { ...decoded.data, logins }, directory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
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> => {
|
||||
vi.restoreAllMocks();
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
@@ -148,6 +150,317 @@ describe('team repository grant', (): void => {
|
||||
expect(result.evidence.teamRepository?.state).toBe('present');
|
||||
});
|
||||
|
||||
it('durably reports indeterminate when team-lock release cannot be verified', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {},
|
||||
async removeTeamMember(): Promise<void> {},
|
||||
async attachTeamRepository(): Promise<void> {},
|
||||
async detachTeamRepository(): 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 stateRoot = join(cleanup, 'state');
|
||||
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{
|
||||
stateRoot,
|
||||
actor: 'provisioner',
|
||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('injected close failure');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('mutation-lock-release-failed');
|
||||
expect(result.mutation).toBe('applied');
|
||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||
expect(journal).toContain('"outcome":"indeterminate"');
|
||||
expect(journal).toContain('"reasonCode":"mutation-lock-release-failed"');
|
||||
expect(journal).toContain('"decision":"permission-write"');
|
||||
});
|
||||
|
||||
it('never compensates a later cooperating mutation after release when final seal fails', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
let memberPresent = false;
|
||||
let repositoryPresent = false;
|
||||
let memberRemovals = 0;
|
||||
let repositoryDetachments = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
memberPresent = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberRemovals += 1;
|
||||
memberPresent = false;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryDetachments += 1;
|
||||
repositoryPresent = false;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
const originalSeal = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reasonCode,
|
||||
): Promise<string> {
|
||||
if (outcome === 'ok') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected final seal failure');
|
||||
}
|
||||
return originalSeal.call(this, outcome, reasonCode);
|
||||
});
|
||||
|
||||
await expect(
|
||||
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',
|
||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
// Simulate the next cooperating owner establishing the same state
|
||||
// immediately after acquiring the released lock.
|
||||
memberPresent = true;
|
||||
repositoryPresent = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
|
||||
expect(memberRemovals).toBe(0);
|
||||
expect(repositoryDetachments).toBe(0);
|
||||
expect(memberPresent).toBe(true);
|
||||
expect(repositoryPresent).toBe(true);
|
||||
});
|
||||
|
||||
it('never compensates successor state after lock release begins but release and audit sealing fail', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
let memberPresent = false;
|
||||
let repositoryPresent = false;
|
||||
let memberRemovals = 0;
|
||||
let repositoryDetachments = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
memberPresent = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberRemovals += 1;
|
||||
memberPresent = false;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryDetachments += 1;
|
||||
repositoryPresent = false;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
const originalSeal = CredentialAuditJournal.prototype.seal;
|
||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
||||
this: CredentialAuditJournal,
|
||||
outcome,
|
||||
reasonCode,
|
||||
): Promise<string> {
|
||||
if (reasonCode === 'mutation-lock-release-failed') {
|
||||
throw new CredentialJournalError('journal-unavailable', 'injected release audit failure');
|
||||
}
|
||||
return originalSeal.call(this, outcome, reasonCode);
|
||||
});
|
||||
|
||||
await expect(
|
||||
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',
|
||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
memberPresent = true;
|
||||
repositoryPresent = true;
|
||||
throw new Error('injected release failure after successor mutation');
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
|
||||
expect(memberRemovals).toBe(0);
|
||||
expect(repositoryDetachments).toBe(0);
|
||||
expect(memberPresent).toBe(true);
|
||||
expect(repositoryPresent).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a shared team already attached to any repository outside the request', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let mutated = false;
|
||||
|
||||
@@ -77,9 +77,22 @@ export interface GiteaTeamGrantProvider {
|
||||
organization: string,
|
||||
): Promise<OrganizationMembershipEvidenceDto>;
|
||||
}
|
||||
type ReleaseTeamGrantLock = () => Promise<void>;
|
||||
type AcquireTeamGrantLock = (
|
||||
estate: string,
|
||||
host: string,
|
||||
teamId: number,
|
||||
) => Promise<ReleaseTeamGrantLock>;
|
||||
|
||||
export interface TeamGrantOptions {
|
||||
readonly stateRoot: string;
|
||||
readonly actor: string;
|
||||
readonly acquireTeamLock?: AcquireTeamGrantLock;
|
||||
}
|
||||
|
||||
interface TeamGrantFinalization {
|
||||
readonly outcome: 'ok' | 'refused' | 'indeterminate';
|
||||
readonly reasonCode: string;
|
||||
}
|
||||
|
||||
class TeamGrantLockError extends Error {
|
||||
@@ -161,11 +174,30 @@ export async function grantTeamRepositoryPermission(
|
||||
await journal.recordIntent('provider-grant');
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let releaseTeamLock: (() => Promise<void>) | undefined;
|
||||
let teamLockReleaseStarted = false;
|
||||
let rollbackTeam: TeamResolutionEvidence | undefined;
|
||||
let membershipBeforeMutation: PresenceEvidence | undefined;
|
||||
let repositoryAttachedBeforeMutation = false;
|
||||
let memberMutationAttempted = false;
|
||||
let repositoryMutationAttempted = false;
|
||||
const sealFinalVerdict = async (
|
||||
providerOutcome: 'ok' | 'refused' | 'indeterminate',
|
||||
providerReasonCode: string,
|
||||
): Promise<TeamGrantFinalization> => {
|
||||
const release = releaseTeamLock;
|
||||
releaseTeamLock = undefined;
|
||||
if (release !== undefined) {
|
||||
teamLockReleaseStarted = true;
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
await journal.seal('indeterminate', 'mutation-lock-release-failed');
|
||||
return { outcome: 'indeterminate', reasonCode: 'mutation-lock-release-failed' };
|
||||
}
|
||||
}
|
||||
await journal.seal(providerOutcome, providerReasonCode);
|
||||
return { outcome: providerOutcome, reasonCode: providerReasonCode };
|
||||
};
|
||||
try {
|
||||
const authorityIdentity = await provider.readBasicIdentity(authority);
|
||||
const organization = request.repo.split('/')[0] ?? '';
|
||||
@@ -198,7 +230,11 @@ export async function grantTeamRepositoryPermission(
|
||||
decision: `permission-${team.permission}`,
|
||||
});
|
||||
try {
|
||||
releaseTeamLock = await acquireTeamGrantLock(request.estate, request.host, team.id);
|
||||
releaseTeamLock = await (options.acquireTeamLock ?? acquireTeamGrantLock)(
|
||||
request.estate,
|
||||
request.host,
|
||||
team.id,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof TeamGrantLockError)) throw error;
|
||||
await journal.seal('indeterminate', error.code);
|
||||
@@ -223,13 +259,13 @@ export async function grantTeamRepositoryPermission(
|
||||
decision: 'team-repository-set-verified',
|
||||
});
|
||||
if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) {
|
||||
await journal.seal('refused', 'team-scope-exceeds-request');
|
||||
const finalization = await sealFinalVerdict('refused', 'team-scope-exceeds-request');
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'refused',
|
||||
finalization.outcome,
|
||||
'none',
|
||||
'team-scope-exceeds-request',
|
||||
finalization.reasonCode,
|
||||
null,
|
||||
team,
|
||||
null,
|
||||
@@ -306,13 +342,16 @@ export async function grantTeamRepositoryPermission(
|
||||
throw new Error('team repository rollback disagreed');
|
||||
}
|
||||
}
|
||||
await journal.seal('indeterminate', 'team-scope-changed-during-grant');
|
||||
const finalization = await sealFinalVerdict(
|
||||
'indeterminate',
|
||||
'team-scope-changed-during-grant',
|
||||
);
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'indeterminate',
|
||||
finalization.outcome,
|
||||
'applied',
|
||||
'team-scope-changed-during-grant',
|
||||
finalization.reasonCode,
|
||||
null,
|
||||
team,
|
||||
teamMembership,
|
||||
@@ -367,16 +406,16 @@ export async function grantTeamRepositoryPermission(
|
||||
organizationMembership?.state === 'present' &&
|
||||
validation.outcome === 'ok' &&
|
||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
||||
await journal.seal(
|
||||
const finalization = await sealFinalVerdict(
|
||||
ok ? 'ok' : 'indeterminate',
|
||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
||||
);
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
ok ? 'ok' : 'indeterminate',
|
||||
finalization.outcome,
|
||||
'applied',
|
||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
||||
finalization.reasonCode,
|
||||
validation,
|
||||
team,
|
||||
teamMembership,
|
||||
@@ -388,6 +427,7 @@ export async function grantTeamRepositoryPermission(
|
||||
let compensationError: unknown;
|
||||
try {
|
||||
if (
|
||||
!teamLockReleaseStarted &&
|
||||
rollbackTeam !== undefined &&
|
||||
membershipBeforeMutation?.state === 'absent' &&
|
||||
memberMutationAttempted
|
||||
@@ -406,6 +446,7 @@ export async function grantTeamRepositoryPermission(
|
||||
}
|
||||
}
|
||||
if (
|
||||
!teamLockReleaseStarted &&
|
||||
rollbackTeam !== undefined &&
|
||||
!repositoryAttachedBeforeMutation &&
|
||||
repositoryMutationAttempted
|
||||
@@ -442,8 +483,9 @@ export async function grantTeamRepositoryPermission(
|
||||
: mutation === 'applied'
|
||||
? 'readback-missing'
|
||||
: 'mutation-state-unknown';
|
||||
let finalization: Awaited<ReturnType<typeof sealFinalVerdict>>;
|
||||
try {
|
||||
await journal.seal('indeterminate', reasonCode);
|
||||
finalization = await sealFinalVerdict('indeterminate', reasonCode);
|
||||
} catch (journalError: unknown) {
|
||||
if (journalError instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
|
||||
@@ -453,9 +495,9 @@ export async function grantTeamRepositoryPermission(
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'indeterminate',
|
||||
finalization.outcome,
|
||||
mutation,
|
||||
reasonCode,
|
||||
finalization.reasonCode,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@@ -464,9 +506,32 @@ export async function grantTeamRepositoryPermission(
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
// The kernel also releases this advisory lock on process exit. A close
|
||||
// cleanup fault must not contradict an already sealed provider verdict.
|
||||
await releaseTeamLock?.().catch((): void => undefined);
|
||||
const release = releaseTeamLock;
|
||||
releaseTeamLock = undefined;
|
||||
if (release !== undefined) {
|
||||
teamLockReleaseStarted = true;
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
try {
|
||||
await journal.seal('indeterminate', 'mutation-lock-release-failed');
|
||||
} catch (journalError: unknown) {
|
||||
if (journalError instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(
|
||||
journalError.code,
|
||||
mutation,
|
||||
journal.journalId(),
|
||||
);
|
||||
}
|
||||
throw journalError;
|
||||
}
|
||||
throw new CredentialGrantExecutionError(
|
||||
'mutation-lock-release-failed',
|
||||
mutation,
|
||||
journal.journalId(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user