Files
stack/packages/mosaic/src/commands/cred.ts
T

349 lines
13 KiB
TypeScript

import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import type { Command } from 'commander';
import { CredentialJournalError } from '../credentials/audit-journal.js';
import { readRegularFileSecure } from '../fleet/secure-file.js';
import {
runCredentialReadValidation,
runCredentialValidation,
} from '../credentials/credential-validate-service.js';
import type { GiteaWriteValidationRequestDto } from '../credentials/credential-provider.dto.js';
import type {
CredentialValidationResultDto,
RepositoryPermission,
} from '../credentials/credential-result.dto.js';
import { readDelegatedCredentialFromFd } from '../credentials/delegated-credential.js';
import { grantDirectRepositoryPermission } from '../credentials/grant.js';
import type { CredentialGrantResultDto } from '../credentials/grant.dto.js';
import { grantTeamRepositoryPermission } from '../credentials/team-grant.js';
import type { TeamGrantResult } from '../credentials/team-grant.js';
import {
CredentialEstateRegistryError,
parseCredentialEstateRegistry,
} from '../credentials/estate-registry.js';
import {
CredentialStoreError,
FileCredentialResolver,
} from '../credentials/file-credential-store.js';
import {
GiteaCredentialProviderAdapter,
GiteaTeamGrantProviderAdapter,
} from '../credentials/gitea-provider.js';
interface CredentialValidateCommandOptions {
readonly estate: string;
readonly host: string;
readonly repo: string;
readonly require: string;
readonly readOnlyControl?: string;
readonly registry?: string;
readonly tokenDir?: string;
readonly stateDir?: string;
readonly mosaicHome?: string;
readonly actor?: string;
readonly json?: boolean;
}
interface CredentialGrantCommandOptions {
readonly estate: string;
readonly host: string;
readonly repo: string;
readonly permission: string;
readonly via: string;
readonly team?: string;
readonly readOnlyControl?: string;
readonly registry?: string;
readonly tokenDir?: string;
readonly stateDir?: string;
readonly mosaicHome?: string;
readonly actor: string;
readonly authorityFd: string;
readonly json?: boolean;
}
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
return options.mosaicHome ?? join(homedir(), '.config', 'mosaic');
}
function readRegistrySource(path: string): string {
const snapshot = readRegularFileSecure(path, {
root: dirname(path),
maxBytes: 256 * 1024,
});
try {
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
} catch {
throw new CredentialEstateRegistryError('invalid-json', 'estate registry was not valid UTF-8');
}
}
function errorResult(
request: GiteaWriteValidationRequestDto,
code: string,
): CredentialValidationResultDto {
return {
schemaVersion: 1,
operation: 'validate',
outcome: 'error',
exitCode: 20,
retryable: false,
subject: {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
},
mutation: 'none',
reason: {
code,
message: 'The local credential control failed before an access verdict was available.',
},
evidence: {
providerIdentity: null,
repositoryPermission: null,
writeDifferential: null,
},
audit: { journalId: null, state: 'not-started' },
};
}
export async function executeCredentialValidate(
identity: string,
options: CredentialValidateCommandOptions,
): Promise<CredentialValidationResultDto> {
const mosaicHome = defaultMosaicHome(options);
const registryPath = options.registry ?? join(mosaicHome, 'cred', 'estates.json');
const tokenDirectory =
options.tokenDir ??
process.env['MOSAIC_GITEA_TOKEN_DIR'] ??
join(mosaicHome, 'secrets', 'gitea-tokens');
const stateRoot = options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred');
let request: GiteaWriteValidationRequestDto = {
identity,
estate: options.estate,
host: options.host,
repo: options.repo,
readOnlyControlIdentity: options.readOnlyControl ?? '(unresolved)',
};
try {
if (options.require !== 'read' && options.require !== 'write') {
return errorResult(request, 'invalid-input');
}
const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath));
const hostConfig = registry.resolve(options.estate, options.host);
if (hostConfig === undefined) {
return {
...errorResult(request, 'estate-host-mismatch'),
outcome: 'refused',
exitCode: 10,
reason: {
code: 'estate-host-mismatch',
message: 'The declared estate does not contain the declared host.',
},
};
}
request = {
...request,
readOnlyControlIdentity: options.readOnlyControl ?? registry.readOnlyControl(options.estate),
};
const resolver = new FileCredentialResolver(tokenDirectory, registry);
const provider = new GiteaCredentialProviderAdapter(hostConfig.apiBaseUrl, fetch);
const dependencies = { resolver, provider, estateRegistry: registry };
const serviceOptions = { stateRoot, actor: options.actor ?? identity };
if (options.require === 'read') {
return await runCredentialReadValidation(request, dependencies, serviceOptions);
}
return await runCredentialValidation(request, dependencies, serviceOptions);
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
return errorResult(request, error.code);
}
if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) {
return errorResult(request, error.code);
}
return errorResult(request, 'internal-invariant');
}
}
function grantErrorResult(
identity: string,
options: CredentialGrantCommandOptions,
code: string,
): CredentialGrantResultDto {
return {
schemaVersion: 1,
operation: 'grant',
outcome: 'error',
exitCode: 20,
retryable: false,
subject: { identity, estate: options.estate, host: options.host, repo: options.repo },
mutation: 'none',
reason: {
code,
message: 'The local grant control failed before an access verdict was available.',
},
evidence: {
providerIdentity: null,
repositoryPermission: null,
writeDifferential: null,
collaboratorPermission: null,
organizationMembership: null,
},
audit: { journalId: null, state: 'not-started' },
};
}
export async function executeCredentialGrant(
identity: string,
options: CredentialGrantCommandOptions,
): Promise<CredentialGrantResultDto | TeamGrantResult> {
const mosaicHome = defaultMosaicHome(options);
const registryPath = options.registry ?? join(mosaicHome, 'cred', 'estates.json');
const tokenDirectory =
options.tokenDir ??
process.env['MOSAIC_GITEA_TOKEN_DIR'] ??
join(mosaicHome, 'secrets', 'gitea-tokens');
const stateRoot = options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred');
if (
!['read', 'write', 'admin'].includes(options.permission) ||
!['collaborator', 'team'].includes(options.via) ||
(options.via === 'team') !== (options.team !== undefined)
) {
return grantErrorResult(identity, options, 'invalid-input');
}
try {
const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath));
const hostConfig = registry.resolve(options.estate, options.host);
if (hostConfig === undefined) {
const refused = grantErrorResult(identity, options, 'estate-host-mismatch');
return {
...refused,
outcome: 'refused',
exitCode: 10,
reason: { code: 'estate-host-mismatch', message: 'Estate and host do not match.' },
};
}
const fd = Number(options.authorityFd);
const authority = await readDelegatedCredentialFromFd(
fd,
options.actor,
options.estate,
options.host,
);
const request = {
identity,
estate: options.estate,
host: options.host,
repo: options.repo,
permission: options.permission as RepositoryPermission,
readOnlyControlIdentity: options.readOnlyControl ?? registry.readOnlyControl(options.estate),
};
const resolver = new FileCredentialResolver(tokenDirectory, registry);
const provider = new GiteaTeamGrantProviderAdapter(hostConfig.apiBaseUrl, fetch);
const dependencies = { resolver, provider, estateRegistry: registry };
const serviceOptions = { stateRoot, actor: options.actor };
if (options.via === 'team' && options.team !== undefined) {
return await grantTeamRepositoryPermission(
{ ...request, team: options.team },
authority,
provider,
dependencies,
serviceOptions,
);
}
return await grantDirectRepositoryPermission(
request,
authority,
provider,
dependencies,
serviceOptions,
);
} catch (error: unknown) {
if (
error instanceof CredentialJournalError ||
error instanceof CredentialEstateRegistryError ||
error instanceof CredentialStoreError
) {
return grantErrorResult(identity, options, error.code);
}
return grantErrorResult(identity, options, 'internal-invariant');
}
}
type PrintableCredentialResult = Pick<
CredentialValidationResultDto | CredentialGrantResultDto | TeamGrantResult,
'operation' | 'outcome' | 'exitCode' | 'reason'
>;
function printCredentialResult(result: PrintableCredentialResult, json: boolean): void {
if (json) {
process.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
process.stdout.write(
`mosaic cred ${result.operation}: ${result.outcome} (${result.reason.code})\n`,
);
}
export function registerCredentialCommand(parent: Command): void {
const cred = parent
.command('cred')
.description('Govern fleet credential identity, scope, validation, rotation, and revocation')
.option('--mosaic-home <path>', 'Mosaic configuration root')
.configureHelp({ sortSubcommands: true })
.action((): void => {
cred.outputHelp();
});
cred
.command('grant <identity>')
.description('Grant repository permission and accept only provider object read-back')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--repo <owner/repo>', 'Target repository')
.requiredOption('--permission <permission>', 'Requested read, write, or admin permission')
.requiredOption('--actor <identity>', 'Explicit delegated authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected credential fd number')
.option('--via <mode>', 'Direct collaborator or team grant', 'collaborator')
.option('--team <team>', 'Exact team name for team grant')
.option('--read-only-control <identity>', 'Known read-only negative-control identity')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed phase-1 token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialGrantCommandOptions): Promise<void> => {
const inherited = cred.opts<{ mosaicHome?: string }>();
const result = await executeCredentialGrant(identity, {
...options,
...(inherited.mosaicHome === undefined ? {} : { mosaicHome: inherited.mosaicHome }),
});
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred
.command('validate <identity>')
.description('Read back identity, permission layers, and side-effect-free write differential')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--repo <owner/repo>', 'Target repository')
.option('--require <permission>', 'Required effective permission', 'write')
.option('--read-only-control <identity>', 'Known read-only negative-control identity')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed phase-1 token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--actor <identity>', 'Explicit audit actor (defaults to subject)')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => {
const inherited = cred.opts<{ mosaicHome?: string }>();
const result = await executeCredentialValidate(identity, {
...options,
...(inherited.mosaicHome === undefined ? {} : { mosaicHome: inherited.mosaicHome }),
});
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
}