fix(mosaic): harden credential mutation boundaries
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CredentialAuditJournal,
|
||||
CredentialJournalError,
|
||||
listCredentialJournals,
|
||||
} from '../credentials/audit-journal.js';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
import { FileCredentialStore } from '../credentials/file-credential-store.js';
|
||||
import { executeCredentialRotate, executeCredentialWire } from './cred.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fixture(): Promise<{
|
||||
readonly mosaicHome: string;
|
||||
readonly registryPath: string;
|
||||
readonly tokenDirectory: string;
|
||||
readonly stateRoot: string;
|
||||
}> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-command-'));
|
||||
await chmod(cleanup, 0o700);
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const credentialDirectory = join(mosaicHome, 'cred');
|
||||
await mkdir(credentialDirectory, { recursive: true, mode: 0o700 });
|
||||
const registryPath = join(credentialDirectory, 'estates.json');
|
||||
await writeFile(
|
||||
registryPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return {
|
||||
mosaicHome,
|
||||
registryPath,
|
||||
tokenDirectory: join(mosaicHome, 'secrets', 'gitea-tokens'),
|
||||
stateRoot: join(cleanup, 'state'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('credential lifecycle command controls', (): void => {
|
||||
it('returns the visible open rotation journal when protected authority resolution fails', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
||||
await store.put(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
providerLogin: 'seat-name',
|
||||
tokenName: 'old-generation',
|
||||
scopes: ['write:repository'],
|
||||
createdAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
new TextEncoder().encode('old-token-canary'),
|
||||
);
|
||||
|
||||
const result = await executeCredentialRotate('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: '999',
|
||||
tokenName: 'new-generation',
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('error');
|
||||
expect(result.mutation).toBe('none');
|
||||
expect(result.audit.state).toBe('open');
|
||||
expect(result.audit.journalId).not.toBeNull();
|
||||
await expect(listCredentialJournals(paths.stateRoot)).resolves.toContainEqual(
|
||||
expect.objectContaining({ id: result.audit.journalId, state: 'open' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
const before = 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n';
|
||||
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
||||
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'intruder-seat',
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.mutation).toBe('none');
|
||||
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
||||
});
|
||||
|
||||
it('authenticates the exact seat and rewrites its roster-derived projection idempotently', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const invoke = async () => {
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
return await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
};
|
||||
|
||||
const first = await invoke();
|
||||
const afterFirst = await readFile(seatEnvironment, 'utf8');
|
||||
const second = await invoke();
|
||||
const afterSecond = await readFile(seatEnvironment, 'utf8');
|
||||
|
||||
expect(first.outcome).toBe('ok');
|
||||
expect(second.outcome).toBe('ok');
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
expect(afterSecond).toContain('MOSAIC_GIT_IDENTITY=seat-name\n');
|
||||
expect(afterSecond).toContain('MOSAIC_CREDENTIAL_ESTATE=homelab\n');
|
||||
expect(afterSecond).toContain('GITEA_LOGIN=seat-name--git.example.invalid\n');
|
||||
});
|
||||
|
||||
it.each(['recordMutation', 'seal'] as const)(
|
||||
'reports an applied wire as indeterminate when audit %s fails after rename',
|
||||
async (method): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.spyOn(CredentialAuditJournal.prototype, method).mockRejectedValueOnce(
|
||||
new CredentialJournalError('journal-unavailable', 'injected audit failure'),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.mutation).toBe('applied');
|
||||
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses to overwrite a roster projection replaced after validation', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
wireBeforeRename: async (): Promise<void> => {
|
||||
const replacement = join(agents, 'replacement');
|
||||
await writeFile(replacement, 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', { mode: 0o600 });
|
||||
await rename(replacement, seatEnvironment);
|
||||
},
|
||||
});
|
||||
expect(result.outcome).toBe('error');
|
||||
expect(result.mutation).toBe('none');
|
||||
expect(await readFile(seatEnvironment, 'utf8')).toBe(
|
||||
'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n',
|
||||
);
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports directory-sync failure after rename as applied and indeterminate', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
wireDirectorySync: async (): Promise<void> => {
|
||||
throw new Error('injected directory sync failure');
|
||||
},
|
||||
});
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.mutation).toBe('applied');
|
||||
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a temporary projection when directory revalidation fails before rename', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
||||
const authorityPath = join(cleanup!, 'authority.json');
|
||||
await writeFile(
|
||||
authorityPath,
|
||||
JSON.stringify({
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
secret: 'authority-canary',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
vi.stubGlobal('fetch', async (): Promise<Response> => {
|
||||
await chmod(agents, 0o777);
|
||||
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
const authority = await open(authorityPath, 'r');
|
||||
try {
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: authority.fd.toString(),
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
expect(result.outcome).toBe('error');
|
||||
expect(await readdir(agents)).toEqual(['seat-name.env.generated']);
|
||||
} finally {
|
||||
await authority.close();
|
||||
await chmod(agents, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a caller-selected seat filename that is not bound to the requested identity', async (): Promise<void> => {
|
||||
const paths = await fixture();
|
||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
||||
const seatEnvironment = join(agents, 'other-seat.env.generated');
|
||||
const before = 'MOSAIC_AGENT_NAME=other-seat\nMOSAIC_AGENT_CLASS=coder\n';
|
||||
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
||||
|
||||
const result = await executeCredentialWire('seat-name', {
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
actor: 'seat-name',
|
||||
authorityFd: '999',
|
||||
seatEnv: seatEnvironment,
|
||||
mosaicHome: paths.mosaicHome,
|
||||
registry: paths.registryPath,
|
||||
tokenDir: paths.tokenDirectory,
|
||||
stateDir: paths.stateRoot,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.reason.code).toBe('credential-binding-mismatch');
|
||||
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { fstatSync, lstatSync, writeSync } from 'node:fs';
|
||||
import { open, rename } from 'node:fs/promises';
|
||||
import { constants, fstatSync, lstatSync, writeSync } from 'node:fs';
|
||||
import { open, rename, unlink } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
@@ -92,6 +92,8 @@ interface CredentialLifecycleCommandOptions {
|
||||
readonly teaConfig?: string;
|
||||
readonly mosaicHome?: string;
|
||||
readonly json?: boolean;
|
||||
readonly wireBeforeRename?: () => Promise<void>;
|
||||
readonly wireDirectorySync?: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
|
||||
@@ -495,6 +497,10 @@ export async function executeCredentialRotate(
|
||||
identity: string,
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
let journal: CredentialAuditJournal | undefined;
|
||||
let old: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
||||
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
|
||||
let mutation: CredentialLifecycleResultDto['mutation'] = 'none';
|
||||
try {
|
||||
if (options.authorityFd === undefined || options.tokenName === undefined) {
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
@@ -504,7 +510,7 @@ export async function executeCredentialRotate(
|
||||
});
|
||||
}
|
||||
const context = await lifecycleContext(options);
|
||||
const journal = await CredentialAuditJournal.open(context.stateRoot, {
|
||||
journal = await CredentialAuditJournal.open(context.stateRoot, {
|
||||
operation: 'rotate',
|
||||
actor: options.actor,
|
||||
identity,
|
||||
@@ -513,25 +519,27 @@ export async function executeCredentialRotate(
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('rotate-requested');
|
||||
const old = await context.store.snapshot(identity, options.estate, options.host);
|
||||
old = await context.store.snapshot(identity, options.estate, options.host);
|
||||
if (old === undefined) {
|
||||
await journal.seal('refused', 'no-token-for-identity');
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'no-token-for-identity',
|
||||
message: 'No existing binding can be rotated.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
if (options.tokenName === old.binding.tokenName) {
|
||||
old.secret.fill(0);
|
||||
await journal.seal('refused', 'replacement-token-name-conflict');
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'replacement-token-name-conflict',
|
||||
message: 'Replacement token name must differ from the active generation.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
const authority = await lifecycleAuthority(identity, options);
|
||||
authority = await lifecycleAuthority(identity, options);
|
||||
mutation = 'unknown';
|
||||
const provisioned = await provisionCredential(
|
||||
{
|
||||
identity,
|
||||
@@ -553,13 +561,13 @@ export async function executeCredentialRotate(
|
||||
},
|
||||
);
|
||||
if (provisioned.outcome !== 'ok') {
|
||||
old.secret.fill(0);
|
||||
return {
|
||||
...provisioned,
|
||||
operation: 'rotate',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
}
|
||||
mutation = 'applied';
|
||||
try {
|
||||
await journal.recordMutation('token-mint-applied');
|
||||
} catch (error: unknown) {
|
||||
@@ -569,7 +577,7 @@ export async function executeCredentialRotate(
|
||||
}
|
||||
await context.store.put(old.binding, old.secret);
|
||||
await context.teaStore.put(identity, options.host, old.secret);
|
||||
old.secret.fill(0);
|
||||
mutation = 'none';
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -580,7 +588,6 @@ export async function executeCredentialRotate(
|
||||
await journal.recordMutation('token-revoke-applied');
|
||||
} catch {
|
||||
await journal.seal('indeterminate', 'old-credential-state-unknown');
|
||||
old.secret.fill(0);
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'unknown',
|
||||
@@ -591,7 +598,6 @@ export async function executeCredentialRotate(
|
||||
});
|
||||
}
|
||||
await journal.seal('ok', 'rotate-verified');
|
||||
old.secret.fill(0);
|
||||
return {
|
||||
...provisioned,
|
||||
operation: 'rotate',
|
||||
@@ -601,13 +607,29 @@ export async function executeCredentialRotate(
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
} catch {
|
||||
} catch (error: unknown) {
|
||||
if (journal !== undefined) {
|
||||
await journal.closeIncomplete().catch((): void => undefined);
|
||||
}
|
||||
const outcome = mutation === 'none' ? 'error' : 'indeterminate';
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'indeterminate',
|
||||
mutation: 'unknown',
|
||||
code: 'mutation-state-unknown',
|
||||
outcome,
|
||||
mutation,
|
||||
code:
|
||||
error instanceof CredentialJournalError
|
||||
? error.code
|
||||
: mutation === 'none'
|
||||
? 'internal-invariant'
|
||||
: 'mutation-state-unknown',
|
||||
message: 'Rotation did not establish both new-token acceptance and old-token revocation.',
|
||||
audit:
|
||||
journal === undefined
|
||||
? { journalId: null, state: 'not-started' }
|
||||
: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
} finally {
|
||||
authority?.secret.fill(0);
|
||||
old?.secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,15 +644,31 @@ export async function executeCredentialWire(
|
||||
message: 'An explicit seat environment file is required.',
|
||||
});
|
||||
}
|
||||
const locations = lifecycleLocations(options);
|
||||
const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents');
|
||||
if (dirname(options.seatEnv) !== seatEnvironmentRoot) {
|
||||
if (options.actor !== identity) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'insecure-credential-destination',
|
||||
message: 'Seat environment must be directly beneath the governed fleet agent directory.',
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'Wire authority must be the exact seat being projected.',
|
||||
});
|
||||
}
|
||||
const locations = lifecycleLocations(options);
|
||||
const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents');
|
||||
const canonicalSeatEnvironment = join(seatEnvironmentRoot, `${identity}.env.generated`);
|
||||
if (options.seatEnv !== canonicalSeatEnvironment) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'credential-binding-mismatch',
|
||||
message: 'Seat environment path is not bound to the explicit identity.',
|
||||
});
|
||||
}
|
||||
if (options.authorityFd === undefined) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'error',
|
||||
code: 'invalid-input',
|
||||
message: 'A protected authority fd is required.',
|
||||
});
|
||||
}
|
||||
let context: Awaited<ReturnType<typeof lifecycleContext>>;
|
||||
try {
|
||||
ensureManagedDirectory(seatEnvironmentRoot, seatEnvironmentRoot);
|
||||
const parent = lstatSync(seatEnvironmentRoot);
|
||||
@@ -642,7 +680,7 @@ export async function executeCredentialWire(
|
||||
) {
|
||||
throw new Error('seat environment directory is unsafe');
|
||||
}
|
||||
const context = await lifecycleContext(options);
|
||||
context = await lifecycleContext(options);
|
||||
if (context.registry.resolve(options.estate, options.host) === undefined) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
@@ -658,6 +696,7 @@ export async function executeCredentialWire(
|
||||
});
|
||||
}
|
||||
let journal: CredentialAuditJournal | undefined;
|
||||
let mutation: CredentialLifecycleResultDto['mutation'] = 'none';
|
||||
try {
|
||||
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'wire',
|
||||
@@ -668,18 +707,46 @@ export async function executeCredentialWire(
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('wire-requested');
|
||||
let existing = '';
|
||||
const authority = await lifecycleAuthority(identity, options);
|
||||
let providerIdentity: Awaited<ReturnType<typeof context.provider.readBasicIdentity>>;
|
||||
try {
|
||||
const snapshot = readRegularFileSecure(options.seatEnv, {
|
||||
root: dirname(options.seatEnv),
|
||||
maxBytes: 1024 * 1024,
|
||||
providerIdentity = await context.provider.readBasicIdentity(authority);
|
||||
} finally {
|
||||
authority.secret.fill(0);
|
||||
}
|
||||
if (providerIdentity.login !== identity) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'Protected authority did not read back as the exact target seat.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: providerIdentity.endpoint,
|
||||
contentType: providerIdentity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
const snapshot = readRegularFileSecure(options.seatEnv, {
|
||||
root: dirname(options.seatEnv),
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new Error('seat environment ownership or mode is unsafe');
|
||||
}
|
||||
const existing = snapshot.content.toString('utf8');
|
||||
const rosterBindings = existing
|
||||
.split(/\r?\n/)
|
||||
.filter((line): boolean => line.startsWith('MOSAIC_AGENT_NAME='));
|
||||
if (rosterBindings.length !== 1 || rosterBindings[0] !== `MOSAIC_AGENT_NAME=${identity}`) {
|
||||
await journal.seal('refused', 'credential-binding-mismatch');
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'credential-binding-mismatch',
|
||||
message: 'Roster-derived seat projection did not bind the requested identity.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new Error('seat environment ownership or mode is unsafe');
|
||||
}
|
||||
existing = snapshot.content.toString('utf8');
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||
}
|
||||
const lines = existing
|
||||
.split(/\r?\n/)
|
||||
@@ -697,22 +764,54 @@ export async function executeCredentialWire(
|
||||
const parentBefore = lstatSync(seatEnvironmentRoot);
|
||||
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
let renamed = false;
|
||||
try {
|
||||
await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8');
|
||||
await handle.sync();
|
||||
try {
|
||||
await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await options.wireBeforeRename?.();
|
||||
const parentAfter = lstatSync(seatEnvironmentRoot);
|
||||
if (
|
||||
parentAfter.dev !== parentBefore.dev ||
|
||||
parentAfter.ino !== parentBefore.ino ||
|
||||
parentAfter.uid !== process.getuid?.() ||
|
||||
(parentAfter.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new Error('seat environment directory changed during mutation');
|
||||
}
|
||||
const targetBeforeRename = readRegularFileSecure(options.seatEnv, {
|
||||
root: dirname(options.seatEnv),
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
if (targetBeforeRename.dev !== snapshot.dev || targetBeforeRename.ino !== snapshot.ino) {
|
||||
throw new Error('seat environment changed during mutation');
|
||||
}
|
||||
await rename(temp, options.seatEnv);
|
||||
renamed = true;
|
||||
mutation = 'applied';
|
||||
if (options.wireDirectorySync !== undefined) {
|
||||
await options.wireDirectorySync(seatEnvironmentRoot);
|
||||
} else {
|
||||
const directory = await open(
|
||||
seatEnvironmentRoot,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const synced = await directory.stat();
|
||||
if (synced.dev !== parentBefore.dev || synced.ino !== parentBefore.ino) {
|
||||
throw new Error('seat environment directory changed before durable sync');
|
||||
}
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
if (!renamed) await unlink(temp).catch((): void => undefined);
|
||||
}
|
||||
const parentAfter = lstatSync(seatEnvironmentRoot);
|
||||
if (
|
||||
parentAfter.dev !== parentBefore.dev ||
|
||||
parentAfter.ino !== parentBefore.ino ||
|
||||
parentAfter.uid !== process.getuid?.() ||
|
||||
(parentAfter.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new Error('seat environment directory changed during mutation');
|
||||
}
|
||||
await rename(temp, options.seatEnv);
|
||||
await journal.recordMutation('wire-applied');
|
||||
await journal.seal('ok', 'wire-verified');
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
@@ -730,20 +829,25 @@ export async function executeCredentialWire(
|
||||
message: 'Seat environment journal could not be opened durably.',
|
||||
});
|
||||
}
|
||||
const outcome = mutation === 'applied' ? 'indeterminate' : 'error';
|
||||
const reason = mutation === 'applied' ? 'wire-audit-incomplete' : 'wire-failed';
|
||||
try {
|
||||
await journal.seal('error', 'wire-failed');
|
||||
await journal.seal(outcome, reason);
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'error',
|
||||
mutation: 'unknown',
|
||||
code: 'wire-failed',
|
||||
message: 'Seat environment wiring failed.',
|
||||
outcome,
|
||||
mutation,
|
||||
code: reason,
|
||||
message:
|
||||
mutation === 'applied'
|
||||
? 'Seat environment changed, but complete audit persistence was not established.'
|
||||
: 'Seat environment wiring failed before mutation.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'error',
|
||||
mutation: 'unknown',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'wire-failed',
|
||||
outcome,
|
||||
mutation,
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : reason,
|
||||
message: 'Seat environment audit could not be sealed.',
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
@@ -1035,8 +1139,9 @@ export function registerCredentialCommand(parent: Command): void {
|
||||
.description('Idempotently wire both explicit fleet identity axes into a seat environment')
|
||||
.requiredOption('--estate <estate>', 'Explicit target estate')
|
||||
.requiredOption('--host <host>', 'Explicit provider host')
|
||||
.requiredOption('--actor <identity>', 'Explicit audit actor')
|
||||
.requiredOption('--seat-env <path>', 'Explicit seat environment file')
|
||||
.requiredOption('--actor <identity>', 'Exact seat identity authorized to wire itself')
|
||||
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
|
||||
.requiredOption('--seat-env <path>', 'Exact roster-derived <identity>.env.generated file')
|
||||
.option('--state-dir <path>', 'Durable credential journal root')
|
||||
.option('--json', 'Emit one machine result object')
|
||||
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
|
||||
|
||||
@@ -40,13 +40,17 @@ const SAFE_DECISIONS = new Set<string>([
|
||||
'get-requested',
|
||||
'validation-verified',
|
||||
'team-member-present',
|
||||
'team-member-absent',
|
||||
'team-repository-present',
|
||||
'team-repository-absent',
|
||||
'team-repository-set-verified',
|
||||
'organization-member-present',
|
||||
'organization-member-absent',
|
||||
'collaborator-grant-applied',
|
||||
'team-member-applied',
|
||||
'team-member-rollback-applied',
|
||||
'team-repository-applied',
|
||||
'team-repository-rollback-applied',
|
||||
'transport-write-verified',
|
||||
'token-mint-applied',
|
||||
'token-binding-stored',
|
||||
|
||||
@@ -183,6 +183,8 @@ describe('Gitea credential provider transport', (): void => {
|
||||
});
|
||||
|
||||
it('reads team permission, member attachment, and repository attachment separately', async (): Promise<void> => {
|
||||
let memberRemoved = false;
|
||||
let repositoryDetached = false;
|
||||
const adapter = new GiteaTeamGrantProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
@@ -191,6 +193,11 @@ describe('Gitea credential provider transport', (): void => {
|
||||
return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]);
|
||||
}
|
||||
if (init?.method === 'PUT') return new Response(null, { status: 204 });
|
||||
if (init?.method === 'DELETE') {
|
||||
if (url.includes('/members/')) memberRemoved = true;
|
||||
if (url.includes('/repos/')) repositoryDetached = true;
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.includes('/members/seat-name')) {
|
||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
||||
}
|
||||
@@ -214,6 +221,10 @@ describe('Gitea credential provider transport', (): void => {
|
||||
await expect(
|
||||
adapter.readTeamRepository(credential, team.id, 'owner/repo'),
|
||||
).resolves.toMatchObject({ state: 'present' });
|
||||
await adapter.removeTeamMember(credential, team.id, 'seat-name');
|
||||
await adapter.detachTeamRepository(credential, team.id, 'owner/repo');
|
||||
expect(memberRemoved).toBe(true);
|
||||
expect(repositoryDetached).toBe(true);
|
||||
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
|
||||
});
|
||||
|
||||
|
||||
@@ -626,6 +626,31 @@ export class GiteaTeamGrantProviderAdapter
|
||||
);
|
||||
}
|
||||
|
||||
async removeTeamMember(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
identity: string,
|
||||
): Promise<void> {
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
await boundedBody(response);
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
'team member rollback failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async attachTeamRepository(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
@@ -635,6 +660,32 @@ export class GiteaTeamGrantProviderAdapter
|
||||
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
|
||||
}
|
||||
|
||||
async detachTeamRepository(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
repo: string,
|
||||
): Promise<void> {
|
||||
const { owner, name } = repoPath(repo);
|
||||
const response = await this.request(
|
||||
`${this.origin}/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
);
|
||||
await boundedBody(response);
|
||||
if (!response.ok) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
'team repository rollback failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
|
||||
const response = await this.request(`${this.origin}${path}`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
@@ -73,6 +73,7 @@ function validation(): CredentialValidationDependencies {
|
||||
describe('team repository grant', (): void => {
|
||||
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
@@ -91,14 +92,17 @@ describe('team repository grant', (): void => {
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: [],
|
||||
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',
|
||||
@@ -174,9 +178,15 @@ describe('team repository grant', (): void => {
|
||||
async addTeamMember(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
mutated = true;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: 'absent',
|
||||
@@ -220,4 +230,384 @@ describe('team repository grant', (): void => {
|
||||
expect(result.reason.code).toBe('team-scope-exceeds-request');
|
||||
expect(mutated).toBe(false);
|
||||
});
|
||||
|
||||
it('journals absent team objects as absent rather than present', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {},
|
||||
async removeTeamMember(): Promise<void> {},
|
||||
async attachTeamRepository(): Promise<void> {},
|
||||
async detachTeamRepository(): Promise<void> {},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const stateRoot = join(cleanup, 'state');
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot, actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||
expect(journal).toContain('team-member-absent');
|
||||
expect(journal).toContain('team-repository-absent');
|
||||
expect(journal).not.toContain('"decision":"team-member-present"');
|
||||
expect(journal).not.toContain('"decision":"team-repository-present"');
|
||||
});
|
||||
|
||||
it('fails closed and removes newly added membership when team scope changes during mutation', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let repositoryReads = 0;
|
||||
let membershipReads = 0;
|
||||
let removed = false;
|
||||
let detached = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
repositoryReads += 1;
|
||||
return {
|
||||
repositories: repositoryReads === 1 ? [] : ['owner/repo', 'owner/concurrent-attachment'],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
removed = true;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
detached = true;
|
||||
},
|
||||
async readTeamMember() {
|
||||
membershipReads += 1;
|
||||
return {
|
||||
state: membershipReads === 1 || removed ? 'absent' : 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: detached ? 'absent' : 'present',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const stateRoot = join(cleanup, 'state');
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot, actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.reason.code).toBe('team-scope-changed-during-grant');
|
||||
expect(result.evidence.teamRepositorySet?.repositories).toEqual([
|
||||
'owner/repo',
|
||||
'owner/concurrent-attachment',
|
||||
]);
|
||||
expect(removed).toBe(true);
|
||||
expect(detached).toBe(true);
|
||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
||||
expect(journal.indexOf('team-member-absent')).toBeLessThan(
|
||||
journal.indexOf('team-member-applied'),
|
||||
);
|
||||
expect(journal).toContain('team-repository-rollback-applied');
|
||||
expect(journal).toContain('team-repository-absent');
|
||||
});
|
||||
|
||||
it('compensates provider changes when repository attachment fails after applying', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let memberPresent = false;
|
||||
let repositoryPresent = false;
|
||||
let memberRemoved = false;
|
||||
let repositoryDetached = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
return {
|
||||
repositories: repositoryPresent ? ['owner/repo'] : [],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
memberPresent = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberPresent = false;
|
||||
memberRemoved = true;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = true;
|
||||
throw new Error('provider response lost after attachment');
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryPresent = false;
|
||||
repositoryDetached = true;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryPresent ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await grantTeamRepositoryPermission(
|
||||
{
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write',
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
},
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(memberPresent).toBe(false);
|
||||
expect(repositoryPresent).toBe(false);
|
||||
expect(memberRemoved).toBe(true);
|
||||
expect(repositoryDetached).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a second governed mutation while the same team lock is held', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let releaseFirst!: () => void;
|
||||
const firstMayFinish = new Promise<void>((resolve): void => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered!: () => void;
|
||||
const firstEntered = new Promise<void>((resolve): void => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
let addCalls = 0;
|
||||
let memberAdded = false;
|
||||
let repositoryAttached = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async resolveTeam() {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'writers',
|
||||
permission: 'write',
|
||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async listTeamRepositories() {
|
||||
return {
|
||||
repositories: repositoryAttached ? ['owner/repo'] : [],
|
||||
endpoint: 'GET /api/v1/teams/7/repos',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async addTeamMember(): Promise<void> {
|
||||
addCalls += 1;
|
||||
markFirstEntered();
|
||||
await firstMayFinish;
|
||||
memberAdded = true;
|
||||
},
|
||||
async removeTeamMember(): Promise<void> {
|
||||
memberAdded = false;
|
||||
},
|
||||
async attachTeamRepository(): Promise<void> {
|
||||
repositoryAttached = true;
|
||||
},
|
||||
async detachTeamRepository(): Promise<void> {
|
||||
repositoryAttached = false;
|
||||
},
|
||||
async readTeamMember() {
|
||||
return {
|
||||
state: memberAdded ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readTeamRepository() {
|
||||
return {
|
||||
state: repositoryAttached ? 'present' : 'absent',
|
||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async readOrganizationMembership() {
|
||||
return {
|
||||
state: 'present',
|
||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
};
|
||||
const request = {
|
||||
identity: 'seat-name',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'owner/repo',
|
||||
permission: 'write' as const,
|
||||
team: 'writers',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
};
|
||||
const options = { stateRoot: join(cleanup, 'state-one'), actor: 'provisioner' };
|
||||
const secondOptions = { stateRoot: join(cleanup, 'state-two'), actor: 'provisioner' };
|
||||
|
||||
const first = grantTeamRepositoryPermission(
|
||||
request,
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
options,
|
||||
);
|
||||
await firstEntered;
|
||||
const second = await grantTeamRepositoryPermission(
|
||||
request,
|
||||
authority,
|
||||
provider,
|
||||
validation(),
|
||||
secondOptions,
|
||||
);
|
||||
releaseFirst();
|
||||
const completedFirst = await first;
|
||||
|
||||
expect(completedFirst.outcome).toBe('ok');
|
||||
expect(second.outcome).toBe('indeterminate');
|
||||
expect(second.reason.code).toBe('concurrent-mutation');
|
||||
expect(second.mutation).toBe('none');
|
||||
expect(addCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ensureManagedDirectory } from '../fleet/secure-file.js';
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import type {
|
||||
CredentialValidationDependencies,
|
||||
@@ -54,7 +59,9 @@ export interface GiteaTeamGrantProvider {
|
||||
teamId: number,
|
||||
): Promise<TeamRepositorySetEvidence>;
|
||||
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
||||
removeTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
||||
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
||||
detachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
||||
readTeamMember(
|
||||
authority: ResolvedCredential,
|
||||
teamId: number,
|
||||
@@ -75,6 +82,67 @@ export interface TeamGrantOptions {
|
||||
readonly actor: string;
|
||||
}
|
||||
|
||||
class TeamGrantLockError extends Error {
|
||||
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireTeamGrantLock(
|
||||
estate: string,
|
||||
host: string,
|
||||
teamId: number,
|
||||
): Promise<() => Promise<void>> {
|
||||
const uid = process.getuid?.();
|
||||
if (uid === undefined) throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
const locksDirectory = `/tmp/mosaic-cred-team-locks-${uid.toString()}`;
|
||||
ensureManagedDirectory(locksDirectory, locksDirectory);
|
||||
const directory = lstatSync(locksDirectory);
|
||||
if (
|
||||
!directory.isDirectory() ||
|
||||
directory.isSymbolicLink() ||
|
||||
directory.uid !== uid ||
|
||||
(directory.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
}
|
||||
const lockPath = join(locksDirectory, `${estate}--${host}--team-${teamId.toString()}.lock`);
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
try {
|
||||
handle = await open(
|
||||
lockPath,
|
||||
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
} catch {
|
||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
}
|
||||
try {
|
||||
const file = await handle.stat();
|
||||
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
|
||||
throw new Error('team mutation lock file is unsafe');
|
||||
}
|
||||
} catch {
|
||||
await handle.close().catch((): void => undefined);
|
||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
||||
}
|
||||
// The child's fd 3 is a dup of the parent's open file description. Linux
|
||||
// flock(2) associates the lock with that description, so it remains held
|
||||
// after the helper exits until this process closes `handle` below.
|
||||
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
||||
});
|
||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
||||
await handle.close().catch((): void => undefined);
|
||||
throw new TeamGrantLockError(
|
||||
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
|
||||
);
|
||||
}
|
||||
return async (): Promise<void> => {
|
||||
await handle.close();
|
||||
};
|
||||
}
|
||||
|
||||
export async function grantTeamRepositoryPermission(
|
||||
request: TeamGrantRequest,
|
||||
authority: ResolvedCredential,
|
||||
@@ -92,10 +160,17 @@ export async function grantTeamRepositoryPermission(
|
||||
});
|
||||
await journal.recordIntent('provider-grant');
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let releaseTeamLock: (() => Promise<void>) | undefined;
|
||||
let rollbackTeam: TeamResolutionEvidence | undefined;
|
||||
let membershipBeforeMutation: PresenceEvidence | undefined;
|
||||
let repositoryAttachedBeforeMutation = false;
|
||||
let memberMutationAttempted = false;
|
||||
let repositoryMutationAttempted = false;
|
||||
try {
|
||||
const authorityIdentity = await provider.readBasicIdentity(authority);
|
||||
const organization = request.repo.split('/')[0] ?? '';
|
||||
const team = await provider.resolveTeam(authority, organization, request.team);
|
||||
rollbackTeam = team;
|
||||
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return result(
|
||||
@@ -122,6 +197,25 @@ export async function grantTeamRepositoryPermission(
|
||||
contentType: team.contentType,
|
||||
decision: `permission-${team.permission}`,
|
||||
});
|
||||
try {
|
||||
releaseTeamLock = await acquireTeamGrantLock(request.estate, request.host, team.id);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof TeamGrantLockError)) throw error;
|
||||
await journal.seal('indeterminate', error.code);
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'indeterminate',
|
||||
'none',
|
||||
error.code,
|
||||
null,
|
||||
team,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
const teamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepositorySet.endpoint,
|
||||
@@ -144,14 +238,89 @@ export async function grantTeamRepositoryPermission(
|
||||
teamRepositorySet,
|
||||
);
|
||||
}
|
||||
const repositoryAttachedBefore = teamRepositorySet.repositories.includes(request.repo);
|
||||
repositoryAttachedBeforeMutation = repositoryAttachedBefore;
|
||||
const membershipBefore = await provider.readTeamMember(authority, team.id, request.identity);
|
||||
membershipBeforeMutation = membershipBefore;
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: membershipBefore.endpoint,
|
||||
contentType: membershipBefore.contentType,
|
||||
decision: membershipBefore.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
mutation = 'unknown';
|
||||
memberMutationAttempted = true;
|
||||
await provider.addTeamMember(authority, team.id, request.identity);
|
||||
mutation = 'applied';
|
||||
await journal.recordMutation('team-member-applied');
|
||||
repositoryMutationAttempted = true;
|
||||
await provider.attachTeamRepository(authority, team.id, request.repo);
|
||||
await journal.recordMutation('team-repository-applied');
|
||||
const teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
||||
const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||
let teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
||||
let teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||
const finalTeamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamMembership.endpoint,
|
||||
contentType: teamMembership.contentType,
|
||||
decision: teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepository.endpoint,
|
||||
contentType: teamRepository.contentType,
|
||||
decision:
|
||||
teamRepository.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
||||
});
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: finalTeamRepositorySet.endpoint,
|
||||
contentType: finalTeamRepositorySet.contentType,
|
||||
decision: 'team-repository-set-verified',
|
||||
});
|
||||
const scopeRemainedExact =
|
||||
finalTeamRepositorySet.repositories.length === 1 &&
|
||||
finalTeamRepositorySet.repositories[0] === request.repo;
|
||||
if (!scopeRemainedExact) {
|
||||
if (membershipBefore.state === 'absent') {
|
||||
await provider.removeTeamMember(authority, team.id, request.identity);
|
||||
await journal.recordMutation('team-member-rollback-applied');
|
||||
teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamMembership.endpoint,
|
||||
contentType: teamMembership.contentType,
|
||||
decision:
|
||||
teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
if (teamMembership.state !== 'absent') throw new Error('team member rollback disagreed');
|
||||
}
|
||||
if (!repositoryAttachedBefore) {
|
||||
await provider.detachTeamRepository(authority, team.id, request.repo);
|
||||
await journal.recordMutation('team-repository-rollback-applied');
|
||||
teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepository.endpoint,
|
||||
contentType: teamRepository.contentType,
|
||||
decision:
|
||||
teamRepository.state === 'present'
|
||||
? 'team-repository-present'
|
||||
: 'team-repository-absent',
|
||||
});
|
||||
if (teamRepository.state !== 'absent') {
|
||||
throw new Error('team repository rollback disagreed');
|
||||
}
|
||||
}
|
||||
await journal.seal('indeterminate', 'team-scope-changed-during-grant');
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'indeterminate',
|
||||
'applied',
|
||||
'team-scope-changed-during-grant',
|
||||
null,
|
||||
team,
|
||||
teamMembership,
|
||||
teamRepository,
|
||||
null,
|
||||
finalTeamRepositorySet,
|
||||
);
|
||||
}
|
||||
const subject = await dependencies.resolver.resolve(
|
||||
request.identity,
|
||||
request.estate,
|
||||
@@ -198,16 +367,6 @@ export async function grantTeamRepositoryPermission(
|
||||
organizationMembership?.state === 'present' &&
|
||||
validation.outcome === 'ok' &&
|
||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamMembership.endpoint,
|
||||
contentType: teamMembership.contentType,
|
||||
decision: 'team-member-present',
|
||||
});
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepository.endpoint,
|
||||
contentType: teamRepository.contentType,
|
||||
decision: 'team-repository-present',
|
||||
});
|
||||
await journal.seal(
|
||||
ok ? 'ok' : 'indeterminate',
|
||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
||||
@@ -223,13 +382,66 @@ export async function grantTeamRepositoryPermission(
|
||||
teamMembership,
|
||||
teamRepository,
|
||||
organizationMembership,
|
||||
teamRepositorySet,
|
||||
finalTeamRepositorySet,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId());
|
||||
let compensationError: unknown;
|
||||
try {
|
||||
if (
|
||||
rollbackTeam !== undefined &&
|
||||
membershipBeforeMutation?.state === 'absent' &&
|
||||
memberMutationAttempted
|
||||
) {
|
||||
let current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
||||
if (current.state === 'present') {
|
||||
await provider.removeTeamMember(authority, rollbackTeam.id, request.identity);
|
||||
await journal.recordMutation('team-member-rollback-applied');
|
||||
current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: current.endpoint,
|
||||
contentType: current.contentType,
|
||||
decision: current.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
||||
});
|
||||
if (current.state !== 'absent') throw new Error('team member rollback disagreed');
|
||||
}
|
||||
}
|
||||
if (
|
||||
rollbackTeam !== undefined &&
|
||||
!repositoryAttachedBeforeMutation &&
|
||||
repositoryMutationAttempted
|
||||
) {
|
||||
let current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||
if (current.state === 'present') {
|
||||
await provider.detachTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||
await journal.recordMutation('team-repository-rollback-applied');
|
||||
current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: current.endpoint,
|
||||
contentType: current.contentType,
|
||||
decision:
|
||||
current.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
||||
});
|
||||
if (current.state !== 'absent') throw new Error('team repository rollback disagreed');
|
||||
}
|
||||
}
|
||||
} catch (rollbackError: unknown) {
|
||||
compensationError = rollbackError;
|
||||
}
|
||||
const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown';
|
||||
const auditError =
|
||||
compensationError instanceof CredentialJournalError
|
||||
? compensationError
|
||||
: error instanceof CredentialJournalError
|
||||
? error
|
||||
: undefined;
|
||||
if (auditError !== undefined) {
|
||||
throw new CredentialGrantExecutionError(auditError.code, mutation, journal.journalId());
|
||||
}
|
||||
const reasonCode =
|
||||
compensationError !== undefined
|
||||
? 'rollback-incomplete'
|
||||
: mutation === 'applied'
|
||||
? 'readback-missing'
|
||||
: 'mutation-state-unknown';
|
||||
try {
|
||||
await journal.seal('indeterminate', reasonCode);
|
||||
} catch (journalError: unknown) {
|
||||
@@ -251,6 +463,10 @@ export async function grantTeamRepositoryPermission(
|
||||
null,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
// The kernel also releases this advisory lock on process exit. A close
|
||||
// cleanup fault must not contradict an already sealed provider verdict.
|
||||
await releaseTeamLock?.().catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user