fix(mosaic): preserve grant safety invariants

This commit is contained in:
2026-08-05 13:11:04 -05:00
committed by Hermes Agent
parent 1e58e6c74a
commit 2419313286
13 changed files with 562 additions and 144 deletions
+1 -1
View File
@@ -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.
+13 -4
View File
@@ -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 ||
@@ -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 {
@@ -39,6 +39,29 @@ describe('protected delegated credential channel', (): void => {
}
});
it('rejects a regular-file authority fd with group or other access', async (): Promise<void> => {
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<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
const path = join(cleanup, 'authority');
@@ -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(
@@ -141,6 +141,32 @@ describe('Gitea credential provider transport', (): void => {
});
});
it('cancels an undeclared oversized streaming provider response before buffering it all', async (): Promise<void> => {
let pulls = 0;
let cancelled = false;
const stream = new ReadableStream<Uint8Array>({
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<Response> =>
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<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
@@ -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<Uint8Array> {
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<TeamRepositorySetEvidence> {
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,
@@ -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<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async grantCollaborator(): Promise<void> {},
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<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
+132 -77
View File
@@ -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<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;
@@ -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' },
};
}
@@ -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<void> {},
async attachTeamRepository(): Promise<void> {},
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<void> => {
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<void> {
mutated = true;
},
async attachTeamRepository(): Promise<void> {
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);
});
});
+122 -57
View File
@@ -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<TeamResolutionEvidence>;
listTeamRepositories(
authority: ResolvedCredential,
teamId: number,
): Promise<TeamRepositorySetEvidence>;
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
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<ReturnType<typeof evaluateGiteaWriteValidation>> | 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' },
};
@@ -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<void> => {
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<void> => {
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<void> => {
const { result } = await validate({ controlPermission: 'write' });
@@ -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,