fix(#1045): close credential finalization races
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
2026-08-05 22:42:48 -05:00
parent 7056ff6973
commit fbff4ffa75
4 changed files with 157 additions and 6 deletions
@@ -2,7 +2,7 @@ import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { TeaLoginStore } from './tea-login-store.js';
import { TeaLoginStore, TeaLoginStoreError } from './tea-login-store.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
@@ -106,6 +106,37 @@ describe('host-bound Tea login store', (): void => {
prototypeNamedFieldChanged?.secret.fill(0);
});
it.each(['.nan', '.inf', '-.inf'] as const)(
'rejects non-finite YAML metadata value %s while accepting explicit null',
async (nonFiniteValue): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const configPath = join(root, 'tea', 'config.yml');
const store = new TeaLoginStore(configPath);
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
const baseline = await readFile(configPath, 'utf8');
await writeFile(
configPath,
baseline.replace(
'default: false',
`default: false\n extension-field: ${nonFiniteValue}`,
),
{ mode: 0o600 },
);
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrowError(TeaLoginStoreError);
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrow(/code=tea-config-invalid/);
await writeFile(
configPath,
baseline.replace('default: false', 'default: false\n extension-field: null'),
{ mode: 0o600 },
);
const explicitNull = store.snapshot('seat', 'git.one.invalid');
expect(explicitNull).toBeDefined();
explicitNull?.secret.fill(0);
},
);
it.each(['put', 'remove'] as const)(
'removes secret-bearing temporary files when %s fails before rename',
async (operation): Promise<void> => {
@@ -104,7 +104,15 @@ interface CanonicalJsonObject {
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 (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new TeaLoginStoreError(
'tea-config-invalid',
'Tea login metadata contains a non-finite number',
);
}
return value;
}
if (Array.isArray(value)) {
return value.map((entry: unknown): CanonicalJsonValue => canonicalizeGenerationValue(entry));
}
@@ -350,6 +350,117 @@ describe('team repository grant', (): void => {
expect(repositoryPresent).toBe(true);
});
it('never compensates successor state after lock release begins but release and audit sealing fail', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let repositoryReads = 0;
let memberPresent = false;
let repositoryPresent = false;
let memberRemovals = 0;
let repositoryDetachments = 0;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
repositoryReads += 1;
return {
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {
memberPresent = true;
},
async removeTeamMember(): Promise<void> {
memberRemovals += 1;
memberPresent = false;
},
async attachTeamRepository(): Promise<void> {
repositoryPresent = true;
},
async detachTeamRepository(): Promise<void> {
repositoryDetachments += 1;
repositoryPresent = false;
},
async readTeamMember() {
return {
state: memberPresent ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: repositoryPresent ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const originalSeal = CredentialAuditJournal.prototype.seal;
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
this: CredentialAuditJournal,
outcome,
reasonCode,
): Promise<string> {
if (reasonCode === 'mutation-lock-release-failed') {
throw new CredentialJournalError('journal-unavailable', 'injected release audit failure');
}
return originalSeal.call(this, outcome, reasonCode);
});
await expect(
grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{
stateRoot: join(cleanup, 'state'),
actor: 'provisioner',
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
memberPresent = true;
repositoryPresent = true;
throw new Error('injected release failure after successor mutation');
},
},
),
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
expect(memberRemovals).toBe(0);
expect(repositoryDetachments).toBe(0);
expect(memberPresent).toBe(true);
expect(repositoryPresent).toBe(true);
});
it('refuses a shared team already attached to any repository outside the request', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let mutated = false;
@@ -174,7 +174,7 @@ export async function grantTeamRepositoryPermission(
await journal.recordIntent('provider-grant');
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let releaseTeamLock: (() => Promise<void>) | undefined;
let teamLockReleased = false;
let teamLockReleaseStarted = false;
let rollbackTeam: TeamResolutionEvidence | undefined;
let membershipBeforeMutation: PresenceEvidence | undefined;
let repositoryAttachedBeforeMutation = false;
@@ -187,9 +187,9 @@ export async function grantTeamRepositoryPermission(
const release = releaseTeamLock;
releaseTeamLock = undefined;
if (release !== undefined) {
teamLockReleaseStarted = true;
try {
await release();
teamLockReleased = true;
} catch {
await journal.seal('indeterminate', 'mutation-lock-release-failed');
return { outcome: 'indeterminate', reasonCode: 'mutation-lock-release-failed' };
@@ -427,7 +427,7 @@ export async function grantTeamRepositoryPermission(
let compensationError: unknown;
try {
if (
!teamLockReleased &&
!teamLockReleaseStarted &&
rollbackTeam !== undefined &&
membershipBeforeMutation?.state === 'absent' &&
memberMutationAttempted
@@ -446,7 +446,7 @@ export async function grantTeamRepositoryPermission(
}
}
if (
!teamLockReleased &&
!teamLockReleaseStarted &&
rollbackTeam !== undefined &&
!repositoryAttachedBeforeMutation &&
repositoryMutationAttempted
@@ -509,6 +509,7 @@ export async function grantTeamRepositoryPermission(
const release = releaseTeamLock;
releaseTeamLock = undefined;
if (release !== undefined) {
teamLockReleaseStarted = true;
try {
await release();
} catch {