From 2419313286b5e127401ddaa3d94a433c0033fbc1 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:14:54 -0500 Subject: [PATCH] fix(mosaic): preserve grant safety invariants --- docs/credentials/GRANT-VALIDATE-CONTRACT.md | 2 +- packages/mosaic/src/commands/cred.ts | 17 +- .../credentials/credential-provider.dto.ts | 2 + .../credentials/delegated-credential.spec.ts | 23 ++ .../src/credentials/delegated-credential.ts | 4 + .../src/credentials/gitea-provider.spec.ts | 26 +++ .../mosaic/src/credentials/gitea-provider.ts | 88 +++++++- packages/mosaic/src/credentials/grant.spec.ts | 41 ++++ packages/mosaic/src/credentials/grant.ts | 209 +++++++++++------- .../mosaic/src/credentials/team-grant.spec.ts | 84 +++++++ packages/mosaic/src/credentials/team-grant.ts | 179 ++++++++++----- .../mosaic/src/credentials/validate.spec.ts | 21 ++ packages/mosaic/src/credentials/validate.ts | 10 + 13 files changed, 562 insertions(+), 144 deletions(-) diff --git a/docs/credentials/GRANT-VALIDATE-CONTRACT.md b/docs/credentials/GRANT-VALIDATE-CONTRACT.md index ee45eb72..10133059 100644 --- a/docs/credentials/GRANT-VALIDATE-CONTRACT.md +++ b/docs/credentials/GRANT-VALIDATE-CONTRACT.md @@ -193,4 +193,4 @@ No ref is updated and no repository artifact is created. This proves that the de ## Grant read-back -A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant additionally requires provider-read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another. +A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant additionally enumerates the team's complete repository attachment set before mutation and refuses any team already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). It then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another. diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 5388e717..6b7fcb84 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -13,7 +13,10 @@ import type { RepositoryPermission, } from '../credentials/credential-result.dto.js'; import { readDelegatedCredentialFromFd } from '../credentials/delegated-credential.js'; -import { grantDirectRepositoryPermission } from '../credentials/grant.js'; +import { + CredentialGrantExecutionError, + 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'; @@ -125,10 +128,11 @@ export async function executeCredentialValidate( host: options.host, repo: options.repo, readOnlyControlIdentity: options.readOnlyControl ?? '(unresolved)', + requiredPermission: options.require as RepositoryPermission, }; try { - if (options.require !== 'read' && options.require !== 'write') { + if (!['read', 'write', 'admin'].includes(options.require)) { return errorResult(request, 'invalid-input'); } const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath)); @@ -171,6 +175,8 @@ function grantErrorResult( identity: string, options: CredentialGrantCommandOptions, code: string, + mutation: 'none' | 'unknown' | 'applied' = 'none', + journalId: string | null = null, ): CredentialGrantResultDto { return { schemaVersion: 1, @@ -179,7 +185,7 @@ function grantErrorResult( exitCode: 20, retryable: false, subject: { identity, estate: options.estate, host: options.host, repo: options.repo }, - mutation: 'none', + mutation, reason: { code, message: 'The local grant control failed before an access verdict was available.', @@ -191,7 +197,7 @@ function grantErrorResult( collaboratorPermission: null, organizationMembership: null, }, - audit: { journalId: null, state: 'not-started' }, + audit: { journalId, state: journalId === null ? 'not-started' : 'open' }, }; } @@ -261,6 +267,9 @@ export async function executeCredentialGrant( serviceOptions, ); } catch (error: unknown) { + if (error instanceof CredentialGrantExecutionError) { + return grantErrorResult(identity, options, error.code, error.mutation, error.journalId); + } if ( error instanceof CredentialJournalError || error instanceof CredentialEstateRegistryError || diff --git a/packages/mosaic/src/credentials/credential-provider.dto.ts b/packages/mosaic/src/credentials/credential-provider.dto.ts index 9970a689..a7807965 100644 --- a/packages/mosaic/src/credentials/credential-provider.dto.ts +++ b/packages/mosaic/src/credentials/credential-provider.dto.ts @@ -1,6 +1,7 @@ import type { ProviderIdentityEvidenceDto, ReceivePackEvidenceDto, + RepositoryPermission, RepositoryPermissionEvidenceDto, } from './credential-result.dto.js'; @@ -43,6 +44,7 @@ export interface GiteaReadValidationRequestDto { readonly estate: string; readonly host: string; readonly repo: string; + readonly requiredPermission?: RepositoryPermission; } export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto { diff --git a/packages/mosaic/src/credentials/delegated-credential.spec.ts b/packages/mosaic/src/credentials/delegated-credential.spec.ts index 0f62c1ed..cac708d8 100644 --- a/packages/mosaic/src/credentials/delegated-credential.spec.ts +++ b/packages/mosaic/src/credentials/delegated-credential.spec.ts @@ -39,6 +39,29 @@ describe('protected delegated credential channel', (): void => { } }); + it('rejects a regular-file authority fd with group or other access', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-')); + const path = join(cleanup, 'authority'); + await writeFile( + path, + JSON.stringify({ + identity: 'provisioner', + estate: 'homelab', + host: 'git.example.invalid', + secret: 'seeded-authority-canary', + }), + { mode: 0o644 }, + ); + const handle = await open(path, 'r'); + try { + await expect( + readDelegatedCredentialFromFd(handle.fd, 'provisioner', 'homelab', 'git.example.invalid'), + ).rejects.toMatchObject({ code: 'delegated-authority-unavailable' }); + } finally { + await handle.close(); + } + }); + it('rejects an authority identity or estate mismatch without echoing the secret', async (): Promise => { cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-')); const path = join(cleanup, 'authority'); diff --git a/packages/mosaic/src/credentials/delegated-credential.ts b/packages/mosaic/src/credentials/delegated-credential.ts index e2abc423..84bca6ec 100644 --- a/packages/mosaic/src/credentials/delegated-credential.ts +++ b/packages/mosaic/src/credentials/delegated-credential.ts @@ -42,6 +42,10 @@ export async function readDelegatedCredentialFromFd( if (!stat.isFile() && !stat.isFIFO()) { throw new Error('fd is not a regular file or pipe'); } + const currentUid = process.getuid?.(); + if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) { + throw new Error('fd owner or permissions are unsafe'); + } bytes = await readFile(`/proc/self/fd/${fd}`); } catch { throw new DelegatedCredentialError( diff --git a/packages/mosaic/src/credentials/gitea-provider.spec.ts b/packages/mosaic/src/credentials/gitea-provider.spec.ts index 9384f872..301a3677 100644 --- a/packages/mosaic/src/credentials/gitea-provider.spec.ts +++ b/packages/mosaic/src/credentials/gitea-provider.spec.ts @@ -141,6 +141,32 @@ describe('Gitea credential provider transport', (): void => { }); }); + it('cancels an undeclared oversized streaming provider response before buffering it all', async (): Promise => { + let pulls = 0; + let cancelled = false; + const stream = new ReadableStream({ + pull(controller): void { + pulls += 1; + controller.enqueue(new Uint8Array(64 * 1024)); + if (pulls === 100) controller.close(); + }, + cancel(): void { + cancelled = true; + }, + }); + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (): Promise => + new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }), + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'unexpected-provider-shape', + }); + expect(pulls).toBeLessThan(100); + expect(cancelled).toBe(true); + }); + it('rejects a 200 HTML identity response as unexpected content type', async (): Promise => { const adapter = new GiteaCredentialProviderAdapter( 'https://git.example.invalid', diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index cc00101a..8ce52a3f 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -4,6 +4,7 @@ import type { GiteaGrantProvider } from './grant.js'; import type { GiteaTeamGrantProvider, PresenceEvidence, + TeamRepositorySetEvidence, TeamResolutionEvidence, } from './team-grant.js'; import type { @@ -85,21 +86,53 @@ function isJson(response: Response): boolean { async function boundedBody(response: Response): Promise { const declared = response.headers.get('content-length'); if (declared !== null) { - const bytes = Number.parseInt(declared, 10); - if (Number.isFinite(bytes) && bytes > MAX_PROVIDER_BYTES) { + if (!/^\d+$/.test(declared)) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider response declared an invalid content length', + ); + } + const bytes = Number(declared); + if (!Number.isSafeInteger(bytes) || bytes > MAX_PROVIDER_BYTES) { throw new CredentialProviderEvidenceError( 'unexpected-provider-shape', 'provider response exceeded the bounded size', ); } } - const body = new Uint8Array(await response.arrayBuffer()); - if (body.byteLength > MAX_PROVIDER_BYTES) { + if (response.body === null) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_PROVIDER_BYTES) { + await reader.cancel(); + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'provider response exceeded the bounded size', + ); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + if (declared !== null && total !== Number(declared)) { throw new CredentialProviderEvidenceError( 'unexpected-provider-shape', - 'provider response exceeded the bounded size', + 'provider response length contradicted its declaration', ); } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } return body; } @@ -486,6 +519,51 @@ export class GiteaTeamGrantProviderAdapter return { ...matches[0], endpoint, contentType: contentType(response) }; } + async listTeamRepositories( + authority: ResolvedCredential, + teamId: number, + ): Promise { + const endpoint = `GET /api/v1/teams/${teamId.toString()}/repos`; + const repositories: string[] = []; + let observedType = ''; + for (let page = 1; page <= 100; page += 1) { + const response = await this.request( + `${this.origin}/api/v1/teams/${teamId.toString()}/repos?limit=50&page=${page.toString()}`, + { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: apiAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'team repository set was unavailable', + ); + } + observedType = contentType(response); + const parsed = z.array(repoSchema).safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'team repository set was not a repository array', + ); + } + repositories.push(...parsed.data.map((repo): string => repo.full_name)); + if (parsed.data.length < 50) { + return { repositories, endpoint, contentType: observedType }; + } + } + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'team repository set exceeded the pagination bound', + ); + } + async addTeamMember( authority: ResolvedCredential, teamId: number, diff --git a/packages/mosaic/src/credentials/grant.spec.ts b/packages/mosaic/src/credentials/grant.spec.ts index cbb49bf5..26838b90 100644 --- a/packages/mosaic/src/credentials/grant.spec.ts +++ b/packages/mosaic/src/credentials/grant.spec.ts @@ -132,6 +132,47 @@ describe('direct repository grant', (): void => { expect(result.audit.state).toBe('sealed'); }); + it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise => { + const root = await stateRoot(); + const provider: GiteaGrantProvider = { + async readIdentity() { + return { + login: 'provisioner', + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async grantCollaborator(): Promise {}, + async readCollaboratorPermission() { + throw new Error('read-back unavailable'); + }, + async readOrganizationMembership() { + throw new Error('must not be reached'); + }, + }; + + const result = await grantDirectRepositoryPermission( + { + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + permission: 'write', + readOnlyControlIdentity: 'read-control', + }, + authority, + provider, + validationDependencies('write'), + { stateRoot: root, actor: 'provisioner' }, + ); + + expect(result.outcome).toBe('indeterminate'); + expect(result.mutation).toBe('applied'); + expect(result.reason.code).toBe('readback-missing'); + expect(result.audit.journalId).not.toBeNull(); + expect(result.audit.state).toBe('sealed'); + }); + it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise => { const root = await stateRoot(); const provider: GiteaGrantProvider = { diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index dbb3d95d..fa63e38a 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -1,4 +1,4 @@ -import { CredentialAuditJournal } from './audit-journal.js'; +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; import type { CredentialValidationDependencies, ResolvedCredential, @@ -35,6 +35,17 @@ export interface GiteaGrantProvider { ): Promise; } +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; @@ -63,14 +74,86 @@ export async function grantDirectRepositoryPermission( repo: request.repo, }); await journal.recordIntent('provider-grant'); - const authorityIdentity = await grantProvider.readIdentity(authority); - if (authorityIdentity.login !== options.actor) { - await journal.seal('refused', 'provider-identity-mismatch'); + 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, + repositoryPermission: null, + writeDifferential: null, + collaboratorPermission: null, + organizationMembership: null, + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; + } + mutation = 'unknown'; + await grantProvider.grantCollaborator( + authority, + request.identity, + request.repo, + request.permission, + ); + mutation = '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); + + 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: 'refused', - exitCode: 10, + outcome, + exitCode: exitFor(outcome), retryable: false, subject: { identity: request.identity, @@ -78,13 +161,52 @@ export async function grantDirectRepositoryPermission( host: request.host, repo: request.repo, }, - mutation: 'none', + mutation: 'applied', reason: { - code: 'provider-identity-mismatch', - message: 'Delegated grant authority did not authenticate as the explicit audit actor.', + code: reasonCode, + message: readBackMatches + ? 'Grant matched every required provider read-back.' + : 'Grant mutation completed but provider permission evidence disagreed.', }, evidence: { - providerIdentity: authorityIdentity, + ...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, repositoryPermission: null, writeDifferential: null, collaboratorPermission: null, @@ -93,71 +215,4 @@ export async function grantDirectRepositoryPermission( audit: { journalId: journal.journalId(), state: 'sealed' }, }; } - await grantProvider.grantCollaborator( - authority, - request.identity, - request.repo, - request.permission, - ); - - 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); - - 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' }, - }; } diff --git a/packages/mosaic/src/credentials/team-grant.spec.ts b/packages/mosaic/src/credentials/team-grant.spec.ts index c16e9cbb..b5da30ae 100644 --- a/packages/mosaic/src/credentials/team-grant.spec.ts +++ b/packages/mosaic/src/credentials/team-grant.spec.ts @@ -90,6 +90,13 @@ describe('team repository grant', (): void => { contentType: 'application/json', }; }, + async listTeamRepositories() { + return { + repositories: [], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, async addTeamMember(): Promise {}, async attachTeamRepository(): Promise {}, async readTeamMember() { @@ -136,4 +143,81 @@ describe('team repository grant', (): void => { expect(result.evidence.teamMembership?.state).toBe('present'); expect(result.evidence.teamRepository?.state).toBe('present'); }); + + it('refuses a shared team already attached to any repository outside the request', async (): Promise => { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-')); + let mutated = false; + const provider: GiteaTeamGrantProvider = { + async readIdentity() { + return { + login: 'provisioner', + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }; + }, + async resolveTeam() { + return { + id: 7, + name: 'writers', + permission: 'write', + endpoint: 'GET /api/v1/orgs/owner/teams', + contentType: 'application/json', + }; + }, + async listTeamRepositories() { + return { + repositories: ['owner/unrelated'], + endpoint: 'GET /api/v1/teams/7/repos', + contentType: 'application/json', + }; + }, + async addTeamMember(): Promise { + mutated = true; + }, + async attachTeamRepository(): Promise { + mutated = true; + }, + async readTeamMember() { + return { + state: 'absent', + endpoint: 'GET /api/v1/teams/7/members/seat-name', + contentType: 'application/json', + }; + }, + async readTeamRepository() { + return { + state: 'absent', + endpoint: 'GET /api/v1/teams/7/repos/owner/repo', + contentType: 'application/json', + }; + }, + async readOrganizationMembership() { + return { + state: 'absent', + endpoint: 'GET /api/v1/users/seat-name/orgs', + contentType: 'application/json', + }; + }, + }; + + const result = await grantTeamRepositoryPermission( + { + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + permission: 'write', + team: 'writers', + readOnlyControlIdentity: 'read-control', + }, + authority, + provider, + validation(), + { stateRoot: join(cleanup, 'state'), actor: 'provisioner' }, + ); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('team-scope-exceeds-request'); + expect(mutated).toBe(false); + }); }); diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts index 7d0f2c76..30c8d80a 100644 --- a/packages/mosaic/src/credentials/team-grant.ts +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -1,4 +1,4 @@ -import { CredentialAuditJournal } from './audit-journal.js'; +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; import type { CredentialValidationDependencies, ResolvedCredential, @@ -9,6 +9,7 @@ import type { OrganizationMembershipEvidenceDto, } from './grant.dto.js'; import type { RepositoryPermission } from './credential-result.dto.js'; +import { CredentialGrantExecutionError } from './grant.js'; import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js'; export interface TeamResolutionEvidence { @@ -23,6 +24,11 @@ export interface PresenceEvidence { readonly endpoint: string; readonly contentType: string; } +export interface TeamRepositorySetEvidence { + readonly repositories: readonly string[]; + readonly endpoint: string; + readonly contentType: string; +} export interface TeamGrantRequest extends DirectGrantRequestDto { readonly team: string; } @@ -31,6 +37,7 @@ export interface TeamGrantResult extends CredentialGrantResultDto { readonly team: TeamResolutionEvidence | null; readonly teamMembership: PresenceEvidence | null; readonly teamRepository: PresenceEvidence | null; + readonly teamRepositorySet: TeamRepositorySetEvidence | null; }; } export interface GiteaTeamGrantProvider { @@ -42,6 +49,10 @@ export interface GiteaTeamGrantProvider { organization: string, team: string, ): Promise; + listTeamRepositories( + authority: ResolvedCredential, + teamId: number, + ): Promise; addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise; attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise; readTeamMember( @@ -80,17 +91,116 @@ export async function grantTeamRepositoryPermission( 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'); + let mutation: 'none' | 'unknown' | 'applied' = 'none'; + try { + 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, + null, + ); + } + const teamRepositorySet = await provider.listTeamRepositories(authority, team.id); + if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) { + await journal.seal('refused', 'team-scope-exceeds-request'); + return result( + request, + journal, + 'refused', + 'none', + 'team-scope-exceeds-request', + null, + team, + null, + null, + null, + teamRepositorySet, + ); + } + mutation = 'unknown'; + await provider.addTeamMember(authority, team.id, request.identity); + mutation = 'applied'; + 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, - 'refused', - 'none', - 'provider-identity-mismatch', + ok ? 'ok' : 'indeterminate', + 'applied', + ok ? 'grant-verified' : 'permission-evidence-disagrees', + validation, + team, + teamMembership, + teamRepository, + organizationMembership, + teamRepositorySet, + ); + } 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 result( + request, + journal, + 'indeterminate', + mutation, + reasonCode, + null, null, null, null, @@ -98,66 +208,20 @@ export async function grantTeamRepositoryPermission( 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', + mutation: 'none' | 'unknown' | 'applied', code: string, validation: Awaited> | null, team: TeamResolutionEvidence | null, teamMembership: PresenceEvidence | null, teamRepository: PresenceEvidence | null, organizationMembership: OrganizationMembershipEvidenceDto | null, + teamRepositorySet: TeamRepositorySetEvidence | null, ): TeamGrantResult { return { schemaVersion: 1, @@ -188,6 +252,7 @@ function result( team, teamMembership, teamRepository, + teamRepositorySet, }, audit: { journalId: journal.journalId(), state: 'sealed' }, }; diff --git a/packages/mosaic/src/credentials/validate.spec.ts b/packages/mosaic/src/credentials/validate.spec.ts index 57d407ae..30705b9f 100644 --- a/packages/mosaic/src/credentials/validate.spec.ts +++ b/packages/mosaic/src/credentials/validate.spec.ts @@ -24,6 +24,7 @@ interface FixtureOptions { readonly controlTransportPrincipal?: string; readonly unauthenticatedTransportState?: 'advertised' | 'refused'; readonly omitControl?: boolean; + readonly requiredPermission?: 'read' | 'write' | 'admin'; } interface Fixture { @@ -150,6 +151,7 @@ async function validate(options: FixtureOptions = {}): Promise<{ host: HOST, repo: REPO, readOnlyControlIdentity: CONTROL, + requiredPermission: options.requiredPermission, }, observed.dependencies, ); @@ -269,6 +271,25 @@ describe('principal-bound Gitea write validation contract v1.1', (): void => { expect(result.reason.code).toBe('permission-evidence-disagrees'); }); + it('refuses write permission when admin permission is explicitly required', async (): Promise => { + const { result } = await validate({ + requiredPermission: 'admin', + subjectPermission: 'write', + }); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('permission-denied'); + }); + + it('accepts admin permission when admin is explicitly required', async (): Promise => { + const { result } = await validate({ + requiredPermission: 'admin', + subjectPermission: 'admin', + }); + + expect(result.outcome).toBe('ok'); + }); + it('makes a write-capable read-only control invalidate the entire result', async (): Promise => { const { result } = await validate({ controlPermission: 'write' }); diff --git a/packages/mosaic/src/credentials/validate.ts b/packages/mosaic/src/credentials/validate.ts index e276f447..bdce5153 100644 --- a/packages/mosaic/src/credentials/validate.ts +++ b/packages/mosaic/src/credentials/validate.ts @@ -361,6 +361,16 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } + if (request.requiredPermission === 'admin' && subjectEvidence.permission.effective !== 'admin') { + return refused( + request, + { + code: 'permission-denied', + message: 'The provider repository object denies required admin permission.', + }, + baseEvidence, + ); + } if (subjectEvidence.permission.effective === 'read') { return refused( request,