fix(cred): close durable audit review blockers
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
2026-08-05 19:52:33 -05:00
parent 12958610cb
commit 7056ff6973
13 changed files with 519 additions and 31 deletions
+1 -1
View File
@@ -469,7 +469,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
### Acceptance criteria
1. `AC-CRED-01`: Red-first tests prove unset identity, missing token, wrong estate, wrong host, wrong Tea login, and out-of-estate identity produce the same structured refusal class/reason on git and API resolution, with no shared credential read and no provider mutation.
2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes register atomically, exact token scopes are read back from the provider token object, and rollback reads back exact pre-operation provider/token-store/Tea state. Injected Tea cleanup failure returns `indeterminate`/`rollback-incomplete` and cannot claim mutation `none`; a second cooperating same-identity transaction is refused across caller-selected state roots. A code-level security-model assertion pins that advisory flock as cooperative serialization, provider authority as the authorization boundary, generation preconditions as optimistic protection for cooperating mutators, and hostile same-UID filesystem mutation as explicitly deferred.
2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes are established together or every completed provider/token-store/Tea change is compensated and read back to the exact pre-operation state, and exact token scopes are read back from the provider token object. Injected Tea cleanup failure returns `indeterminate`/`rollback-incomplete` and cannot claim mutation `none`; no cross-system atomic commit is claimed. A second cooperating same-identity transaction is refused across caller-selected state roots. A code-level security-model assertion pins that advisory flock as cooperative serialization, provider authority as the authorization boundary, generation preconditions as optimistic protection for cooperating mutators rather than atomic CAS, and hostile same-UID filesystem mutation as explicitly deferred.
3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope and repo grant cases cannot return `ok`; organization/team membership and team-repository attachment are additionally acceptance-bearing for team grants. A direct collaborator grant reports organization membership but does not require it, because direct collaborator permission and organization membership are intentionally independent provider layers.
4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, or provider outage returns `indeterminate`, never success or policy refusal. Runtime exact scope is reported independently as `not-measured` when the current seat credential is not authorized to read its provider token object; NOT-MEASURED is neither pass nor failure and does not erase confirmed repository capability. Exact scope is acceptance-bearing at provision/rotate time, where delegated mint authority can read the token object.
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back. Protected credential output fault injection covers partial write, post-write append, and seal failure and preserves possibly-issued/applied truth in both DTO and durable journal.
+2 -2
View File
@@ -171,7 +171,7 @@ Stable v1 codes:
- refusal: `identity-required`, `estate-required`, `estate-host-mismatch`, `cross-estate-resolution`, `no-token-for-identity`, `tea-login-missing`, `tea-login-host-mismatch`, `provider-identity-mismatch`, `credential-rejected`, `permission-denied`, `organization-membership-required`, `team-membership-required`
- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant`
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `mutation-lock-release-failed`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds.
@@ -201,4 +201,4 @@ No ref is updated and no repository artifact is created. This proves that the de
## Grant read-back
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant serializes governed mutations per provider team and enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant uses a same-UID-replaceable advisory lock to serialize cooperating `mosaic cred` mutations per provider team; this is optimistic coordination, not an authorization boundary or atomic CAS against direct filesystem mutation. Provider authority remains the authorization boundary. While holding that cooperative lock, the grant enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; this is explicit all-or-verified-compensation behavior, not a cross-system atomic commit, and the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
+25 -1
View File
@@ -2,6 +2,20 @@
Last updated: 2026-08-05
## Successor remediation (`be-coder-07`, PR #1059 review id 89)
Objective: close the four exact-head `rev-974` blockers without weakening any assertion, repair canonical-image test portability, and refresh the narrowed cooperative-concurrency claims and PR metadata. The provider-fetched starting head is `12958610cbafaa54a3db95327a7c3453d9111669` at merge-base `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`.
Plan:
1. Preserve the journal-lock release assertion while replacing Alpine-invalid `/usr/bin/true` with canonical `/bin/true`.
2. Replace cross-system atomicity claims with exact all-or-verified-compensation semantics; retain atomic wording only for explicitly scoped single-file rename/replacement primitives and qualify team locking as cooperative advisory serialization.
3. Add red-first controls for complete journal writes under one-byte progress, rejection of zero/invalid progress, Tea generation equivalence under recursive key reorder, and durable indeterminate classification when team-lock release cannot be verified.
4. Implement bounded journal write-all, recursive canonical generation serialization, and release-before-seal team-lock cleanup classification; then run focused and baseline gates plus code/security advisories.
5. Commit with explicit `be-coder-07` identity, rebase rather than merge onto current `origin/main`, prove stable patch identity, run the required queue/direct-CI guards, and make one force-with-lease push pinned to the measured starting head.
Budget: hard context ceiling is 60%. Reuse predecessor evidence and avoid re-deriving unrelated 41-file history. Non-blocking secret zeroization is included only after all blockers are green. Stop and write a seam/report before the ceiling.
## Objective
Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. The trunk-only ruling superseded the original `next` checkpoint: the branch is rebased onto `origin/main` and its PR target is `main`. Linked issues remain **believed-fixed, pending jarvis validation** after merge.
@@ -69,7 +83,7 @@ Red-first evidence:
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
Pre-remediation focused evidence: 80/80 across 11 credential/command suites; package lint, typecheck, formatting, and build were green. The advisory remediation adds thirty-five focused regression cases for rollback cleanup, Tea temporary-file cleanup, unsupported-host passthrough, and credential issuance partial-write/post-write-audit truthfulness; focused reruns are green (latest credential/command set: 109/109). Final uncommitted author advisories report code `approve` with no findings and security `none` with no findings; they remain advisory rather than independent approval. Full package Vitest reached 1,614 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and use atomic file replacement for cooperating mutators; grant mutation/read-back state is journaled.
Pre-remediation focused evidence: 80/80 across 11 credential/command suites; package lint, typecheck, formatting, and build were green. The advisory remediation adds thirty-five focused regression cases for rollback cleanup, Tea temporary-file cleanup, unsupported-host passthrough, and credential issuance partial-write/post-write-audit truthfulness; focused reruns are green (latest credential/command set: 109/109). Final uncommitted author advisories report code `approve` with no findings and security `none` with no findings; they remain advisory rather than independent approval. Full package Vitest reached 1,614 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and use atomic rename only for each explicitly scoped single-file replacement primitive; cross-system lifecycle completion uses verified compensation rather than an atomic-commit claim; grant mutation/read-back state is journaled.
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
@@ -77,6 +91,16 @@ Live validation v1.4 (subject credential's own `/user`, no admin): population 13
Write differential for be-coder-06 passed with the configured read-only control and unauthenticated arm. Unit evidence proves the control arm invalidates validation when write-capable, identity-mismatched, or receive-pack-admitted.
## Successor review-remediation evidence
- Canonical-image CI portability: `/usr/bin/true` was changed only to `/bin/true`; the second-process `flock` acquisition assertion remains `status === 0` and passes in the focused lifecycle suite.
- Required red phase: five exact finding controls failed before implementation (journal one-byte/zero/oversized progress, recursively reordered Tea metadata, and lock-release failure). Review remediation added a sixth red control proving final-seal failure could compensate a later cooperating team mutation after lock release; a prototype-named Tea metadata control also failed before canonical object construction was hardened.
- Focused final: 120/120 passed across all 11 credential and credential-command files.
- Package lint and typecheck passed; dependency-aware workspace build passed 13/13 packages; repository format check and `git diff --check` passed; both git credential resolver shell regressions passed.
- Full local package Vitest: 1,624 passed / 3 failed. The failures are the same installed-version update banner emitted to stderr by three `cli-smoke.spec.ts` cases; prior canonical pipeline #2223 ran all 22 CLI-smoke cases green. Exact-head canonical CI remains required after push.
- First uncommitted advisories found a real post-release compensation race. Its red control observed one removal after lock release; the fix forbids compensation after successful release, and the control then passed without weakening an assertion.
- Final uncommitted advisories: code `approve` with no findings; security risk `none` with no findings. Codex could not execute tests in its read-only sandbox (EROFS), so these verdicts rely on review only; the writable author runs above are the test evidence and independent reviewers remain required.
## Risks/blockers
- Hostile same-UID direct filesystem mutation is outside phase-1 and requires a transactional service, broker/distinct identity, or equivalent non-bypassable primitive; lifecycle flock and generation preconditions cover cooperating `mosaic cred` mutators only. Track the explicit security deferral linked from PR #1059.
+19 -5
View File
@@ -266,6 +266,7 @@ export async function executeCredentialGrant(
process.env['MOSAIC_GITEA_TOKEN_DIR'] ??
join(mosaicHome, 'secrets', 'gitea-tokens');
const stateRoot = options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred');
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
if (
!['read', 'write', 'admin'].includes(options.permission) ||
!['collaborator', 'team'].includes(options.via) ||
@@ -286,7 +287,7 @@ export async function executeCredentialGrant(
};
}
const fd = Number(options.authorityFd);
const authority = await readDelegatedCredentialFromFd(
authority = await readDelegatedCredentialFromFd(
fd,
options.actor,
options.estate,
@@ -332,6 +333,8 @@ export async function executeCredentialGrant(
return grantErrorResult(identity, options, error.code);
}
return grantErrorResult(identity, options, 'internal-invariant');
} finally {
authority?.secret.fill(0);
}
}
@@ -422,6 +425,7 @@ export async function executeCredentialProvision(
identity: string,
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
try {
if (options.authorityFd === undefined || options.tokenName === undefined) {
return localLifecycleResult('provision', identity, options, {
@@ -439,7 +443,7 @@ export async function executeCredentialProvision(
});
}
const context = await lifecycleContext(options);
const authority = await lifecycleAuthority(identity, options);
authority = await lifecycleAuthority(identity, options);
return await provisionCredential(
{
identity,
@@ -466,6 +470,8 @@ export async function executeCredentialProvision(
code,
message: 'Provisioning control failed locally.',
});
} finally {
authority?.secret.fill(0);
}
}
@@ -473,6 +479,7 @@ export async function executeCredentialRevoke(
identity: string,
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
try {
if (options.authorityFd === undefined) {
return localLifecycleResult('revoke', identity, options, {
@@ -482,7 +489,7 @@ export async function executeCredentialRevoke(
});
}
const context = await lifecycleContext(options);
const authority = await lifecycleAuthority(identity, options);
authority = await lifecycleAuthority(identity, options);
return await revokeCredential(
{ identity, estate: options.estate, host: options.host },
authority,
@@ -497,6 +504,8 @@ export async function executeCredentialRevoke(
code: 'internal-invariant',
message: 'Revocation control failed locally.',
});
} finally {
authority?.secret.fill(0);
}
}
@@ -925,6 +934,8 @@ export async function executeCredentialGet(
): Promise<CredentialLifecycleResultDto> {
const locations = lifecycleLocations(options);
let journal: CredentialAuditJournal | undefined;
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
let resolved: Awaited<ReturnType<FileCredentialResolver['resolve']>> = undefined;
let disclosureStarted = false;
let disclosureCompleted = false;
try {
@@ -957,7 +968,7 @@ export async function executeCredentialGet(
throw new Error('unsafe output fd');
}
const context = await lifecycleContext(options);
const authority = await lifecycleAuthority(identity, options);
authority = await lifecycleAuthority(identity, options);
const providerIdentity = await context.provider.readIdentity(authority);
if (providerIdentity.login !== identity) {
await journal.seal('refused', 'provider-identity-mismatch');
@@ -969,7 +980,7 @@ export async function executeCredentialGet(
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
const resolved = await new FileCredentialResolver(
resolved = await new FileCredentialResolver(
lifecycleLocations(options).tokenDirectory,
context.registry,
).resolve(identity, options.estate, options.host);
@@ -1074,6 +1085,9 @@ export async function executeCredentialGet(
audit: { journalId: journal.journalId(), state: 'open' },
});
}
} finally {
authority?.secret.fill(0);
resolved?.secret.fill(0);
}
}
@@ -1,3 +1,5 @@
import type { FileHandle } from 'node:fs/promises';
export type CredentialJournalOperation =
| 'provision'
| 'wire'
@@ -50,6 +52,12 @@ export interface CredentialJournalRuntimeOptionsDto {
readonly now?: () => string;
readonly syncDirectory?: (path: string) => Promise<void>;
readonly rename?: (source: string, destination: string) => Promise<void>;
readonly write?: (
handle: FileHandle,
data: Uint8Array,
offset: number,
length: number,
) => Promise<number>;
}
export interface CredentialJournalSummaryDto {
@@ -60,6 +60,68 @@ describe('credential durable audit journal', (): void => {
expect(records[3]).toContain('"phase":"sealed"');
});
it('writes every journal record completely when each write makes one-byte progress', async (): Promise<void> => {
const root = await stateRoot();
let writeCalls = 0;
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'grant',
actor: 'provisioner',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
},
{
id: 'short-write',
now: (): string => '2026-08-05T00:00:00.000Z',
write: async (handle, data, offset, length): Promise<number> => {
writeCalls += 1;
const result = await handle.write(data, offset, Math.min(1, length), null);
return result.bytesWritten;
},
},
);
await journal.recordIntent('provider-grant');
const sealedPath = await journal.seal('ok', 'grant-verified');
const records = (await readFile(sealedPath, 'utf8')).trim().split('\n');
expect(writeCalls).toBeGreaterThan(3);
expect(records).toHaveLength(3);
expect(records[0]).toContain('"phase":"opened"');
expect(records[1]).toContain('"phase":"intent"');
expect(records[2]).toContain('"phase":"sealed"');
});
it.each([
['zero', (_remaining: number): number => 0],
['oversized', (remaining: number): number => remaining + 1],
] as const)(
'rejects %s journal write progress before reporting an opened journal',
async (_label, progress): Promise<void> => {
const root = await stateRoot();
await expect(
CredentialAuditJournal.open(
root,
{
operation: 'grant',
actor: 'provisioner',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
},
{
id: `invalid-progress-${_label}`,
write: async (_handle, _data, _offset, length): Promise<number> => progress(length),
},
),
).rejects.toThrow(/journal-unavailable/);
},
);
it('keeps a final seal non-accepting while directory durability is pending', async (): Promise<void> => {
const root = await stateRoot();
let directorySyncs = 0;
@@ -122,6 +122,16 @@ function assertEvidence(evidence: CredentialProviderJournalEvidenceDto): void {
}
}
async function writeJournalBytes(
handle: FileHandle,
data: Uint8Array,
offset: number,
length: number,
): Promise<number> {
const result = await handle.write(data, offset, length, null);
return result.bytesWritten;
}
async function syncDirectory(path: string): Promise<void> {
const directory = await open(path, 'r');
try {
@@ -168,6 +178,12 @@ export class CredentialAuditJournal {
private readonly now: () => string,
private readonly syncJournalDirectory: (path: string) => Promise<void>,
private readonly renameJournal: (source: string, destination: string) => Promise<void>,
private readonly writeJournal: (
handle: FileHandle,
data: Uint8Array,
offset: number,
length: number,
) => Promise<number>,
) {}
static async open(
@@ -212,6 +228,7 @@ export class CredentialAuditJournal {
now,
syncJournalDirectory,
renameJournal,
runtime.write ?? writeJournalBytes,
);
await journal.append({ phase: 'opened', at: now(), context });
await syncJournalDirectory(journalsDirectory);
@@ -232,7 +249,16 @@ export class CredentialAuditJournal {
throw new CredentialJournalError('journal-unavailable', 'journal is already closed');
}
try {
await this.handle.write(`${JSON.stringify(record)}\n`);
const data = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
let offset = 0;
while (offset < data.byteLength) {
const remaining = data.byteLength - offset;
const written = await this.writeJournal(this.handle, data, offset, remaining);
if (!Number.isSafeInteger(written) || written <= 0 || written > remaining) {
throw new Error('journal write made invalid progress');
}
offset += written;
}
await this.handle.sync();
} catch {
throw new CredentialJournalError(
@@ -102,7 +102,7 @@ describe('phase-1 governed file credential resolver', (): void => {
);
});
it('atomically stores, lists, reads binding metadata, and removes a governed credential', async (): Promise<void> => {
it('stores one governed envelope, lists and reads it, then removes all credential artifacts', async (): Promise<void> => {
const root = await fixtureRoot();
const store = new FileCredentialStore(root, registry());
await store.put(
@@ -605,7 +605,7 @@ describe('credential lifecycle', (): void => {
});
if (result.audit.journalId === null) throw new Error('journal id was not returned');
const lockPath = join(root, 'journal-locks', `${result.audit.journalId}.lock`);
const lockProbe = spawnSync('/usr/bin/flock', ['-n', lockPath, '/usr/bin/true']);
const lockProbe = spawnSync('/usr/bin/flock', ['-n', lockPath, '/bin/true']);
expect(lockProbe.status).toBe(0);
} finally {
CredentialAuditJournal.prototype.recordProviderEvidence = recordProviderEvidence;
@@ -11,7 +11,7 @@ afterEach(async (): Promise<void> => {
});
describe('host-bound Tea login store', (): void => {
it('serializes concurrent updates and preserves the same identity on two hosts', async (): Promise<void> => {
it('coordinates concurrent cooperating updates and preserves one identity on two hosts', async (): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
await Promise.all([
@@ -54,6 +54,58 @@ describe('host-bound Tea login store', (): void => {
snapshot?.secret.fill(0);
});
it('keeps generation stable across recursive metadata key reordering', async (): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const configPath = join(root, 'tea', 'config.yml');
const store = new TeaLoginStore(configPath);
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
const baseline = await readFile(configPath, 'utf8');
await writeFile(
configPath,
baseline.replace(
'default: false',
'default: false\n extension:\n zebra: last\n __proto__: one\n nested:\n second: 2\n first: 1',
),
{ mode: 0o600 },
);
const first = store.snapshot('seat', 'git.one.invalid');
await writeFile(
configPath,
baseline.replace(
'default: false',
'extension:\n nested:\n first: 1\n second: 2\n __proto__: one\n zebra: last\n default: false',
),
{ mode: 0o600 },
);
const reordered = store.snapshot('seat', 'git.one.invalid');
await writeFile(
configPath,
baseline.replace(
'default: false',
'extension:\n nested:\n first: 9\n second: 2\n zebra: last\n default: false',
),
{ mode: 0o600 },
);
const changed = store.snapshot('seat', 'git.one.invalid');
await writeFile(
configPath,
baseline.replace(
'default: false',
'extension:\n nested:\n first: 1\n second: 2\n __proto__: two\n zebra: last\n default: false',
),
{ mode: 0o600 },
);
const prototypeNamedFieldChanged = store.snapshot('seat', 'git.one.invalid');
expect(reordered?.generation).toBe(first?.generation);
expect(changed?.generation).not.toBe(first?.generation);
expect(prototypeNamedFieldChanged?.generation).not.toBe(first?.generation);
first?.secret.fill(0);
reordered?.secret.fill(0);
changed?.secret.fill(0);
prototypeNamedFieldChanged?.secret.fill(0);
});
it.each(['put', 'remove'] as const)(
'removes secret-bearing temporary files when %s fails before rename',
async (operation): Promise<void> => {
@@ -90,6 +90,42 @@ export interface TeaLoginSnapshot {
readonly generation: string;
}
type CanonicalJsonValue =
| string
| number
| boolean
| null
| CanonicalJsonValue[]
| CanonicalJsonObject;
interface CanonicalJsonObject {
[key: string]: CanonicalJsonValue;
}
function canonicalizeGenerationValue(value: unknown): CanonicalJsonValue {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (Array.isArray(value)) {
return value.map((entry: unknown): CanonicalJsonValue => canonicalizeGenerationValue(entry));
}
if (typeof value === 'object') {
const entries = Object.entries(value)
.sort(([left], [right]): number => (left < right ? -1 : left > right ? 1 : 0))
.flatMap(([key, entry]): [string, CanonicalJsonValue][] =>
entry === undefined ? [] : [[key, canonicalizeGenerationValue(entry)]],
);
return Object.fromEntries(entries);
}
throw new TeaLoginStoreError(
'tea-config-invalid',
'Tea login metadata is outside canonical JSON values',
);
}
function canonicalGenerationJson(value: object): string {
return JSON.stringify(canonicalizeGenerationValue(value));
}
function snapshotFromConfig(
config: TeaConfig,
identity: string,
@@ -105,7 +141,7 @@ function snapshotFromConfig(
const { token, ...fields } = matches[0];
const secret = new TextEncoder().encode(token);
const generation = createHash('sha256')
.update(JSON.stringify(fields))
.update(canonicalGenerationJson(fields))
.update('\0')
.update(secret)
.digest('hex');
@@ -1,13 +1,15 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import { grantTeamRepositoryPermission, type GiteaTeamGrantProvider } from './team-grant.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
import type { CredentialValidationDependencies } from './validate.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
@@ -148,6 +150,206 @@ describe('team repository grant', (): void => {
expect(result.evidence.teamRepository?.state).toBe('present');
});
it('durably reports indeterminate when team-lock release cannot be verified', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let repositoryReads = 0;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
repositoryReads += 1;
return {
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {},
async removeTeamMember(): Promise<void> {},
async attachTeamRepository(): Promise<void> {},
async detachTeamRepository(): Promise<void> {},
async readTeamMember() {
return {
state: 'present',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: 'present',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const stateRoot = join(cleanup, 'state');
const result = await grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{
stateRoot,
actor: 'provisioner',
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
throw new Error('injected close failure');
},
},
);
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('mutation-lock-release-failed');
expect(result.mutation).toBe('applied');
const [journalName] = await readdir(join(stateRoot, 'journals'));
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
expect(journal).toContain('"outcome":"indeterminate"');
expect(journal).toContain('"reasonCode":"mutation-lock-release-failed"');
expect(journal).toContain('"decision":"permission-write"');
});
it('never compensates a later cooperating mutation after release when final seal fails', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let repositoryReads = 0;
let memberPresent = false;
let repositoryPresent = false;
let memberRemovals = 0;
let repositoryDetachments = 0;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
repositoryReads += 1;
return {
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {
memberPresent = true;
},
async removeTeamMember(): Promise<void> {
memberRemovals += 1;
memberPresent = false;
},
async attachTeamRepository(): Promise<void> {
repositoryPresent = true;
},
async detachTeamRepository(): Promise<void> {
repositoryDetachments += 1;
repositoryPresent = false;
},
async readTeamMember() {
return {
state: memberPresent ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: repositoryPresent ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const originalSeal = CredentialAuditJournal.prototype.seal;
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
this: CredentialAuditJournal,
outcome,
reasonCode,
): Promise<string> {
if (outcome === 'ok') {
throw new CredentialJournalError('journal-unavailable', 'injected final seal failure');
}
return originalSeal.call(this, outcome, reasonCode);
});
await expect(
grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{
stateRoot: join(cleanup, 'state'),
actor: 'provisioner',
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
// Simulate the next cooperating owner establishing the same state
// immediately after acquiring the released lock.
memberPresent = true;
repositoryPresent = true;
},
},
),
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
expect(memberRemovals).toBe(0);
expect(repositoryDetachments).toBe(0);
expect(memberPresent).toBe(true);
expect(repositoryPresent).toBe(true);
});
it('refuses a shared team already attached to any repository outside the request', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let mutated = false;
+80 -16
View File
@@ -77,9 +77,22 @@ export interface GiteaTeamGrantProvider {
organization: string,
): Promise<OrganizationMembershipEvidenceDto>;
}
type ReleaseTeamGrantLock = () => Promise<void>;
type AcquireTeamGrantLock = (
estate: string,
host: string,
teamId: number,
) => Promise<ReleaseTeamGrantLock>;
export interface TeamGrantOptions {
readonly stateRoot: string;
readonly actor: string;
readonly acquireTeamLock?: AcquireTeamGrantLock;
}
interface TeamGrantFinalization {
readonly outcome: 'ok' | 'refused' | 'indeterminate';
readonly reasonCode: string;
}
class TeamGrantLockError extends Error {
@@ -161,11 +174,30 @@ export async function grantTeamRepositoryPermission(
await journal.recordIntent('provider-grant');
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let releaseTeamLock: (() => Promise<void>) | undefined;
let teamLockReleased = false;
let rollbackTeam: TeamResolutionEvidence | undefined;
let membershipBeforeMutation: PresenceEvidence | undefined;
let repositoryAttachedBeforeMutation = false;
let memberMutationAttempted = false;
let repositoryMutationAttempted = false;
const sealFinalVerdict = async (
providerOutcome: 'ok' | 'refused' | 'indeterminate',
providerReasonCode: string,
): Promise<TeamGrantFinalization> => {
const release = releaseTeamLock;
releaseTeamLock = undefined;
if (release !== undefined) {
try {
await release();
teamLockReleased = true;
} catch {
await journal.seal('indeterminate', 'mutation-lock-release-failed');
return { outcome: 'indeterminate', reasonCode: 'mutation-lock-release-failed' };
}
}
await journal.seal(providerOutcome, providerReasonCode);
return { outcome: providerOutcome, reasonCode: providerReasonCode };
};
try {
const authorityIdentity = await provider.readBasicIdentity(authority);
const organization = request.repo.split('/')[0] ?? '';
@@ -198,7 +230,11 @@ export async function grantTeamRepositoryPermission(
decision: `permission-${team.permission}`,
});
try {
releaseTeamLock = await acquireTeamGrantLock(request.estate, request.host, team.id);
releaseTeamLock = await (options.acquireTeamLock ?? acquireTeamGrantLock)(
request.estate,
request.host,
team.id,
);
} catch (error: unknown) {
if (!(error instanceof TeamGrantLockError)) throw error;
await journal.seal('indeterminate', error.code);
@@ -223,13 +259,13 @@ export async function grantTeamRepositoryPermission(
decision: 'team-repository-set-verified',
});
if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) {
await journal.seal('refused', 'team-scope-exceeds-request');
const finalization = await sealFinalVerdict('refused', 'team-scope-exceeds-request');
return result(
request,
journal,
'refused',
finalization.outcome,
'none',
'team-scope-exceeds-request',
finalization.reasonCode,
null,
team,
null,
@@ -306,13 +342,16 @@ export async function grantTeamRepositoryPermission(
throw new Error('team repository rollback disagreed');
}
}
await journal.seal('indeterminate', 'team-scope-changed-during-grant');
const finalization = await sealFinalVerdict(
'indeterminate',
'team-scope-changed-during-grant',
);
return result(
request,
journal,
'indeterminate',
finalization.outcome,
'applied',
'team-scope-changed-during-grant',
finalization.reasonCode,
null,
team,
teamMembership,
@@ -367,16 +406,16 @@ export async function grantTeamRepositoryPermission(
organizationMembership?.state === 'present' &&
validation.outcome === 'ok' &&
validation.evidence.repositoryPermission?.effective === request.permission;
await journal.seal(
const finalization = await sealFinalVerdict(
ok ? 'ok' : 'indeterminate',
ok ? 'grant-verified' : 'permission-evidence-disagrees',
);
return result(
request,
journal,
ok ? 'ok' : 'indeterminate',
finalization.outcome,
'applied',
ok ? 'grant-verified' : 'permission-evidence-disagrees',
finalization.reasonCode,
validation,
team,
teamMembership,
@@ -388,6 +427,7 @@ export async function grantTeamRepositoryPermission(
let compensationError: unknown;
try {
if (
!teamLockReleased &&
rollbackTeam !== undefined &&
membershipBeforeMutation?.state === 'absent' &&
memberMutationAttempted
@@ -406,6 +446,7 @@ export async function grantTeamRepositoryPermission(
}
}
if (
!teamLockReleased &&
rollbackTeam !== undefined &&
!repositoryAttachedBeforeMutation &&
repositoryMutationAttempted
@@ -442,8 +483,9 @@ export async function grantTeamRepositoryPermission(
: mutation === 'applied'
? 'readback-missing'
: 'mutation-state-unknown';
let finalization: Awaited<ReturnType<typeof sealFinalVerdict>>;
try {
await journal.seal('indeterminate', reasonCode);
finalization = await sealFinalVerdict('indeterminate', reasonCode);
} catch (journalError: unknown) {
if (journalError instanceof CredentialJournalError) {
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
@@ -453,9 +495,9 @@ export async function grantTeamRepositoryPermission(
return result(
request,
journal,
'indeterminate',
finalization.outcome,
mutation,
reasonCode,
finalization.reasonCode,
null,
null,
null,
@@ -464,9 +506,31 @@ export async function grantTeamRepositoryPermission(
null,
);
} finally {
// The kernel also releases this advisory lock on process exit. A close
// cleanup fault must not contradict an already sealed provider verdict.
await releaseTeamLock?.().catch((): void => undefined);
const release = releaseTeamLock;
releaseTeamLock = undefined;
if (release !== undefined) {
try {
await release();
} catch {
try {
await journal.seal('indeterminate', 'mutation-lock-release-failed');
} catch (journalError: unknown) {
if (journalError instanceof CredentialJournalError) {
throw new CredentialGrantExecutionError(
journalError.code,
mutation,
journal.journalId(),
);
}
throw journalError;
}
throw new CredentialGrantExecutionError(
'mutation-lock-release-failed',
mutation,
journal.journalId(),
);
}
}
}
}