263 lines
8.6 KiB
TypeScript
263 lines
8.6 KiB
TypeScript
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
import type {
|
|
CredentialValidationDependencies,
|
|
ResolvedCredential,
|
|
} from './credential-provider.dto.js';
|
|
import type { RepositoryPermission } from './credential-result.dto.js';
|
|
import type {
|
|
CollaboratorPermissionEvidenceDto,
|
|
CredentialGrantResultDto,
|
|
DirectGrantRequestDto,
|
|
OrganizationMembershipEvidenceDto,
|
|
} from './grant.dto.js';
|
|
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
|
|
|
export interface GiteaGrantProvider {
|
|
readIdentity(authority: ResolvedCredential): Promise<{
|
|
readonly login: string;
|
|
readonly endpoint: string;
|
|
readonly contentType: string;
|
|
}>;
|
|
grantCollaborator(
|
|
authority: ResolvedCredential,
|
|
identity: string,
|
|
repo: string,
|
|
permission: RepositoryPermission,
|
|
): Promise<void>;
|
|
readCollaboratorPermission(
|
|
authority: ResolvedCredential,
|
|
identity: string,
|
|
repo: string,
|
|
): Promise<CollaboratorPermissionEvidenceDto>;
|
|
readOrganizationMembership(
|
|
subject: ResolvedCredential,
|
|
organization: string,
|
|
): Promise<OrganizationMembershipEvidenceDto>;
|
|
}
|
|
|
|
export class CredentialGrantExecutionError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
public readonly mutation: 'none' | 'unknown' | 'applied',
|
|
public readonly journalId: string,
|
|
) {
|
|
super(`Credential grant control failed: code=${code}`);
|
|
this.name = 'CredentialGrantExecutionError';
|
|
}
|
|
}
|
|
|
|
export interface CredentialGrantServiceOptions {
|
|
readonly stateRoot: string;
|
|
readonly actor: string;
|
|
}
|
|
|
|
function exitFor(outcome: CredentialGrantResultDto['outcome']): 0 | 10 | 20 | 30 {
|
|
if (outcome === 'ok') return 0;
|
|
if (outcome === 'refused') return 10;
|
|
if (outcome === 'error') return 20;
|
|
return 30;
|
|
}
|
|
|
|
export async function grantDirectRepositoryPermission(
|
|
request: DirectGrantRequestDto,
|
|
authority: ResolvedCredential,
|
|
grantProvider: GiteaGrantProvider,
|
|
validationDependencies: CredentialValidationDependencies,
|
|
options: CredentialGrantServiceOptions,
|
|
): Promise<CredentialGrantResultDto> {
|
|
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
|
operation: 'grant',
|
|
actor: options.actor,
|
|
identity: request.identity,
|
|
estate: request.estate,
|
|
host: request.host,
|
|
repo: request.repo,
|
|
});
|
|
await journal.recordIntent('provider-grant');
|
|
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
|
try {
|
|
const authorityIdentity = await grantProvider.readIdentity(authority);
|
|
if (authorityIdentity.login !== options.actor) {
|
|
await journal.seal('refused', 'provider-identity-mismatch');
|
|
return {
|
|
schemaVersion: 1,
|
|
operation: 'grant',
|
|
outcome: 'refused',
|
|
exitCode: 10,
|
|
retryable: false,
|
|
subject: {
|
|
identity: request.identity,
|
|
estate: request.estate,
|
|
host: request.host,
|
|
repo: request.repo,
|
|
},
|
|
mutation: 'none',
|
|
reason: {
|
|
code: 'provider-identity-mismatch',
|
|
message: 'Delegated grant authority did not authenticate as the explicit audit actor.',
|
|
},
|
|
evidence: {
|
|
providerIdentity: authorityIdentity,
|
|
tokenCapabilities: {
|
|
state: 'not-measured',
|
|
scopes: [],
|
|
source: 'runtime-not-authorized',
|
|
},
|
|
repositoryPermission: null,
|
|
writeDifferential: null,
|
|
collaboratorPermission: null,
|
|
organizationMembership: null,
|
|
},
|
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
};
|
|
}
|
|
await journal.recordProviderEvidence({
|
|
endpoint: authorityIdentity.endpoint,
|
|
contentType: authorityIdentity.contentType,
|
|
decision: 'identity-verified',
|
|
});
|
|
mutation = 'unknown';
|
|
await grantProvider.grantCollaborator(
|
|
authority,
|
|
request.identity,
|
|
request.repo,
|
|
request.permission,
|
|
);
|
|
mutation = 'applied';
|
|
await journal.recordMutation('collaborator-grant-applied');
|
|
|
|
const collaborator = await grantProvider.readCollaboratorPermission(
|
|
authority,
|
|
request.identity,
|
|
request.repo,
|
|
);
|
|
const subject = await validationDependencies.resolver.resolve(
|
|
request.identity,
|
|
request.estate,
|
|
request.host,
|
|
);
|
|
const organization = request.repo.split('/')[0] ?? '';
|
|
const organizationMembership =
|
|
subject === undefined
|
|
? null
|
|
: await grantProvider.readOrganizationMembership(subject, organization);
|
|
const validation =
|
|
request.permission === 'read'
|
|
? await evaluateGiteaReadValidation(request, validationDependencies)
|
|
: await evaluateGiteaWriteValidation(request, validationDependencies);
|
|
|
|
if (organizationMembership !== null) {
|
|
await journal.recordProviderEvidence({
|
|
endpoint: organizationMembership.endpoint,
|
|
contentType: organizationMembership.contentType,
|
|
decision:
|
|
organizationMembership.state === 'present'
|
|
? 'organization-member-present'
|
|
: 'organization-member-absent',
|
|
});
|
|
}
|
|
if (validation.evidence.providerIdentity !== null) {
|
|
await journal.recordProviderEvidence({
|
|
endpoint: validation.evidence.providerIdentity.endpoint,
|
|
contentType: validation.evidence.providerIdentity.contentType,
|
|
decision: 'identity-verified',
|
|
});
|
|
}
|
|
if (validation.evidence.repositoryPermission !== null) {
|
|
await journal.recordProviderEvidence({
|
|
endpoint: validation.evidence.repositoryPermission.endpoint,
|
|
contentType: validation.evidence.repositoryPermission.contentType,
|
|
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
|
|
});
|
|
}
|
|
if (validation.evidence.writeDifferential !== null) {
|
|
await journal.recordMutation('transport-write-verified');
|
|
}
|
|
|
|
const readBackMatches =
|
|
collaborator.identity === request.identity &&
|
|
collaborator.permission === request.permission &&
|
|
validation.outcome === 'ok' &&
|
|
validation.evidence.repositoryPermission?.effective === request.permission;
|
|
const outcome: CredentialGrantResultDto['outcome'] = readBackMatches ? 'ok' : 'indeterminate';
|
|
const reasonCode = readBackMatches ? 'grant-verified' : 'permission-evidence-disagrees';
|
|
await journal.recordProviderEvidence({
|
|
endpoint: collaborator.endpoint,
|
|
contentType: collaborator.contentType,
|
|
decision: `permission-${collaborator.permission}`,
|
|
});
|
|
await journal.seal(outcome, reasonCode);
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
operation: 'grant',
|
|
outcome,
|
|
exitCode: exitFor(outcome),
|
|
retryable: false,
|
|
subject: {
|
|
identity: request.identity,
|
|
estate: request.estate,
|
|
host: request.host,
|
|
repo: request.repo,
|
|
},
|
|
mutation: 'applied',
|
|
reason: {
|
|
code: reasonCode,
|
|
message: readBackMatches
|
|
? 'Grant matched every required provider read-back.'
|
|
: 'Grant mutation completed but provider permission evidence disagreed.',
|
|
},
|
|
evidence: {
|
|
...validation.evidence,
|
|
collaboratorPermission: collaborator,
|
|
organizationMembership,
|
|
},
|
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
};
|
|
} catch (error: unknown) {
|
|
if (error instanceof CredentialJournalError) {
|
|
throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId());
|
|
}
|
|
const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown';
|
|
try {
|
|
await journal.seal('indeterminate', reasonCode);
|
|
} catch (journalError: unknown) {
|
|
if (journalError instanceof CredentialJournalError) {
|
|
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
|
|
}
|
|
throw journalError;
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
operation: 'grant',
|
|
outcome: 'indeterminate',
|
|
exitCode: 30,
|
|
retryable: false,
|
|
subject: {
|
|
identity: request.identity,
|
|
estate: request.estate,
|
|
host: request.host,
|
|
repo: request.repo,
|
|
},
|
|
mutation,
|
|
reason: {
|
|
code: reasonCode,
|
|
message: 'Grant mutation state was preserved after provider evidence failed.',
|
|
},
|
|
evidence: {
|
|
providerIdentity: null,
|
|
tokenCapabilities: {
|
|
state: 'not-measured',
|
|
scopes: [],
|
|
source: 'runtime-not-authorized',
|
|
},
|
|
repositoryPermission: null,
|
|
writeDifferential: null,
|
|
collaboratorPermission: null,
|
|
organizationMembership: null,
|
|
},
|
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
};
|
|
}
|
|
}
|