This commit is contained in:
+2
-2
@@ -461,7 +461,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
|
|||||||
10. `CRED-REQ-10`: Operations SHALL return stable machine outcomes `ok`, `refused`, `error`, or `indeterminate`. Policy refusal, local operational failure, and incomplete/inconsistent evidence SHALL remain distinguishable. `provider-unavailable`, `identity-not-measured`, `identity-not-visible`, `identity-not-found`, and `credential-rejected` SHALL remain distinct diagnoses. Validation SHALL report capability from an in-scope probe separately from identity measurement. `/user` 401 is `credential-rejected`/refused; `/user` 403/404 plus successful in-scope capability is `identity-not-measured`, never a dead credential. A returned login mismatch is a binding refusal. No implemented operation may emit `identity-not-found`; that diagnosis requires a separately approved visibility-authorized inventory capability. Security callers SHALL fail closed on every outcome except `ok` without relabelling indeterminate evidence as a denial.
|
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.
|
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. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work.
|
||||||
13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the authoritative fleet environment source/projection so both identity axes survive restart. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names.
|
13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the exact roster-derived `<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.
|
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.
|
15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback.
|
||||||
16. `CRED-REQ-16`: Provider claims SHALL record the estate, instance, endpoint, asserted content type, and decision-relevant object fields. Append-only provider status history SHALL be reduced to latest-per-context where current state is required.
|
16. `CRED-REQ-16`: Provider claims SHALL record the estate, instance, endpoint, asserted content type, and decision-relevant object fields. Append-only provider status history SHALL be reduced to latest-per-context where current state is required.
|
||||||
@@ -475,7 +475,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
|
|||||||
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back.
|
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back.
|
||||||
6. `AC-CRED-06`: Adversarial output/argv tests seed distinct secret values through success, refusal, provider-error, parser-error, rollback, rotate, and revoke paths and find zero secret/partial/fingerprint occurrences in stdout, stderr, logs, audit, and child argv.
|
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, conflicting concurrent mutation, and production-store leakage into fixture tests. Existing canonical per-seat token consumers continue through the governed adapter.
|
||||||
8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the authoritative generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched.
|
8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the exact roster-derived generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. An unauthenticated caller, a caller authenticated as another seat, a caller-selected filename, or a file whose roster identity differs is refused before mutation.
|
||||||
9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes.
|
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.
|
10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing.
|
||||||
11. `AC-CRED-11`: Baseline typecheck/lint/format/tests, focused auth/permission abuse cases, independent code review, independent security review, and terminal-green HOMELAB Woodpecker CI pass on the exact reviewed head.
|
11. `AC-CRED-11`: Baseline typecheck/lint/format/tests, focused auth/permission abuse cases, independent code review, independent security review, and terminal-green HOMELAB Woodpecker CI pass on the exact reviewed head.
|
||||||
|
|||||||
@@ -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`
|
- 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`
|
- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant`
|
||||||
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`
|
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
|
||||||
|
|
||||||
`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds.
|
`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
|
## Grant read-back
|
||||||
|
|
||||||
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant additionally enumerates the team's complete repository attachment set before mutation and refuses any team already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). It then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
|
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant serializes governed mutations per provider team and enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Last updated: 2026-08-05
|
|||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. Interim merge target is `next`; terminal status remains **believed-fixed, pending validation AND pending promotion to `main`**.
|
Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. The trunk-only ruling superseded the original `next` checkpoint: the branch is rebased onto `origin/main` and its PR target is `main`. Linked issues remain **believed-fixed, pending jarvis validation** after merge.
|
||||||
|
|
||||||
## Requirements sources
|
## Requirements sources
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ Deliver the governed `mosaic cred` identity boundary for issue, scope, validatio
|
|||||||
4. Implement `grant` and side-effect-free `validate`; then provision/wire/get/whoami/list/rotate/revoke/audit.
|
4. Implement `grant` and side-effect-free `validate`; then provision/wire/get/whoami/list/rotate/revoke/audit.
|
||||||
5. Make git and API resolver refusals identical and fail closed under fleet context.
|
5. Make git and API resolver refusals identical and fail closed under fleet context.
|
||||||
6. Reconcile live HOMELAB seats through each subject credential's own `/user`; #1044 hold is lifted, and its fail-closed change carries the pre-registered mechanism evidence (resolver refusal marker, same-run marker positive control, confirmed-lane negative arm).
|
6. Reconcile live HOMELAB seats through each subject credential's own `/user`; #1044 hold is lifted, and its fail-closed change carries the pre-registered mechanism evidence (resolver refusal marker, same-run marker positive control, confirmed-lane negative arm).
|
||||||
7. Run baseline/situational tests, independent code review and mandatory independent security review, CI on exact head, then open PR against `next` without closing issues or claiming completion.
|
7. Run baseline/situational tests, independent code review and mandatory independent security review, CI on the exact head, then open the PR directly against `main` without closing issues or claiming Jarvis validation.
|
||||||
8. After C1 merges first, rebase/refresh the base and re-take head-bound CI/provider measurements only.
|
8. C1 merges first. After any base/head move, re-derive merge-base, commit set, diff, CI, reviews, and provider measurements from the replacement SHA.
|
||||||
|
|
||||||
## Budget
|
## Budget
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ No explicit token cap supplied. Working cap: keep implementation in one package
|
|||||||
|
|
||||||
- [x] Mode/intake/core guides/skills/doctrine loaded.
|
- [x] Mode/intake/core guides/skills/doctrine loaded.
|
||||||
- [x] Spec repository READ confirmed under be-coder-06 from provider object.
|
- [x] Spec repository READ confirmed under be-coder-06 from provider object.
|
||||||
- [x] Target-branch completion conflict raised; lead ruled work may proceed to PR/CI on `next` but not completion/closure.
|
- [x] Target-branch conflict resolved by the trunk-only ruling; the lane was rebased from `next` onto `origin/main`.
|
||||||
- [x] Canonical remote PRD v3 addenda re-read at new head.
|
- [x] Canonical remote PRD v3 addenda re-read at new head.
|
||||||
- [x] Required issues read via Mosaic wrapper.
|
- [x] Required issues read via Mosaic wrapper.
|
||||||
- [x] Early grant/validate contract v1 published at `docs/credentials/GRANT-VALIDATE-CONTRACT.md`.
|
- [x] Early grant/validate contract v1 published at `docs/credentials/GRANT-VALIDATE-CONTRACT.md`.
|
||||||
@@ -51,9 +51,10 @@ No explicit token cap supplied. Working cap: keep implementation in one package
|
|||||||
- [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path.
|
- [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path.
|
||||||
- [x] PRD update.
|
- [x] PRD update.
|
||||||
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
|
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
|
||||||
- [ ] Implementation (validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, and fleet fail-closed resolver paths implemented; final review hardening and live provider lifecycle evidence open).
|
- [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.
|
||||||
- [ ] Independent code/security reviews.
|
- [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.
|
||||||
- [ ] CI and provider evidence.
|
- [ ] Independent code/security approvals on the final exact head (Codex advisory iterations are not independent approval).
|
||||||
|
- [ ] Final exact-head CI and provider evidence.
|
||||||
|
|
||||||
## Tests and evidence
|
## Tests and evidence
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ Red-first evidence:
|
|||||||
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
|
- 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.
|
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
|
||||||
|
|
||||||
Current focused evidence: 62/62 across 9 credential suites; package lint and typecheck green. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled.
|
Current focused evidence: 77/77 across 11 credential/command suites; package lint, typecheck, formatting, and build are green. Full package Vitest reached 1,578 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled.
|
||||||
|
|
||||||
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
|
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
|
||||||
|
|
||||||
@@ -78,4 +79,4 @@ Write differential for be-coder-06 passed with the configured read-only control
|
|||||||
- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts.
|
- 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.
|
- 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.
|
- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes.
|
||||||
- Branch model compatibility remains escalated above this lane. Do not claim completion at `next`.
|
- C1 PR #1054 must first be rebuilt from only its four commits on `main`; the retargeted head `8b067839` carries 13 unrelated `next` commits and is not merge-eligible. MC-CRED remains sequenced after the clean C1 merge.
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
mkdtemp,
|
||||||
|
mkdir,
|
||||||
|
open,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
CredentialAuditJournal,
|
||||||
|
CredentialJournalError,
|
||||||
|
listCredentialJournals,
|
||||||
|
} from '../credentials/audit-journal.js';
|
||||||
|
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||||
|
import { FileCredentialStore } from '../credentials/file-credential-store.js';
|
||||||
|
import { executeCredentialRotate, executeCredentialWire } from './cred.js';
|
||||||
|
|
||||||
|
let cleanup: string | undefined;
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||||
|
cleanup = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fixture(): Promise<{
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly registryPath: string;
|
||||||
|
readonly tokenDirectory: string;
|
||||||
|
readonly stateRoot: string;
|
||||||
|
}> {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-command-'));
|
||||||
|
await chmod(cleanup, 0o700);
|
||||||
|
const mosaicHome = join(cleanup, 'mosaic');
|
||||||
|
const credentialDirectory = join(mosaicHome, 'cred');
|
||||||
|
await mkdir(credentialDirectory, { recursive: true, mode: 0o700 });
|
||||||
|
const registryPath = join(credentialDirectory, 'estates.json');
|
||||||
|
await writeFile(
|
||||||
|
registryPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
name: 'homelab',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
provider: 'gitea',
|
||||||
|
apiBaseUrl: 'https://git.example.invalid',
|
||||||
|
tokenPrefix: 'gitea-example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
mosaicHome,
|
||||||
|
registryPath,
|
||||||
|
tokenDirectory: join(mosaicHome, 'secrets', 'gitea-tokens'),
|
||||||
|
stateRoot: join(cleanup, 'state'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('credential lifecycle command controls', (): void => {
|
||||||
|
it('returns the visible open rotation journal when protected authority resolution fails', async (): Promise<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-canary'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await executeCredentialRotate('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'seat-name',
|
||||||
|
authorityFd: '999',
|
||||||
|
tokenName: 'new-generation',
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('error');
|
||||||
|
expect(result.mutation).toBe('none');
|
||||||
|
expect(result.audit.state).toBe('open');
|
||||||
|
expect(result.audit.journalId).not.toBeNull();
|
||||||
|
await expect(listCredentialJournals(paths.stateRoot)).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({ id: result.audit.journalId, state: 'open' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||||
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||||
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||||
|
const before = 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n';
|
||||||
|
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
||||||
|
|
||||||
|
const result = await executeCredentialWire('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'intruder-seat',
|
||||||
|
seatEnv: seatEnvironment,
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('refused');
|
||||||
|
expect(result.mutation).toBe('none');
|
||||||
|
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates the exact seat and rewrites its roster-derived projection idempotently', async (): Promise<void> => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||||
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||||
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||||
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n', {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const authorityPath = join(cleanup!, 'authority.json');
|
||||||
|
await writeFile(
|
||||||
|
authorityPath,
|
||||||
|
JSON.stringify({
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
secret: 'authority-canary',
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
async (): Promise<Response> =>
|
||||||
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoke = async () => {
|
||||||
|
const authority = await open(authorityPath, 'r');
|
||||||
|
try {
|
||||||
|
return await executeCredentialWire('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'seat-name',
|
||||||
|
authorityFd: authority.fd.toString(),
|
||||||
|
seatEnv: seatEnvironment,
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await authority.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await invoke();
|
||||||
|
const afterFirst = await readFile(seatEnvironment, 'utf8');
|
||||||
|
const second = await invoke();
|
||||||
|
const afterSecond = await readFile(seatEnvironment, 'utf8');
|
||||||
|
|
||||||
|
expect(first.outcome).toBe('ok');
|
||||||
|
expect(second.outcome).toBe('ok');
|
||||||
|
expect(afterSecond).toBe(afterFirst);
|
||||||
|
expect(afterSecond).toContain('MOSAIC_GIT_IDENTITY=seat-name\n');
|
||||||
|
expect(afterSecond).toContain('MOSAIC_CREDENTIAL_ESTATE=homelab\n');
|
||||||
|
expect(afterSecond).toContain('GITEA_LOGIN=seat-name--git.example.invalid\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['recordMutation', 'seal'] as const)(
|
||||||
|
'reports an applied wire as indeterminate when audit %s fails after rename',
|
||||||
|
async (method): Promise<void> => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||||
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||||
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||||
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||||
|
const authorityPath = join(cleanup!, 'authority.json');
|
||||||
|
await writeFile(
|
||||||
|
authorityPath,
|
||||||
|
JSON.stringify({
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
secret: 'authority-canary',
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
async (): Promise<Response> =>
|
||||||
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.spyOn(CredentialAuditJournal.prototype, method).mockRejectedValueOnce(
|
||||||
|
new CredentialJournalError('journal-unavailable', 'injected audit failure'),
|
||||||
|
);
|
||||||
|
const authority = await open(authorityPath, 'r');
|
||||||
|
try {
|
||||||
|
const result = await executeCredentialWire('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'seat-name',
|
||||||
|
authorityFd: authority.fd.toString(),
|
||||||
|
seatEnv: seatEnvironment,
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('indeterminate');
|
||||||
|
expect(result.mutation).toBe('applied');
|
||||||
|
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
||||||
|
} finally {
|
||||||
|
await authority.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('refuses to overwrite a roster projection replaced after validation', async (): Promise<void> => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||||
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||||
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||||
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||||
|
const authorityPath = join(cleanup!, 'authority.json');
|
||||||
|
await writeFile(
|
||||||
|
authorityPath,
|
||||||
|
JSON.stringify({
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
secret: 'authority-canary',
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
async (): Promise<Response> =>
|
||||||
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const authority = await open(authorityPath, 'r');
|
||||||
|
try {
|
||||||
|
const result = await executeCredentialWire('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'seat-name',
|
||||||
|
authorityFd: authority.fd.toString(),
|
||||||
|
seatEnv: seatEnvironment,
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
wireBeforeRename: async (): Promise<void> => {
|
||||||
|
const replacement = join(agents, 'replacement');
|
||||||
|
await writeFile(replacement, 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', { mode: 0o600 });
|
||||||
|
await rename(replacement, seatEnvironment);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('error');
|
||||||
|
expect(result.mutation).toBe('none');
|
||||||
|
expect(await readFile(seatEnvironment, 'utf8')).toBe(
|
||||||
|
'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await authority.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports directory-sync failure after rename as applied and indeterminate', async (): Promise<void> => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||||
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||||
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||||
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||||
|
const authorityPath = join(cleanup!, 'authority.json');
|
||||||
|
await writeFile(
|
||||||
|
authorityPath,
|
||||||
|
JSON.stringify({
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
secret: 'authority-canary',
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
async (): Promise<Response> =>
|
||||||
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const authority = await open(authorityPath, 'r');
|
||||||
|
try {
|
||||||
|
const result = await executeCredentialWire('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'seat-name',
|
||||||
|
authorityFd: authority.fd.toString(),
|
||||||
|
seatEnv: seatEnvironment,
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
wireDirectorySync: async (): Promise<void> => {
|
||||||
|
throw new Error('injected directory sync failure');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('indeterminate');
|
||||||
|
expect(result.mutation).toBe('applied');
|
||||||
|
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
||||||
|
} finally {
|
||||||
|
await authority.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a temporary projection when directory revalidation fails before rename', async (): Promise<void> => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||||
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||||
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||||
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||||
|
const authorityPath = join(cleanup!, 'authority.json');
|
||||||
|
await writeFile(
|
||||||
|
authorityPath,
|
||||||
|
JSON.stringify({
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
secret: 'authority-canary',
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', async (): Promise<Response> => {
|
||||||
|
await chmod(agents, 0o777);
|
||||||
|
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const authority = await open(authorityPath, 'r');
|
||||||
|
try {
|
||||||
|
const result = await executeCredentialWire('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'seat-name',
|
||||||
|
authorityFd: authority.fd.toString(),
|
||||||
|
seatEnv: seatEnvironment,
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
});
|
||||||
|
expect(result.outcome).toBe('error');
|
||||||
|
expect(await readdir(agents)).toEqual(['seat-name.env.generated']);
|
||||||
|
} finally {
|
||||||
|
await authority.close();
|
||||||
|
await chmod(agents, 0o700);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a caller-selected seat filename that is not bound to the requested identity', async (): Promise<void> => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||||
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||||
|
const seatEnvironment = join(agents, 'other-seat.env.generated');
|
||||||
|
const before = 'MOSAIC_AGENT_NAME=other-seat\nMOSAIC_AGENT_CLASS=coder\n';
|
||||||
|
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
||||||
|
|
||||||
|
const result = await executeCredentialWire('seat-name', {
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
actor: 'seat-name',
|
||||||
|
authorityFd: '999',
|
||||||
|
seatEnv: seatEnvironment,
|
||||||
|
mosaicHome: paths.mosaicHome,
|
||||||
|
registry: paths.registryPath,
|
||||||
|
tokenDir: paths.tokenDirectory,
|
||||||
|
stateDir: paths.stateRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('refused');
|
||||||
|
expect(result.reason.code).toBe('credential-binding-mismatch');
|
||||||
|
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import { fstatSync, lstatSync, writeSync } from 'node:fs';
|
import { constants, fstatSync, lstatSync, writeSync } from 'node:fs';
|
||||||
import { open, rename } from 'node:fs/promises';
|
import { open, rename, unlink } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
@@ -92,6 +92,8 @@ interface CredentialLifecycleCommandOptions {
|
|||||||
readonly teaConfig?: string;
|
readonly teaConfig?: string;
|
||||||
readonly mosaicHome?: string;
|
readonly mosaicHome?: string;
|
||||||
readonly json?: boolean;
|
readonly json?: boolean;
|
||||||
|
readonly wireBeforeRename?: () => Promise<void>;
|
||||||
|
readonly wireDirectorySync?: (path: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
|
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
|
||||||
@@ -495,6 +497,10 @@ export async function executeCredentialRotate(
|
|||||||
identity: string,
|
identity: string,
|
||||||
options: CredentialLifecycleCommandOptions,
|
options: CredentialLifecycleCommandOptions,
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
): Promise<CredentialLifecycleResultDto> {
|
||||||
|
let journal: CredentialAuditJournal | undefined;
|
||||||
|
let old: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||||
|
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
|
||||||
|
let mutation: CredentialLifecycleResultDto['mutation'] = 'none';
|
||||||
try {
|
try {
|
||||||
if (options.authorityFd === undefined || options.tokenName === undefined) {
|
if (options.authorityFd === undefined || options.tokenName === undefined) {
|
||||||
return localLifecycleResult('rotate', identity, options, {
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
@@ -504,7 +510,7 @@ export async function executeCredentialRotate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const context = await lifecycleContext(options);
|
const context = await lifecycleContext(options);
|
||||||
const journal = await CredentialAuditJournal.open(context.stateRoot, {
|
journal = await CredentialAuditJournal.open(context.stateRoot, {
|
||||||
operation: 'rotate',
|
operation: 'rotate',
|
||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
identity,
|
identity,
|
||||||
@@ -513,25 +519,27 @@ export async function executeCredentialRotate(
|
|||||||
repo: null,
|
repo: null,
|
||||||
});
|
});
|
||||||
await journal.recordIntent('rotate-requested');
|
await journal.recordIntent('rotate-requested');
|
||||||
const old = await context.store.snapshot(identity, options.estate, options.host);
|
old = await context.store.snapshot(identity, options.estate, options.host);
|
||||||
if (old === undefined) {
|
if (old === undefined) {
|
||||||
await journal.seal('refused', 'no-token-for-identity');
|
await journal.seal('refused', 'no-token-for-identity');
|
||||||
return localLifecycleResult('rotate', identity, options, {
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
outcome: 'refused',
|
outcome: 'refused',
|
||||||
code: 'no-token-for-identity',
|
code: 'no-token-for-identity',
|
||||||
message: 'No existing binding can be rotated.',
|
message: 'No existing binding can be rotated.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.tokenName === old.binding.tokenName) {
|
if (options.tokenName === old.binding.tokenName) {
|
||||||
old.secret.fill(0);
|
|
||||||
await journal.seal('refused', 'replacement-token-name-conflict');
|
await journal.seal('refused', 'replacement-token-name-conflict');
|
||||||
return localLifecycleResult('rotate', identity, options, {
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
outcome: 'refused',
|
outcome: 'refused',
|
||||||
code: 'replacement-token-name-conflict',
|
code: 'replacement-token-name-conflict',
|
||||||
message: 'Replacement token name must differ from the active generation.',
|
message: 'Replacement token name must differ from the active generation.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const authority = await lifecycleAuthority(identity, options);
|
authority = await lifecycleAuthority(identity, options);
|
||||||
|
mutation = 'unknown';
|
||||||
const provisioned = await provisionCredential(
|
const provisioned = await provisionCredential(
|
||||||
{
|
{
|
||||||
identity,
|
identity,
|
||||||
@@ -553,13 +561,13 @@ export async function executeCredentialRotate(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (provisioned.outcome !== 'ok') {
|
if (provisioned.outcome !== 'ok') {
|
||||||
old.secret.fill(0);
|
|
||||||
return {
|
return {
|
||||||
...provisioned,
|
...provisioned,
|
||||||
operation: 'rotate',
|
operation: 'rotate',
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
mutation = 'applied';
|
||||||
try {
|
try {
|
||||||
await journal.recordMutation('token-mint-applied');
|
await journal.recordMutation('token-mint-applied');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -569,7 +577,7 @@ export async function executeCredentialRotate(
|
|||||||
}
|
}
|
||||||
await context.store.put(old.binding, old.secret);
|
await context.store.put(old.binding, old.secret);
|
||||||
await context.teaStore.put(identity, options.host, old.secret);
|
await context.teaStore.put(identity, options.host, old.secret);
|
||||||
old.secret.fill(0);
|
mutation = 'none';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -580,7 +588,6 @@ export async function executeCredentialRotate(
|
|||||||
await journal.recordMutation('token-revoke-applied');
|
await journal.recordMutation('token-revoke-applied');
|
||||||
} catch {
|
} catch {
|
||||||
await journal.seal('indeterminate', 'old-credential-state-unknown');
|
await journal.seal('indeterminate', 'old-credential-state-unknown');
|
||||||
old.secret.fill(0);
|
|
||||||
return localLifecycleResult('rotate', identity, options, {
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
outcome: 'indeterminate',
|
outcome: 'indeterminate',
|
||||||
mutation: 'unknown',
|
mutation: 'unknown',
|
||||||
@@ -591,7 +598,6 @@ export async function executeCredentialRotate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await journal.seal('ok', 'rotate-verified');
|
await journal.seal('ok', 'rotate-verified');
|
||||||
old.secret.fill(0);
|
|
||||||
return {
|
return {
|
||||||
...provisioned,
|
...provisioned,
|
||||||
operation: 'rotate',
|
operation: 'rotate',
|
||||||
@@ -601,13 +607,29 @@ export async function executeCredentialRotate(
|
|||||||
},
|
},
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
|
if (journal !== undefined) {
|
||||||
|
await journal.closeIncomplete().catch((): void => undefined);
|
||||||
|
}
|
||||||
|
const outcome = mutation === 'none' ? 'error' : 'indeterminate';
|
||||||
return localLifecycleResult('rotate', identity, options, {
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
outcome: 'indeterminate',
|
outcome,
|
||||||
mutation: 'unknown',
|
mutation,
|
||||||
code: 'mutation-state-unknown',
|
code:
|
||||||
|
error instanceof CredentialJournalError
|
||||||
|
? error.code
|
||||||
|
: mutation === 'none'
|
||||||
|
? 'internal-invariant'
|
||||||
|
: 'mutation-state-unknown',
|
||||||
message: 'Rotation did not establish both new-token acceptance and old-token revocation.',
|
message: 'Rotation did not establish both new-token acceptance and old-token revocation.',
|
||||||
|
audit:
|
||||||
|
journal === undefined
|
||||||
|
? { journalId: null, state: 'not-started' }
|
||||||
|
: { journalId: journal.journalId(), state: 'open' },
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
authority?.secret.fill(0);
|
||||||
|
old?.secret.fill(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,15 +644,31 @@ export async function executeCredentialWire(
|
|||||||
message: 'An explicit seat environment file is required.',
|
message: 'An explicit seat environment file is required.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const locations = lifecycleLocations(options);
|
if (options.actor !== identity) {
|
||||||
const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents');
|
|
||||||
if (dirname(options.seatEnv) !== seatEnvironmentRoot) {
|
|
||||||
return localLifecycleResult('wire', identity, options, {
|
return localLifecycleResult('wire', identity, options, {
|
||||||
outcome: 'refused',
|
outcome: 'refused',
|
||||||
code: 'insecure-credential-destination',
|
code: 'provider-identity-mismatch',
|
||||||
message: 'Seat environment must be directly beneath the governed fleet agent directory.',
|
message: 'Wire authority must be the exact seat being projected.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const locations = lifecycleLocations(options);
|
||||||
|
const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents');
|
||||||
|
const canonicalSeatEnvironment = join(seatEnvironmentRoot, `${identity}.env.generated`);
|
||||||
|
if (options.seatEnv !== canonicalSeatEnvironment) {
|
||||||
|
return localLifecycleResult('wire', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'credential-binding-mismatch',
|
||||||
|
message: 'Seat environment path is not bound to the explicit identity.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.authorityFd === undefined) {
|
||||||
|
return localLifecycleResult('wire', identity, options, {
|
||||||
|
outcome: 'error',
|
||||||
|
code: 'invalid-input',
|
||||||
|
message: 'A protected authority fd is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let context: Awaited<ReturnType<typeof lifecycleContext>>;
|
||||||
try {
|
try {
|
||||||
ensureManagedDirectory(seatEnvironmentRoot, seatEnvironmentRoot);
|
ensureManagedDirectory(seatEnvironmentRoot, seatEnvironmentRoot);
|
||||||
const parent = lstatSync(seatEnvironmentRoot);
|
const parent = lstatSync(seatEnvironmentRoot);
|
||||||
@@ -642,7 +680,7 @@ export async function executeCredentialWire(
|
|||||||
) {
|
) {
|
||||||
throw new Error('seat environment directory is unsafe');
|
throw new Error('seat environment directory is unsafe');
|
||||||
}
|
}
|
||||||
const context = await lifecycleContext(options);
|
context = await lifecycleContext(options);
|
||||||
if (context.registry.resolve(options.estate, options.host) === undefined) {
|
if (context.registry.resolve(options.estate, options.host) === undefined) {
|
||||||
return localLifecycleResult('wire', identity, options, {
|
return localLifecycleResult('wire', identity, options, {
|
||||||
outcome: 'refused',
|
outcome: 'refused',
|
||||||
@@ -658,6 +696,7 @@ export async function executeCredentialWire(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let journal: CredentialAuditJournal | undefined;
|
let journal: CredentialAuditJournal | undefined;
|
||||||
|
let mutation: CredentialLifecycleResultDto['mutation'] = 'none';
|
||||||
try {
|
try {
|
||||||
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||||
operation: 'wire',
|
operation: 'wire',
|
||||||
@@ -668,18 +707,46 @@ export async function executeCredentialWire(
|
|||||||
repo: null,
|
repo: null,
|
||||||
});
|
});
|
||||||
await journal.recordIntent('wire-requested');
|
await journal.recordIntent('wire-requested');
|
||||||
let existing = '';
|
const authority = await lifecycleAuthority(identity, options);
|
||||||
|
let providerIdentity: Awaited<ReturnType<typeof context.provider.readBasicIdentity>>;
|
||||||
try {
|
try {
|
||||||
const snapshot = readRegularFileSecure(options.seatEnv, {
|
providerIdentity = await context.provider.readBasicIdentity(authority);
|
||||||
root: dirname(options.seatEnv),
|
} finally {
|
||||||
maxBytes: 1024 * 1024,
|
authority.secret.fill(0);
|
||||||
|
}
|
||||||
|
if (providerIdentity.login !== identity) {
|
||||||
|
await journal.seal('refused', 'provider-identity-mismatch');
|
||||||
|
return localLifecycleResult('wire', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'provider-identity-mismatch',
|
||||||
|
message: 'Protected authority did not read back as the exact target seat.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: providerIdentity.endpoint,
|
||||||
|
contentType: providerIdentity.contentType,
|
||||||
|
decision: 'identity-verified',
|
||||||
|
});
|
||||||
|
const snapshot = readRegularFileSecure(options.seatEnv, {
|
||||||
|
root: dirname(options.seatEnv),
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
});
|
||||||
|
if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||||
|
throw new Error('seat environment ownership or mode is unsafe');
|
||||||
|
}
|
||||||
|
const existing = snapshot.content.toString('utf8');
|
||||||
|
const rosterBindings = existing
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line): boolean => line.startsWith('MOSAIC_AGENT_NAME='));
|
||||||
|
if (rosterBindings.length !== 1 || rosterBindings[0] !== `MOSAIC_AGENT_NAME=${identity}`) {
|
||||||
|
await journal.seal('refused', 'credential-binding-mismatch');
|
||||||
|
return localLifecycleResult('wire', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'credential-binding-mismatch',
|
||||||
|
message: 'Roster-derived seat projection did not bind the requested identity.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
});
|
});
|
||||||
if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) {
|
|
||||||
throw new Error('seat environment ownership or mode is unsafe');
|
|
||||||
}
|
|
||||||
existing = snapshot.content.toString('utf8');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
|
||||||
}
|
}
|
||||||
const lines = existing
|
const lines = existing
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@@ -697,22 +764,54 @@ export async function executeCredentialWire(
|
|||||||
const parentBefore = lstatSync(seatEnvironmentRoot);
|
const parentBefore = lstatSync(seatEnvironmentRoot);
|
||||||
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
const handle = await open(temp, 'wx', 0o600);
|
||||||
|
let renamed = false;
|
||||||
try {
|
try {
|
||||||
await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8');
|
try {
|
||||||
await handle.sync();
|
await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8');
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
await options.wireBeforeRename?.();
|
||||||
|
const parentAfter = lstatSync(seatEnvironmentRoot);
|
||||||
|
if (
|
||||||
|
parentAfter.dev !== parentBefore.dev ||
|
||||||
|
parentAfter.ino !== parentBefore.ino ||
|
||||||
|
parentAfter.uid !== process.getuid?.() ||
|
||||||
|
(parentAfter.mode & 0o022) !== 0
|
||||||
|
) {
|
||||||
|
throw new Error('seat environment directory changed during mutation');
|
||||||
|
}
|
||||||
|
const targetBeforeRename = readRegularFileSecure(options.seatEnv, {
|
||||||
|
root: dirname(options.seatEnv),
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (targetBeforeRename.dev !== snapshot.dev || targetBeforeRename.ino !== snapshot.ino) {
|
||||||
|
throw new Error('seat environment changed during mutation');
|
||||||
|
}
|
||||||
|
await rename(temp, options.seatEnv);
|
||||||
|
renamed = true;
|
||||||
|
mutation = 'applied';
|
||||||
|
if (options.wireDirectorySync !== undefined) {
|
||||||
|
await options.wireDirectorySync(seatEnvironmentRoot);
|
||||||
|
} else {
|
||||||
|
const directory = await open(
|
||||||
|
seatEnvironmentRoot,
|
||||||
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const synced = await directory.stat();
|
||||||
|
if (synced.dev !== parentBefore.dev || synced.ino !== parentBefore.ino) {
|
||||||
|
throw new Error('seat environment directory changed before durable sync');
|
||||||
|
}
|
||||||
|
await directory.sync();
|
||||||
|
} finally {
|
||||||
|
await directory.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
if (!renamed) await unlink(temp).catch((): void => undefined);
|
||||||
}
|
}
|
||||||
const parentAfter = lstatSync(seatEnvironmentRoot);
|
|
||||||
if (
|
|
||||||
parentAfter.dev !== parentBefore.dev ||
|
|
||||||
parentAfter.ino !== parentBefore.ino ||
|
|
||||||
parentAfter.uid !== process.getuid?.() ||
|
|
||||||
(parentAfter.mode & 0o022) !== 0
|
|
||||||
) {
|
|
||||||
throw new Error('seat environment directory changed during mutation');
|
|
||||||
}
|
|
||||||
await rename(temp, options.seatEnv);
|
|
||||||
await journal.recordMutation('wire-applied');
|
await journal.recordMutation('wire-applied');
|
||||||
await journal.seal('ok', 'wire-verified');
|
await journal.seal('ok', 'wire-verified');
|
||||||
return localLifecycleResult('wire', identity, options, {
|
return localLifecycleResult('wire', identity, options, {
|
||||||
@@ -730,20 +829,25 @@ export async function executeCredentialWire(
|
|||||||
message: 'Seat environment journal could not be opened durably.',
|
message: 'Seat environment journal could not be opened durably.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const outcome = mutation === 'applied' ? 'indeterminate' : 'error';
|
||||||
|
const reason = mutation === 'applied' ? 'wire-audit-incomplete' : 'wire-failed';
|
||||||
try {
|
try {
|
||||||
await journal.seal('error', 'wire-failed');
|
await journal.seal(outcome, reason);
|
||||||
return localLifecycleResult('wire', identity, options, {
|
return localLifecycleResult('wire', identity, options, {
|
||||||
outcome: 'error',
|
outcome,
|
||||||
mutation: 'unknown',
|
mutation,
|
||||||
code: 'wire-failed',
|
code: reason,
|
||||||
message: 'Seat environment wiring failed.',
|
message:
|
||||||
|
mutation === 'applied'
|
||||||
|
? 'Seat environment changed, but complete audit persistence was not established.'
|
||||||
|
: 'Seat environment wiring failed before mutation.',
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
});
|
});
|
||||||
} catch (sealError: unknown) {
|
} catch (sealError: unknown) {
|
||||||
return localLifecycleResult('wire', identity, options, {
|
return localLifecycleResult('wire', identity, options, {
|
||||||
outcome: 'error',
|
outcome,
|
||||||
mutation: 'unknown',
|
mutation,
|
||||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'wire-failed',
|
code: sealError instanceof CredentialJournalError ? sealError.code : reason,
|
||||||
message: 'Seat environment audit could not be sealed.',
|
message: 'Seat environment audit could not be sealed.',
|
||||||
audit: { journalId: journal.journalId(), state: 'open' },
|
audit: { journalId: journal.journalId(), state: 'open' },
|
||||||
});
|
});
|
||||||
@@ -1035,8 +1139,9 @@ export function registerCredentialCommand(parent: Command): void {
|
|||||||
.description('Idempotently wire both explicit fleet identity axes into a seat environment')
|
.description('Idempotently wire both explicit fleet identity axes into a seat environment')
|
||||||
.requiredOption('--estate <estate>', 'Explicit target estate')
|
.requiredOption('--estate <estate>', 'Explicit target estate')
|
||||||
.requiredOption('--host <host>', 'Explicit provider host')
|
.requiredOption('--host <host>', 'Explicit provider host')
|
||||||
.requiredOption('--actor <identity>', 'Explicit audit actor')
|
.requiredOption('--actor <identity>', 'Exact seat identity authorized to wire itself')
|
||||||
.requiredOption('--seat-env <path>', 'Explicit seat environment file')
|
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
|
||||||
|
.requiredOption('--seat-env <path>', 'Exact roster-derived <identity>.env.generated file')
|
||||||
.option('--state-dir <path>', 'Durable credential journal root')
|
.option('--state-dir <path>', 'Durable credential journal root')
|
||||||
.option('--json', 'Emit one machine result object')
|
.option('--json', 'Emit one machine result object')
|
||||||
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
|
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
|
||||||
|
|||||||
@@ -40,13 +40,17 @@ const SAFE_DECISIONS = new Set<string>([
|
|||||||
'get-requested',
|
'get-requested',
|
||||||
'validation-verified',
|
'validation-verified',
|
||||||
'team-member-present',
|
'team-member-present',
|
||||||
|
'team-member-absent',
|
||||||
'team-repository-present',
|
'team-repository-present',
|
||||||
|
'team-repository-absent',
|
||||||
'team-repository-set-verified',
|
'team-repository-set-verified',
|
||||||
'organization-member-present',
|
'organization-member-present',
|
||||||
'organization-member-absent',
|
'organization-member-absent',
|
||||||
'collaborator-grant-applied',
|
'collaborator-grant-applied',
|
||||||
'team-member-applied',
|
'team-member-applied',
|
||||||
|
'team-member-rollback-applied',
|
||||||
'team-repository-applied',
|
'team-repository-applied',
|
||||||
|
'team-repository-rollback-applied',
|
||||||
'transport-write-verified',
|
'transport-write-verified',
|
||||||
'token-mint-applied',
|
'token-mint-applied',
|
||||||
'token-binding-stored',
|
'token-binding-stored',
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ describe('Gitea credential provider transport', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reads team permission, member attachment, and repository attachment separately', async (): Promise<void> => {
|
it('reads team permission, member attachment, and repository attachment separately', async (): Promise<void> => {
|
||||||
|
let memberRemoved = false;
|
||||||
|
let repositoryDetached = false;
|
||||||
const adapter = new GiteaTeamGrantProviderAdapter(
|
const adapter = new GiteaTeamGrantProviderAdapter(
|
||||||
'https://git.example.invalid',
|
'https://git.example.invalid',
|
||||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||||
@@ -191,6 +193,11 @@ describe('Gitea credential provider transport', (): void => {
|
|||||||
return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]);
|
return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]);
|
||||||
}
|
}
|
||||||
if (init?.method === 'PUT') return new Response(null, { status: 204 });
|
if (init?.method === 'PUT') return new Response(null, { status: 204 });
|
||||||
|
if (init?.method === 'DELETE') {
|
||||||
|
if (url.includes('/members/')) memberRemoved = true;
|
||||||
|
if (url.includes('/repos/')) repositoryDetached = true;
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
if (url.includes('/members/seat-name')) {
|
if (url.includes('/members/seat-name')) {
|
||||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
return jsonResponse({ id: 21, login: 'seat-name' });
|
||||||
}
|
}
|
||||||
@@ -214,6 +221,10 @@ describe('Gitea credential provider transport', (): void => {
|
|||||||
await expect(
|
await expect(
|
||||||
adapter.readTeamRepository(credential, team.id, 'owner/repo'),
|
adapter.readTeamRepository(credential, team.id, 'owner/repo'),
|
||||||
).resolves.toMatchObject({ state: 'present' });
|
).resolves.toMatchObject({ state: 'present' });
|
||||||
|
await adapter.removeTeamMember(credential, team.id, 'seat-name');
|
||||||
|
await adapter.detachTeamRepository(credential, team.id, 'owner/repo');
|
||||||
|
expect(memberRemoved).toBe(true);
|
||||||
|
expect(repositoryDetached).toBe(true);
|
||||||
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
|
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -626,6 +626,31 @@ export class GiteaTeamGrantProviderAdapter
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeTeamMember(
|
||||||
|
authority: ResolvedCredential,
|
||||||
|
teamId: number,
|
||||||
|
identity: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await this.request(
|
||||||
|
`${this.origin}/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: JSON_CONTENT_TYPE,
|
||||||
|
Authorization: basicAuthorization(authority),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await boundedBody(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new CredentialProviderEvidenceError(
|
||||||
|
'provider-unavailable',
|
||||||
|
'team member rollback failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async attachTeamRepository(
|
async attachTeamRepository(
|
||||||
authority: ResolvedCredential,
|
authority: ResolvedCredential,
|
||||||
teamId: number,
|
teamId: number,
|
||||||
@@ -635,6 +660,32 @@ export class GiteaTeamGrantProviderAdapter
|
|||||||
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
|
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async detachTeamRepository(
|
||||||
|
authority: ResolvedCredential,
|
||||||
|
teamId: number,
|
||||||
|
repo: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { owner, name } = repoPath(repo);
|
||||||
|
const response = await this.request(
|
||||||
|
`${this.origin}/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: JSON_CONTENT_TYPE,
|
||||||
|
Authorization: basicAuthorization(authority),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await boundedBody(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new CredentialProviderEvidenceError(
|
||||||
|
'provider-unavailable',
|
||||||
|
'team repository rollback failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
|
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
|
||||||
const response = await this.request(`${this.origin}${path}`, {
|
const response = await this.request(`${this.origin}${path}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
@@ -73,6 +73,7 @@ function validation(): CredentialValidationDependencies {
|
|||||||
describe('team repository grant', (): void => {
|
describe('team repository grant', (): void => {
|
||||||
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||||
|
let repositoryReads = 0;
|
||||||
const provider: GiteaTeamGrantProvider = {
|
const provider: GiteaTeamGrantProvider = {
|
||||||
async readBasicIdentity() {
|
async readBasicIdentity() {
|
||||||
return {
|
return {
|
||||||
@@ -91,14 +92,17 @@ describe('team repository grant', (): void => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async listTeamRepositories() {
|
async listTeamRepositories() {
|
||||||
|
repositoryReads += 1;
|
||||||
return {
|
return {
|
||||||
repositories: [],
|
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
endpoint: 'GET /api/v1/teams/7/repos',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async addTeamMember(): Promise<void> {},
|
async addTeamMember(): Promise<void> {},
|
||||||
|
async removeTeamMember(): Promise<void> {},
|
||||||
async attachTeamRepository(): Promise<void> {},
|
async attachTeamRepository(): Promise<void> {},
|
||||||
|
async detachTeamRepository(): Promise<void> {},
|
||||||
async readTeamMember() {
|
async readTeamMember() {
|
||||||
return {
|
return {
|
||||||
state: 'present',
|
state: 'present',
|
||||||
@@ -174,9 +178,15 @@ describe('team repository grant', (): void => {
|
|||||||
async addTeamMember(): Promise<void> {
|
async addTeamMember(): Promise<void> {
|
||||||
mutated = true;
|
mutated = true;
|
||||||
},
|
},
|
||||||
|
async removeTeamMember(): Promise<void> {
|
||||||
|
mutated = true;
|
||||||
|
},
|
||||||
async attachTeamRepository(): Promise<void> {
|
async attachTeamRepository(): Promise<void> {
|
||||||
mutated = true;
|
mutated = true;
|
||||||
},
|
},
|
||||||
|
async detachTeamRepository(): Promise<void> {
|
||||||
|
mutated = true;
|
||||||
|
},
|
||||||
async readTeamMember() {
|
async readTeamMember() {
|
||||||
return {
|
return {
|
||||||
state: 'absent',
|
state: 'absent',
|
||||||
@@ -220,4 +230,384 @@ describe('team repository grant', (): void => {
|
|||||||
expect(result.reason.code).toBe('team-scope-exceeds-request');
|
expect(result.reason.code).toBe('team-scope-exceeds-request');
|
||||||
expect(mutated).toBe(false);
|
expect(mutated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('journals absent team objects as absent rather than present', 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: 'absent',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readTeamRepository() {
|
||||||
|
return {
|
||||||
|
state: 'absent',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readOrganizationMembership() {
|
||||||
|
return {
|
||||||
|
state: 'present',
|
||||||
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateRoot = join(cleanup, 'state');
|
||||||
|
const result = await grantTeamRepositoryPermission(
|
||||||
|
{
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
repo: 'owner/repo',
|
||||||
|
permission: 'write',
|
||||||
|
team: 'writers',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
},
|
||||||
|
authority,
|
||||||
|
provider,
|
||||||
|
validation(),
|
||||||
|
{ stateRoot, actor: 'provisioner' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('indeterminate');
|
||||||
|
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||||
|
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||||
|
expect(journal).toContain('team-member-absent');
|
||||||
|
expect(journal).toContain('team-repository-absent');
|
||||||
|
expect(journal).not.toContain('"decision":"team-member-present"');
|
||||||
|
expect(journal).not.toContain('"decision":"team-repository-present"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed and removes newly added membership when team scope changes during mutation', async (): Promise<void> => {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||||
|
let repositoryReads = 0;
|
||||||
|
let membershipReads = 0;
|
||||||
|
let removed = false;
|
||||||
|
let detached = false;
|
||||||
|
const provider: GiteaTeamGrantProvider = {
|
||||||
|
async readBasicIdentity() {
|
||||||
|
return {
|
||||||
|
login: 'provisioner',
|
||||||
|
endpoint: 'GET /api/v1/user',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async resolveTeam() {
|
||||||
|
return {
|
||||||
|
id: 7,
|
||||||
|
name: 'writers',
|
||||||
|
permission: 'write',
|
||||||
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async listTeamRepositories() {
|
||||||
|
repositoryReads += 1;
|
||||||
|
return {
|
||||||
|
repositories: repositoryReads === 1 ? [] : ['owner/repo', 'owner/concurrent-attachment'],
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async addTeamMember(): Promise<void> {},
|
||||||
|
async removeTeamMember(): Promise<void> {
|
||||||
|
removed = true;
|
||||||
|
},
|
||||||
|
async attachTeamRepository(): Promise<void> {},
|
||||||
|
async detachTeamRepository(): Promise<void> {
|
||||||
|
detached = true;
|
||||||
|
},
|
||||||
|
async readTeamMember() {
|
||||||
|
membershipReads += 1;
|
||||||
|
return {
|
||||||
|
state: membershipReads === 1 || removed ? 'absent' : 'present',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readTeamRepository() {
|
||||||
|
return {
|
||||||
|
state: detached ? 'absent' : 'present',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readOrganizationMembership() {
|
||||||
|
return {
|
||||||
|
state: 'present',
|
||||||
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateRoot = join(cleanup, 'state');
|
||||||
|
const result = await grantTeamRepositoryPermission(
|
||||||
|
{
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
repo: 'owner/repo',
|
||||||
|
permission: 'write',
|
||||||
|
team: 'writers',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
},
|
||||||
|
authority,
|
||||||
|
provider,
|
||||||
|
validation(),
|
||||||
|
{ stateRoot, actor: 'provisioner' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('indeterminate');
|
||||||
|
expect(result.reason.code).toBe('team-scope-changed-during-grant');
|
||||||
|
expect(result.evidence.teamRepositorySet?.repositories).toEqual([
|
||||||
|
'owner/repo',
|
||||||
|
'owner/concurrent-attachment',
|
||||||
|
]);
|
||||||
|
expect(removed).toBe(true);
|
||||||
|
expect(detached).toBe(true);
|
||||||
|
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||||
|
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||||
|
expect(journal.indexOf('team-member-absent')).toBeLessThan(
|
||||||
|
journal.indexOf('team-member-applied'),
|
||||||
|
);
|
||||||
|
expect(journal).toContain('team-repository-rollback-applied');
|
||||||
|
expect(journal).toContain('team-repository-absent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compensates provider changes when repository attachment fails after applying', async (): Promise<void> => {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||||
|
let memberPresent = false;
|
||||||
|
let repositoryPresent = false;
|
||||||
|
let memberRemoved = false;
|
||||||
|
let repositoryDetached = false;
|
||||||
|
const provider: GiteaTeamGrantProvider = {
|
||||||
|
async readBasicIdentity() {
|
||||||
|
return {
|
||||||
|
login: 'provisioner',
|
||||||
|
endpoint: 'GET /api/v1/user',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async resolveTeam() {
|
||||||
|
return {
|
||||||
|
id: 7,
|
||||||
|
name: 'writers',
|
||||||
|
permission: 'write',
|
||||||
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async listTeamRepositories() {
|
||||||
|
return {
|
||||||
|
repositories: repositoryPresent ? ['owner/repo'] : [],
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async addTeamMember(): Promise<void> {
|
||||||
|
memberPresent = true;
|
||||||
|
},
|
||||||
|
async removeTeamMember(): Promise<void> {
|
||||||
|
memberPresent = false;
|
||||||
|
memberRemoved = true;
|
||||||
|
},
|
||||||
|
async attachTeamRepository(): Promise<void> {
|
||||||
|
repositoryPresent = true;
|
||||||
|
throw new Error('provider response lost after attachment');
|
||||||
|
},
|
||||||
|
async detachTeamRepository(): Promise<void> {
|
||||||
|
repositoryPresent = false;
|
||||||
|
repositoryDetached = true;
|
||||||
|
},
|
||||||
|
async readTeamMember() {
|
||||||
|
return {
|
||||||
|
state: memberPresent ? 'present' : 'absent',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readTeamRepository() {
|
||||||
|
return {
|
||||||
|
state: repositoryPresent ? 'present' : 'absent',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readOrganizationMembership() {
|
||||||
|
return {
|
||||||
|
state: 'present',
|
||||||
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await grantTeamRepositoryPermission(
|
||||||
|
{
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
repo: 'owner/repo',
|
||||||
|
permission: 'write',
|
||||||
|
team: 'writers',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
},
|
||||||
|
authority,
|
||||||
|
provider,
|
||||||
|
validation(),
|
||||||
|
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('indeterminate');
|
||||||
|
expect(memberPresent).toBe(false);
|
||||||
|
expect(repositoryPresent).toBe(false);
|
||||||
|
expect(memberRemoved).toBe(true);
|
||||||
|
expect(repositoryDetached).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a second governed mutation while the same team lock is held', async (): Promise<void> => {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||||
|
let releaseFirst!: () => void;
|
||||||
|
const firstMayFinish = new Promise<void>((resolve): void => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
});
|
||||||
|
let markFirstEntered!: () => void;
|
||||||
|
const firstEntered = new Promise<void>((resolve): void => {
|
||||||
|
markFirstEntered = resolve;
|
||||||
|
});
|
||||||
|
let addCalls = 0;
|
||||||
|
let memberAdded = false;
|
||||||
|
let repositoryAttached = false;
|
||||||
|
const provider: GiteaTeamGrantProvider = {
|
||||||
|
async readBasicIdentity() {
|
||||||
|
return {
|
||||||
|
login: 'provisioner',
|
||||||
|
endpoint: 'GET /api/v1/user',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async resolveTeam() {
|
||||||
|
return {
|
||||||
|
id: 7,
|
||||||
|
name: 'writers',
|
||||||
|
permission: 'write',
|
||||||
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async listTeamRepositories() {
|
||||||
|
return {
|
||||||
|
repositories: repositoryAttached ? ['owner/repo'] : [],
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async addTeamMember(): Promise<void> {
|
||||||
|
addCalls += 1;
|
||||||
|
markFirstEntered();
|
||||||
|
await firstMayFinish;
|
||||||
|
memberAdded = true;
|
||||||
|
},
|
||||||
|
async removeTeamMember(): Promise<void> {
|
||||||
|
memberAdded = false;
|
||||||
|
},
|
||||||
|
async attachTeamRepository(): Promise<void> {
|
||||||
|
repositoryAttached = true;
|
||||||
|
},
|
||||||
|
async detachTeamRepository(): Promise<void> {
|
||||||
|
repositoryAttached = false;
|
||||||
|
},
|
||||||
|
async readTeamMember() {
|
||||||
|
return {
|
||||||
|
state: memberAdded ? 'present' : 'absent',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readTeamRepository() {
|
||||||
|
return {
|
||||||
|
state: repositoryAttached ? 'present' : 'absent',
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readOrganizationMembership() {
|
||||||
|
return {
|
||||||
|
state: 'present',
|
||||||
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const request = {
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
repo: 'owner/repo',
|
||||||
|
permission: 'write' as const,
|
||||||
|
team: 'writers',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
};
|
||||||
|
const options = { stateRoot: join(cleanup, 'state-one'), actor: 'provisioner' };
|
||||||
|
const secondOptions = { stateRoot: join(cleanup, 'state-two'), actor: 'provisioner' };
|
||||||
|
|
||||||
|
const first = grantTeamRepositoryPermission(
|
||||||
|
request,
|
||||||
|
authority,
|
||||||
|
provider,
|
||||||
|
validation(),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
await firstEntered;
|
||||||
|
const second = await grantTeamRepositoryPermission(
|
||||||
|
request,
|
||||||
|
authority,
|
||||||
|
provider,
|
||||||
|
validation(),
|
||||||
|
secondOptions,
|
||||||
|
);
|
||||||
|
releaseFirst();
|
||||||
|
const completedFirst = await first;
|
||||||
|
|
||||||
|
expect(completedFirst.outcome).toBe('ok');
|
||||||
|
expect(second.outcome).toBe('indeterminate');
|
||||||
|
expect(second.reason.code).toBe('concurrent-mutation');
|
||||||
|
expect(second.mutation).toBe('none');
|
||||||
|
expect(addCalls).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { constants, lstatSync } from 'node:fs';
|
||||||
|
import { open } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ensureManagedDirectory } from '../fleet/secure-file.js';
|
||||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||||
import type {
|
import type {
|
||||||
CredentialValidationDependencies,
|
CredentialValidationDependencies,
|
||||||
@@ -54,7 +59,9 @@ export interface GiteaTeamGrantProvider {
|
|||||||
teamId: number,
|
teamId: number,
|
||||||
): Promise<TeamRepositorySetEvidence>;
|
): Promise<TeamRepositorySetEvidence>;
|
||||||
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
||||||
|
removeTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
||||||
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
||||||
|
detachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
||||||
readTeamMember(
|
readTeamMember(
|
||||||
authority: ResolvedCredential,
|
authority: ResolvedCredential,
|
||||||
teamId: number,
|
teamId: number,
|
||||||
@@ -75,6 +82,67 @@ export interface TeamGrantOptions {
|
|||||||
readonly actor: string;
|
readonly actor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TeamGrantLockError extends Error {
|
||||||
|
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireTeamGrantLock(
|
||||||
|
estate: string,
|
||||||
|
host: string,
|
||||||
|
teamId: number,
|
||||||
|
): Promise<() => Promise<void>> {
|
||||||
|
const uid = process.getuid?.();
|
||||||
|
if (uid === undefined) throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||||
|
const locksDirectory = `/tmp/mosaic-cred-team-locks-${uid.toString()}`;
|
||||||
|
ensureManagedDirectory(locksDirectory, locksDirectory);
|
||||||
|
const directory = lstatSync(locksDirectory);
|
||||||
|
if (
|
||||||
|
!directory.isDirectory() ||
|
||||||
|
directory.isSymbolicLink() ||
|
||||||
|
directory.uid !== uid ||
|
||||||
|
(directory.mode & 0o077) !== 0
|
||||||
|
) {
|
||||||
|
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||||
|
}
|
||||||
|
const lockPath = join(locksDirectory, `${estate}--${host}--team-${teamId.toString()}.lock`);
|
||||||
|
let handle: Awaited<ReturnType<typeof open>>;
|
||||||
|
try {
|
||||||
|
handle = await open(
|
||||||
|
lockPath,
|
||||||
|
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const file = await handle.stat();
|
||||||
|
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
|
||||||
|
throw new Error('team mutation lock file is unsafe');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await handle.close().catch((): void => undefined);
|
||||||
|
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||||
|
}
|
||||||
|
// The child's fd 3 is a dup of the parent's open file description. Linux
|
||||||
|
// flock(2) associates the lock with that description, so it remains held
|
||||||
|
// after the helper exits until this process closes `handle` below.
|
||||||
|
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
||||||
|
});
|
||||||
|
if (acquired.error !== undefined || acquired.status !== 0) {
|
||||||
|
await handle.close().catch((): void => undefined);
|
||||||
|
throw new TeamGrantLockError(
|
||||||
|
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await handle.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function grantTeamRepositoryPermission(
|
export async function grantTeamRepositoryPermission(
|
||||||
request: TeamGrantRequest,
|
request: TeamGrantRequest,
|
||||||
authority: ResolvedCredential,
|
authority: ResolvedCredential,
|
||||||
@@ -92,10 +160,17 @@ export async function grantTeamRepositoryPermission(
|
|||||||
});
|
});
|
||||||
await journal.recordIntent('provider-grant');
|
await journal.recordIntent('provider-grant');
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||||
|
let releaseTeamLock: (() => Promise<void>) | undefined;
|
||||||
|
let rollbackTeam: TeamResolutionEvidence | undefined;
|
||||||
|
let membershipBeforeMutation: PresenceEvidence | undefined;
|
||||||
|
let repositoryAttachedBeforeMutation = false;
|
||||||
|
let memberMutationAttempted = false;
|
||||||
|
let repositoryMutationAttempted = false;
|
||||||
try {
|
try {
|
||||||
const authorityIdentity = await provider.readBasicIdentity(authority);
|
const authorityIdentity = await provider.readBasicIdentity(authority);
|
||||||
const organization = request.repo.split('/')[0] ?? '';
|
const organization = request.repo.split('/')[0] ?? '';
|
||||||
const team = await provider.resolveTeam(authority, organization, request.team);
|
const team = await provider.resolveTeam(authority, organization, request.team);
|
||||||
|
rollbackTeam = team;
|
||||||
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
await journal.seal('refused', 'provider-identity-mismatch');
|
||||||
return result(
|
return result(
|
||||||
@@ -122,6 +197,25 @@ export async function grantTeamRepositoryPermission(
|
|||||||
contentType: team.contentType,
|
contentType: team.contentType,
|
||||||
decision: `permission-${team.permission}`,
|
decision: `permission-${team.permission}`,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
releaseTeamLock = await acquireTeamGrantLock(request.estate, request.host, team.id);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof TeamGrantLockError)) throw error;
|
||||||
|
await journal.seal('indeterminate', error.code);
|
||||||
|
return result(
|
||||||
|
request,
|
||||||
|
journal,
|
||||||
|
'indeterminate',
|
||||||
|
'none',
|
||||||
|
error.code,
|
||||||
|
null,
|
||||||
|
team,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
const teamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
const teamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
||||||
await journal.recordProviderEvidence({
|
await journal.recordProviderEvidence({
|
||||||
endpoint: teamRepositorySet.endpoint,
|
endpoint: teamRepositorySet.endpoint,
|
||||||
@@ -144,14 +238,89 @@ export async function grantTeamRepositoryPermission(
|
|||||||
teamRepositorySet,
|
teamRepositorySet,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const repositoryAttachedBefore = teamRepositorySet.repositories.includes(request.repo);
|
||||||
|
repositoryAttachedBeforeMutation = repositoryAttachedBefore;
|
||||||
|
const membershipBefore = await provider.readTeamMember(authority, team.id, request.identity);
|
||||||
|
membershipBeforeMutation = membershipBefore;
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: membershipBefore.endpoint,
|
||||||
|
contentType: membershipBefore.contentType,
|
||||||
|
decision: membershipBefore.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||||
|
});
|
||||||
mutation = 'unknown';
|
mutation = 'unknown';
|
||||||
|
memberMutationAttempted = true;
|
||||||
await provider.addTeamMember(authority, team.id, request.identity);
|
await provider.addTeamMember(authority, team.id, request.identity);
|
||||||
mutation = 'applied';
|
mutation = 'applied';
|
||||||
await journal.recordMutation('team-member-applied');
|
await journal.recordMutation('team-member-applied');
|
||||||
|
repositoryMutationAttempted = true;
|
||||||
await provider.attachTeamRepository(authority, team.id, request.repo);
|
await provider.attachTeamRepository(authority, team.id, request.repo);
|
||||||
await journal.recordMutation('team-repository-applied');
|
await journal.recordMutation('team-repository-applied');
|
||||||
const teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
let teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
||||||
const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
let teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||||
|
const finalTeamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: teamMembership.endpoint,
|
||||||
|
contentType: teamMembership.contentType,
|
||||||
|
decision: teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||||
|
});
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: teamRepository.endpoint,
|
||||||
|
contentType: teamRepository.contentType,
|
||||||
|
decision:
|
||||||
|
teamRepository.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
||||||
|
});
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: finalTeamRepositorySet.endpoint,
|
||||||
|
contentType: finalTeamRepositorySet.contentType,
|
||||||
|
decision: 'team-repository-set-verified',
|
||||||
|
});
|
||||||
|
const scopeRemainedExact =
|
||||||
|
finalTeamRepositorySet.repositories.length === 1 &&
|
||||||
|
finalTeamRepositorySet.repositories[0] === request.repo;
|
||||||
|
if (!scopeRemainedExact) {
|
||||||
|
if (membershipBefore.state === 'absent') {
|
||||||
|
await provider.removeTeamMember(authority, team.id, request.identity);
|
||||||
|
await journal.recordMutation('team-member-rollback-applied');
|
||||||
|
teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: teamMembership.endpoint,
|
||||||
|
contentType: teamMembership.contentType,
|
||||||
|
decision:
|
||||||
|
teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||||
|
});
|
||||||
|
if (teamMembership.state !== 'absent') throw new Error('team member rollback disagreed');
|
||||||
|
}
|
||||||
|
if (!repositoryAttachedBefore) {
|
||||||
|
await provider.detachTeamRepository(authority, team.id, request.repo);
|
||||||
|
await journal.recordMutation('team-repository-rollback-applied');
|
||||||
|
teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: teamRepository.endpoint,
|
||||||
|
contentType: teamRepository.contentType,
|
||||||
|
decision:
|
||||||
|
teamRepository.state === 'present'
|
||||||
|
? 'team-repository-present'
|
||||||
|
: 'team-repository-absent',
|
||||||
|
});
|
||||||
|
if (teamRepository.state !== 'absent') {
|
||||||
|
throw new Error('team repository rollback disagreed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await journal.seal('indeterminate', 'team-scope-changed-during-grant');
|
||||||
|
return result(
|
||||||
|
request,
|
||||||
|
journal,
|
||||||
|
'indeterminate',
|
||||||
|
'applied',
|
||||||
|
'team-scope-changed-during-grant',
|
||||||
|
null,
|
||||||
|
team,
|
||||||
|
teamMembership,
|
||||||
|
teamRepository,
|
||||||
|
null,
|
||||||
|
finalTeamRepositorySet,
|
||||||
|
);
|
||||||
|
}
|
||||||
const subject = await dependencies.resolver.resolve(
|
const subject = await dependencies.resolver.resolve(
|
||||||
request.identity,
|
request.identity,
|
||||||
request.estate,
|
request.estate,
|
||||||
@@ -198,16 +367,6 @@ export async function grantTeamRepositoryPermission(
|
|||||||
organizationMembership?.state === 'present' &&
|
organizationMembership?.state === 'present' &&
|
||||||
validation.outcome === 'ok' &&
|
validation.outcome === 'ok' &&
|
||||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
validation.evidence.repositoryPermission?.effective === request.permission;
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamMembership.endpoint,
|
|
||||||
contentType: teamMembership.contentType,
|
|
||||||
decision: 'team-member-present',
|
|
||||||
});
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamRepository.endpoint,
|
|
||||||
contentType: teamRepository.contentType,
|
|
||||||
decision: 'team-repository-present',
|
|
||||||
});
|
|
||||||
await journal.seal(
|
await journal.seal(
|
||||||
ok ? 'ok' : 'indeterminate',
|
ok ? 'ok' : 'indeterminate',
|
||||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
||||||
@@ -223,13 +382,66 @@ export async function grantTeamRepositoryPermission(
|
|||||||
teamMembership,
|
teamMembership,
|
||||||
teamRepository,
|
teamRepository,
|
||||||
organizationMembership,
|
organizationMembership,
|
||||||
teamRepositorySet,
|
finalTeamRepositorySet,
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof CredentialJournalError) {
|
let compensationError: unknown;
|
||||||
throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId());
|
try {
|
||||||
|
if (
|
||||||
|
rollbackTeam !== undefined &&
|
||||||
|
membershipBeforeMutation?.state === 'absent' &&
|
||||||
|
memberMutationAttempted
|
||||||
|
) {
|
||||||
|
let current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
||||||
|
if (current.state === 'present') {
|
||||||
|
await provider.removeTeamMember(authority, rollbackTeam.id, request.identity);
|
||||||
|
await journal.recordMutation('team-member-rollback-applied');
|
||||||
|
current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: current.endpoint,
|
||||||
|
contentType: current.contentType,
|
||||||
|
decision: current.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||||
|
});
|
||||||
|
if (current.state !== 'absent') throw new Error('team member rollback disagreed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rollbackTeam !== undefined &&
|
||||||
|
!repositoryAttachedBeforeMutation &&
|
||||||
|
repositoryMutationAttempted
|
||||||
|
) {
|
||||||
|
let current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||||
|
if (current.state === 'present') {
|
||||||
|
await provider.detachTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||||
|
await journal.recordMutation('team-repository-rollback-applied');
|
||||||
|
current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||||
|
await journal.recordProviderEvidence({
|
||||||
|
endpoint: current.endpoint,
|
||||||
|
contentType: current.contentType,
|
||||||
|
decision:
|
||||||
|
current.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
||||||
|
});
|
||||||
|
if (current.state !== 'absent') throw new Error('team repository rollback disagreed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (rollbackError: unknown) {
|
||||||
|
compensationError = rollbackError;
|
||||||
}
|
}
|
||||||
const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown';
|
const auditError =
|
||||||
|
compensationError instanceof CredentialJournalError
|
||||||
|
? compensationError
|
||||||
|
: error instanceof CredentialJournalError
|
||||||
|
? error
|
||||||
|
: undefined;
|
||||||
|
if (auditError !== undefined) {
|
||||||
|
throw new CredentialGrantExecutionError(auditError.code, mutation, journal.journalId());
|
||||||
|
}
|
||||||
|
const reasonCode =
|
||||||
|
compensationError !== undefined
|
||||||
|
? 'rollback-incomplete'
|
||||||
|
: mutation === 'applied'
|
||||||
|
? 'readback-missing'
|
||||||
|
: 'mutation-state-unknown';
|
||||||
try {
|
try {
|
||||||
await journal.seal('indeterminate', reasonCode);
|
await journal.seal('indeterminate', reasonCode);
|
||||||
} catch (journalError: unknown) {
|
} catch (journalError: unknown) {
|
||||||
@@ -251,6 +463,10 @@ export async function grantTeamRepositoryPermission(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
// The kernel also releases this advisory lock on process exit. A close
|
||||||
|
// cleanup fault must not contradict an already sealed provider verdict.
|
||||||
|
await releaseTeamLock?.().catch((): void => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user