Files
stack/packages/mosaic/src/credentials/validate.ts
T

522 lines
16 KiB
TypeScript

import { CredentialProviderEvidenceError } from './gitea-provider.js';
import type {
CredentialValidationDependencies,
GiteaReadValidationRequestDto,
GiteaWriteValidationRequestDto,
ResolvedCredential,
} from './credential-provider.dto.js';
import type {
CredentialOutcome,
CredentialReasonDto,
CredentialValidationEvidenceDto,
CredentialValidationResultDto,
ProviderIdentityEvidenceDto,
ReceivePackEvidenceDto,
RepositoryPermissionEvidenceDto,
WriteDifferentialEvidenceDto,
} from './credential-result.dto.js';
export type {
CredentialResolver,
CredentialValidationDependencies,
GiteaCredentialProvider,
GiteaReadValidationRequestDto,
GiteaWriteValidationRequestDto,
ResolvedCredential,
} from './credential-provider.dto.js';
export type {
ProviderIdentityEvidenceDto as ProviderIdentityEvidence,
ReceivePackEvidenceDto as ReceivePackEvidence,
RepositoryPermissionEvidenceDto as RepositoryPermissionEvidence,
} from './credential-result.dto.js';
const JSON_CONTENT_TYPE = 'application/json';
const RUNTIME_SCOPE_NOT_MEASURED = {
state: 'not-measured' as const,
scopes: [] as readonly string[],
source: 'runtime-not-authorized' as const,
};
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
interface ResultOptions {
readonly outcome: CredentialOutcome;
readonly code: string;
readonly message: string;
readonly retryable?: boolean;
readonly evidence?: CredentialValidationEvidenceDto;
}
function subject(request: GiteaReadValidationRequestDto): CredentialValidationResultDto['subject'] {
return {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
};
}
function result(
request: GiteaReadValidationRequestDto,
options: ResultOptions,
): CredentialValidationResultDto {
const exits: Readonly<Record<CredentialOutcome, 0 | 10 | 20 | 30>> = {
ok: 0,
refused: 10,
error: 20,
indeterminate: 30,
};
return {
schemaVersion: 1,
operation: 'validate',
outcome: options.outcome,
exitCode: exits[options.outcome],
retryable: options.retryable ?? false,
subject: subject(request),
mutation: 'none',
reason: { code: options.code, message: options.message },
evidence: options.evidence ?? {
providerIdentity: null,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: null,
writeDifferential: null,
},
audit: { journalId: null, state: 'not-started' },
};
}
function refused(
request: GiteaReadValidationRequestDto,
reason: CredentialReasonDto,
evidence?: CredentialValidationEvidenceDto,
): CredentialValidationResultDto {
return result(request, {
outcome: 'refused',
code: reason.code,
message: reason.message,
...(evidence === undefined ? {} : { evidence }),
});
}
function indeterminate(
request: GiteaReadValidationRequestDto,
reason: CredentialReasonDto,
evidence?: CredentialValidationEvidenceDto,
): CredentialValidationResultDto {
return result(request, {
outcome: 'indeterminate',
code: reason.code,
message: reason.message,
...(evidence === undefined ? {} : { evidence }),
});
}
function providerEvidenceFailure(
request: GiteaReadValidationRequestDto,
error: CredentialProviderEvidenceError,
): CredentialValidationResultDto {
if (error.code === 'credential-rejected') {
return refused(request, {
code: error.code,
message: 'The provider authoritatively rejected the supplied subject credential.',
});
}
return indeterminate(request, {
code: error.code,
message: 'Provider evidence could not be evaluated completely.',
});
}
function identityContentTypeValid(evidence: ProviderIdentityEvidenceDto): boolean {
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
}
function permissionContentTypeValid(evidence: RepositoryPermissionEvidenceDto): boolean {
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
}
function advertised(evidence: ReceivePackEvidenceDto): boolean {
return (
evidence.state === 'advertised' &&
evidence.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
);
}
async function resolveCredential(
request: GiteaWriteValidationRequestDto,
identity: string,
dependencies: CredentialValidationDependencies,
): Promise<ResolvedCredential | undefined> {
return dependencies.resolver.resolve(identity, request.estate, request.host);
}
function successfulEvidence(
subjectLogin: string,
subjectIdentity: ProviderIdentityEvidenceDto | null,
subjectPermission: RepositoryPermissionEvidenceDto,
subjectReceivePack: ReceivePackEvidenceDto,
controlIdentity: ProviderIdentityEvidenceDto,
controlPermission: RepositoryPermissionEvidenceDto,
controlReceivePack: ReceivePackEvidenceDto,
): CredentialValidationEvidenceDto {
const writeDifferential: WriteDifferentialEvidenceDto = {
state: 'can-write',
credentialBinding: 'same-resolution',
transportPrincipal: subjectLogin,
authenticatedReceivePack: 'advertised',
readOnlyControl: {
identity: controlIdentity.login,
providerPermission: controlPermission.effective,
receivePack: controlReceivePack.state,
},
unauthenticatedReceivePack: 'refused',
artifactCreated: false,
proves:
'The declared subject credential authenticated provider identity, repository permission, and write transport while a distinct provider-confirmed read-only principal and an unauthenticated caller were refused.',
doesNotProve:
'A particular ref update will pass branch protection, hooks, races, or content policy.',
};
return {
providerIdentity: subjectIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: subjectPermission,
writeDifferential,
};
}
async function evaluateGiteaReadValidationUnsafe(
request: GiteaReadValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
return refused(request, {
code: 'estate-host-mismatch',
message: 'The declared estate does not contain the declared host.',
});
}
const resolved = await dependencies.resolver.resolve(
request.identity,
request.estate,
request.host,
);
if (resolved === undefined) {
return refused(request, {
code: 'no-token-for-identity',
message: 'The explicit identity has no credential in the declared estate.',
});
}
let providerIdentity: ProviderIdentityEvidenceDto | null;
try {
providerIdentity = await dependencies.provider.readIdentity(resolved);
} catch (error: unknown) {
if (
error instanceof CredentialProviderEvidenceError &&
error.code === 'identity-read-forbidden'
) {
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
resolved,
request.repo,
);
const evidence: CredentialValidationEvidenceDto = {
providerIdentity: null,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission,
writeDifferential: null,
};
if (!permissionContentTypeValid(repositoryPermission)) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'In-scope capability evidence was not JSON.',
},
evidence,
);
}
if (repositoryPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The in-scope provider object denies repository access.',
},
evidence,
);
}
return indeterminate(
request,
{
code: 'identity-not-measured',
message:
'Repository capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.',
},
evidence,
);
}
throw error;
}
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
resolved,
request.repo,
);
const evidence: CredentialValidationEvidenceDto = {
providerIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission,
writeDifferential: null,
};
if (
!identityContentTypeValid(providerIdentity) ||
!permissionContentTypeValid(repositoryPermission)
) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'Provider read evidence was not JSON.',
},
evidence,
);
}
if (repositoryPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The provider repository object denies read permission.',
},
evidence,
);
}
if (providerIdentity.login !== request.identity) {
return refused(
request,
{
code: 'provider-identity-mismatch',
message: 'The provider credential identity does not equal the declared subject.',
},
evidence,
);
}
return result(request, {
outcome: 'ok',
code: 'validation-verified',
message: 'Provider identity and repository permission were read back.',
evidence,
});
}
export async function evaluateGiteaReadValidation(
request: GiteaReadValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
try {
return await evaluateGiteaReadValidationUnsafe(request, dependencies);
} catch (error: unknown) {
if (error instanceof CredentialProviderEvidenceError) {
return providerEvidenceFailure(request, error);
}
throw error;
}
}
async function evaluateGiteaWriteValidationUnsafe(
request: GiteaWriteValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
return refused(request, {
code: 'estate-host-mismatch',
message: 'The declared estate does not contain the declared host.',
});
}
const resolved = await resolveCredential(request, request.identity, dependencies);
if (resolved === undefined) {
return refused(request, {
code: 'no-token-for-identity',
message: 'The explicit identity has no credential in the declared estate.',
});
}
let subjectIdentity: ProviderIdentityEvidenceDto | null = null;
try {
subjectIdentity = await dependencies.provider.readIdentity(resolved);
} catch (error: unknown) {
if (
!(error instanceof CredentialProviderEvidenceError) ||
error.code !== 'identity-read-forbidden'
) {
throw error;
}
}
const subjectPermission = await dependencies.provider.readRepositoryPermission(
resolved,
request.repo,
);
const subjectReceivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
const baseEvidence: CredentialValidationEvidenceDto = {
providerIdentity: subjectIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: subjectPermission,
writeDifferential: null,
};
if (subjectIdentity !== null && !identityContentTypeValid(subjectIdentity)) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'The provider identity response was not JSON.',
},
baseEvidence,
);
}
if (subjectIdentity !== null && subjectIdentity.login !== request.identity) {
return refused(
request,
{
code: 'provider-identity-mismatch',
message: 'The provider credential identity does not equal the declared subject.',
},
baseEvidence,
);
}
if (!permissionContentTypeValid(subjectPermission)) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'The provider repository response was not JSON.',
},
baseEvidence,
);
}
if (request.requiredPermission === 'admin' && subjectPermission.effective !== 'admin') {
return refused(
request,
{
code: 'permission-denied',
message: 'The provider repository object denies required admin permission.',
},
baseEvidence,
);
}
if (subjectPermission.effective === 'read' || subjectPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The provider repository object denies write permission.',
},
baseEvidence,
);
}
if (
subjectReceivePack.principal !== request.identity ||
subjectReceivePack.resolutionId !== resolved.resolutionId
) {
return indeterminate(
request,
{
code: 'transport-principal-mismatch',
message: 'The write transport evidence is not bound to the declared subject credential.',
},
baseEvidence,
);
}
if (!advertised(subjectReceivePack)) {
return indeterminate(
request,
{
code: 'permission-evidence-disagrees',
message: 'Repository permission and write transport evidence disagree.',
},
baseEvidence,
);
}
const control = await resolveCredential(request, request.readOnlyControlIdentity, dependencies);
if (control === undefined) {
return indeterminate(request, {
code: 'read-only-control-invalid',
message: 'The configured read-only control credential could not be resolved.',
});
}
const controlIdentity = await dependencies.provider.readIdentity(control);
const controlPermission = await dependencies.provider.readRepositoryPermission(
control,
request.repo,
);
const controlReceivePack = await dependencies.provider.probeReceivePack(control, request.repo);
const controlIsDistinct =
request.readOnlyControlIdentity !== request.identity &&
control.resolutionId !== resolved.resolutionId;
const controlIdentityMatches =
identityContentTypeValid(controlIdentity) &&
controlIdentity.login === request.readOnlyControlIdentity;
const controlPermissionIsReadOnly =
permissionContentTypeValid(controlPermission) && controlPermission.effective === 'read';
const controlTransportIsBoundAndRefused =
controlReceivePack.state === 'refused' &&
controlReceivePack.principal === request.readOnlyControlIdentity &&
controlReceivePack.resolutionId === control.resolutionId &&
!controlReceivePack.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE);
if (
!controlIsDistinct ||
!controlIdentityMatches ||
!controlPermissionIsReadOnly ||
!controlTransportIsBoundAndRefused
) {
return indeterminate(request, {
code: 'read-only-control-invalid',
message:
'The read-only control was absent, identity-mismatched, write-capable, unbound, or admitted to write transport.',
});
}
const unauthenticated = await dependencies.provider.probeReceivePack(undefined, request.repo);
if (
unauthenticated.state !== 'refused' ||
unauthenticated.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
) {
return indeterminate(request, {
code: 'permission-evidence-disagrees',
message: 'The unauthenticated write-transport control was not refused.',
});
}
const evidence = successfulEvidence(
request.identity,
subjectIdentity,
subjectPermission,
subjectReceivePack,
controlIdentity,
controlPermission,
controlReceivePack,
);
return result(request, {
outcome: subjectIdentity === null ? 'indeterminate' : 'ok',
code: subjectIdentity === null ? 'identity-not-measured' : 'validation-verified',
message:
subjectIdentity === null
? 'Write capability and both controls were confirmed, but identity was not measured because this least-privilege token cannot read /user.'
: 'Every required provider evidence layer agreed.',
evidence,
});
}
export async function evaluateGiteaWriteValidation(
request: GiteaWriteValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
try {
return await evaluateGiteaWriteValidationUnsafe(request, dependencies);
} catch (error: unknown) {
if (error instanceof CredentialProviderEvidenceError) {
return providerEvidenceFailure(request, error);
}
throw error;
}
}