fix(mosaic): preserve grant safety invariants
This commit is contained in:
@@ -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,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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user