349 lines
13 KiB
TypeScript
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;
|
|
});
|
|
}
|