195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
import { CredentialAuditJournal } from './audit-journal.js';
|
|
import type {
|
|
CredentialValidationDependencies,
|
|
ResolvedCredential,
|
|
} from './credential-provider.dto.js';
|
|
import type {
|
|
CredentialGrantResultDto,
|
|
DirectGrantRequestDto,
|
|
OrganizationMembershipEvidenceDto,
|
|
} from './grant.dto.js';
|
|
import type { RepositoryPermission } from './credential-result.dto.js';
|
|
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
|
|
|
export interface TeamResolutionEvidence {
|
|
readonly id: number;
|
|
readonly name: string;
|
|
readonly permission: RepositoryPermission;
|
|
readonly endpoint: string;
|
|
readonly contentType: string;
|
|
}
|
|
export interface PresenceEvidence {
|
|
readonly state: 'present' | 'absent';
|
|
readonly endpoint: string;
|
|
readonly contentType: string;
|
|
}
|
|
export interface TeamGrantRequest extends DirectGrantRequestDto {
|
|
readonly team: string;
|
|
}
|
|
export interface TeamGrantResult extends CredentialGrantResultDto {
|
|
readonly evidence: CredentialGrantResultDto['evidence'] & {
|
|
readonly team: TeamResolutionEvidence | null;
|
|
readonly teamMembership: PresenceEvidence | null;
|
|
readonly teamRepository: PresenceEvidence | null;
|
|
};
|
|
}
|
|
export interface GiteaTeamGrantProvider {
|
|
readIdentity(
|
|
authority: ResolvedCredential,
|
|
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
|
|
resolveTeam(
|
|
authority: ResolvedCredential,
|
|
organization: string,
|
|
team: string,
|
|
): Promise<TeamResolutionEvidence>;
|
|
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
|
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
|
readTeamMember(
|
|
authority: ResolvedCredential,
|
|
teamId: number,
|
|
identity: string,
|
|
): Promise<PresenceEvidence>;
|
|
readTeamRepository(
|
|
authority: ResolvedCredential,
|
|
teamId: number,
|
|
repo: string,
|
|
): Promise<PresenceEvidence>;
|
|
readOrganizationMembership(
|
|
subject: ResolvedCredential,
|
|
organization: string,
|
|
): Promise<OrganizationMembershipEvidenceDto>;
|
|
}
|
|
export interface TeamGrantOptions {
|
|
readonly stateRoot: string;
|
|
readonly actor: string;
|
|
}
|
|
|
|
export async function grantTeamRepositoryPermission(
|
|
request: TeamGrantRequest,
|
|
authority: ResolvedCredential,
|
|
provider: GiteaTeamGrantProvider,
|
|
dependencies: CredentialValidationDependencies,
|
|
options: TeamGrantOptions,
|
|
): Promise<TeamGrantResult> {
|
|
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');
|
|
const authorityIdentity = await provider.readIdentity(authority);
|
|
const organization = request.repo.split('/')[0] ?? '';
|
|
const team = await provider.resolveTeam(authority, organization, request.team);
|
|
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
|
await journal.seal('refused', 'provider-identity-mismatch');
|
|
return result(
|
|
request,
|
|
journal,
|
|
'refused',
|
|
'none',
|
|
'provider-identity-mismatch',
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
);
|
|
}
|
|
await provider.addTeamMember(authority, team.id, request.identity);
|
|
await provider.attachTeamRepository(authority, team.id, request.repo);
|
|
const teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
|
const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
|
const subject = await dependencies.resolver.resolve(
|
|
request.identity,
|
|
request.estate,
|
|
request.host,
|
|
);
|
|
const organizationMembership =
|
|
subject === undefined ? null : await provider.readOrganizationMembership(subject, organization);
|
|
const validation =
|
|
request.permission === 'read'
|
|
? await evaluateGiteaReadValidation(request, dependencies)
|
|
: await evaluateGiteaWriteValidation(request, dependencies);
|
|
const ok =
|
|
teamMembership.state === 'present' &&
|
|
teamRepository.state === 'present' &&
|
|
organizationMembership?.state === 'present' &&
|
|
validation.outcome === 'ok' &&
|
|
validation.evidence.repositoryPermission?.effective === request.permission;
|
|
await journal.recordProviderEvidence({
|
|
endpoint: teamMembership.endpoint,
|
|
contentType: teamMembership.contentType,
|
|
decision: 'team-member-present',
|
|
});
|
|
await journal.recordProviderEvidence({
|
|
endpoint: teamRepository.endpoint,
|
|
contentType: teamRepository.contentType,
|
|
decision: 'team-repository-present',
|
|
});
|
|
await journal.seal(
|
|
ok ? 'ok' : 'indeterminate',
|
|
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
|
);
|
|
return result(
|
|
request,
|
|
journal,
|
|
ok ? 'ok' : 'indeterminate',
|
|
'applied',
|
|
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
|
validation,
|
|
team,
|
|
teamMembership,
|
|
teamRepository,
|
|
organizationMembership,
|
|
);
|
|
}
|
|
|
|
function result(
|
|
request: TeamGrantRequest,
|
|
journal: CredentialAuditJournal,
|
|
outcome: 'ok' | 'refused' | 'indeterminate',
|
|
mutation: 'none' | 'applied',
|
|
code: string,
|
|
validation: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>> | null,
|
|
team: TeamResolutionEvidence | null,
|
|
teamMembership: PresenceEvidence | null,
|
|
teamRepository: PresenceEvidence | null,
|
|
organizationMembership: OrganizationMembershipEvidenceDto | null,
|
|
): TeamGrantResult {
|
|
return {
|
|
schemaVersion: 1,
|
|
operation: 'grant',
|
|
outcome,
|
|
exitCode: outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30,
|
|
retryable: false,
|
|
subject: {
|
|
identity: request.identity,
|
|
estate: request.estate,
|
|
host: request.host,
|
|
repo: request.repo,
|
|
},
|
|
mutation,
|
|
reason: {
|
|
code,
|
|
message:
|
|
outcome === 'ok'
|
|
? 'Team grant matched every provider read-back.'
|
|
: 'Team grant was refused or could not be established.',
|
|
},
|
|
evidence: {
|
|
providerIdentity: validation?.evidence.providerIdentity ?? null,
|
|
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
|
|
writeDifferential: validation?.evidence.writeDifferential ?? null,
|
|
collaboratorPermission: null,
|
|
organizationMembership,
|
|
team,
|
|
teamMembership,
|
|
teamRepository,
|
|
},
|
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
};
|
|
}
|