This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user