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
+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(),
);
}
}
}
}