522 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|