fix(mosaic): harden credential mutation boundaries

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent f4efc311ab
commit 593b5a1f45
10 changed files with 1281 additions and 84 deletions
+419
View File
@@ -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);
});
});
+158 -53
View File
@@ -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);
});
});
+232 -16
View File
@@ -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);
}
}