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
|
## 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,
|
RepositoryPermission,
|
||||||
} from '../credentials/credential-result.dto.js';
|
} from '../credentials/credential-result.dto.js';
|
||||||
import { readDelegatedCredentialFromFd } from '../credentials/delegated-credential.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 type { CredentialGrantResultDto } from '../credentials/grant.dto.js';
|
||||||
import { grantTeamRepositoryPermission } from '../credentials/team-grant.js';
|
import { grantTeamRepositoryPermission } from '../credentials/team-grant.js';
|
||||||
import type { TeamGrantResult } from '../credentials/team-grant.js';
|
import type { TeamGrantResult } from '../credentials/team-grant.js';
|
||||||
@@ -125,10 +128,11 @@ export async function executeCredentialValidate(
|
|||||||
host: options.host,
|
host: options.host,
|
||||||
repo: options.repo,
|
repo: options.repo,
|
||||||
readOnlyControlIdentity: options.readOnlyControl ?? '(unresolved)',
|
readOnlyControlIdentity: options.readOnlyControl ?? '(unresolved)',
|
||||||
|
requiredPermission: options.require as RepositoryPermission,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (options.require !== 'read' && options.require !== 'write') {
|
if (!['read', 'write', 'admin'].includes(options.require)) {
|
||||||
return errorResult(request, 'invalid-input');
|
return errorResult(request, 'invalid-input');
|
||||||
}
|
}
|
||||||
const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath));
|
const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath));
|
||||||
@@ -171,6 +175,8 @@ function grantErrorResult(
|
|||||||
identity: string,
|
identity: string,
|
||||||
options: CredentialGrantCommandOptions,
|
options: CredentialGrantCommandOptions,
|
||||||
code: string,
|
code: string,
|
||||||
|
mutation: 'none' | 'unknown' | 'applied' = 'none',
|
||||||
|
journalId: string | null = null,
|
||||||
): CredentialGrantResultDto {
|
): CredentialGrantResultDto {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -179,7 +185,7 @@ function grantErrorResult(
|
|||||||
exitCode: 20,
|
exitCode: 20,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
subject: { identity, estate: options.estate, host: options.host, repo: options.repo },
|
subject: { identity, estate: options.estate, host: options.host, repo: options.repo },
|
||||||
mutation: 'none',
|
mutation,
|
||||||
reason: {
|
reason: {
|
||||||
code,
|
code,
|
||||||
message: 'The local grant control failed before an access verdict was available.',
|
message: 'The local grant control failed before an access verdict was available.',
|
||||||
@@ -191,7 +197,7 @@ function grantErrorResult(
|
|||||||
collaboratorPermission: null,
|
collaboratorPermission: null,
|
||||||
organizationMembership: 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,
|
serviceOptions,
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof CredentialGrantExecutionError) {
|
||||||
|
return grantErrorResult(identity, options, error.code, error.mutation, error.journalId);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
error instanceof CredentialJournalError ||
|
error instanceof CredentialJournalError ||
|
||||||
error instanceof CredentialEstateRegistryError ||
|
error instanceof CredentialEstateRegistryError ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ProviderIdentityEvidenceDto,
|
ProviderIdentityEvidenceDto,
|
||||||
ReceivePackEvidenceDto,
|
ReceivePackEvidenceDto,
|
||||||
|
RepositoryPermission,
|
||||||
RepositoryPermissionEvidenceDto,
|
RepositoryPermissionEvidenceDto,
|
||||||
} from './credential-result.dto.js';
|
} from './credential-result.dto.js';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export interface GiteaReadValidationRequestDto {
|
|||||||
readonly estate: string;
|
readonly estate: string;
|
||||||
readonly host: string;
|
readonly host: string;
|
||||||
readonly repo: string;
|
readonly repo: string;
|
||||||
|
readonly requiredPermission?: RepositoryPermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
|
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> => {
|
it('rejects an authority identity or estate mismatch without echoing the secret', async (): Promise<void> => {
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
||||||
const path = join(cleanup, 'authority');
|
const path = join(cleanup, 'authority');
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export async function readDelegatedCredentialFromFd(
|
|||||||
if (!stat.isFile() && !stat.isFIFO()) {
|
if (!stat.isFile() && !stat.isFIFO()) {
|
||||||
throw new Error('fd is not a regular file or pipe');
|
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}`);
|
bytes = await readFile(`/proc/self/fd/${fd}`);
|
||||||
} catch {
|
} catch {
|
||||||
throw new DelegatedCredentialError(
|
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> => {
|
it('rejects a 200 HTML identity response as unexpected content type', async (): Promise<void> => {
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
const adapter = new GiteaCredentialProviderAdapter(
|
||||||
'https://git.example.invalid',
|
'https://git.example.invalid',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { GiteaGrantProvider } from './grant.js';
|
|||||||
import type {
|
import type {
|
||||||
GiteaTeamGrantProvider,
|
GiteaTeamGrantProvider,
|
||||||
PresenceEvidence,
|
PresenceEvidence,
|
||||||
|
TeamRepositorySetEvidence,
|
||||||
TeamResolutionEvidence,
|
TeamResolutionEvidence,
|
||||||
} from './team-grant.js';
|
} from './team-grant.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -85,21 +86,53 @@ function isJson(response: Response): boolean {
|
|||||||
async function boundedBody(response: Response): Promise<Uint8Array> {
|
async function boundedBody(response: Response): Promise<Uint8Array> {
|
||||||
const declared = response.headers.get('content-length');
|
const declared = response.headers.get('content-length');
|
||||||
if (declared !== null) {
|
if (declared !== null) {
|
||||||
const bytes = Number.parseInt(declared, 10);
|
if (!/^\d+$/.test(declared)) {
|
||||||
if (Number.isFinite(bytes) && bytes > MAX_PROVIDER_BYTES) {
|
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(
|
throw new CredentialProviderEvidenceError(
|
||||||
'unexpected-provider-shape',
|
'unexpected-provider-shape',
|
||||||
'provider response exceeded the bounded size',
|
'provider response exceeded the bounded size',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const body = new Uint8Array(await response.arrayBuffer());
|
if (response.body === null) return new Uint8Array();
|
||||||
if (body.byteLength > MAX_PROVIDER_BYTES) {
|
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(
|
throw new CredentialProviderEvidenceError(
|
||||||
'unexpected-provider-shape',
|
'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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +519,51 @@ export class GiteaTeamGrantProviderAdapter
|
|||||||
return { ...matches[0], endpoint, contentType: contentType(response) };
|
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(
|
async addTeamMember(
|
||||||
authority: ResolvedCredential,
|
authority: ResolvedCredential,
|
||||||
teamId: number,
|
teamId: number,
|
||||||
|
|||||||
@@ -132,6 +132,47 @@ describe('direct repository grant', (): void => {
|
|||||||
expect(result.audit.state).toBe('sealed');
|
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> => {
|
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
|
||||||
const root = await stateRoot();
|
const root = await stateRoot();
|
||||||
const provider: GiteaGrantProvider = {
|
const provider: GiteaGrantProvider = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CredentialAuditJournal } from './audit-journal.js';
|
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||||
import type {
|
import type {
|
||||||
CredentialValidationDependencies,
|
CredentialValidationDependencies,
|
||||||
ResolvedCredential,
|
ResolvedCredential,
|
||||||
@@ -35,6 +35,17 @@ export interface GiteaGrantProvider {
|
|||||||
): Promise<OrganizationMembershipEvidenceDto>;
|
): 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 {
|
export interface CredentialGrantServiceOptions {
|
||||||
readonly stateRoot: string;
|
readonly stateRoot: string;
|
||||||
readonly actor: string;
|
readonly actor: string;
|
||||||
@@ -63,14 +74,86 @@ export async function grantDirectRepositoryPermission(
|
|||||||
repo: request.repo,
|
repo: request.repo,
|
||||||
});
|
});
|
||||||
await journal.recordIntent('provider-grant');
|
await journal.recordIntent('provider-grant');
|
||||||
const authorityIdentity = await grantProvider.readIdentity(authority);
|
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||||
if (authorityIdentity.login !== options.actor) {
|
try {
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
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 {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
operation: 'grant',
|
operation: 'grant',
|
||||||
outcome: 'refused',
|
outcome,
|
||||||
exitCode: 10,
|
exitCode: exitFor(outcome),
|
||||||
retryable: false,
|
retryable: false,
|
||||||
subject: {
|
subject: {
|
||||||
identity: request.identity,
|
identity: request.identity,
|
||||||
@@ -78,13 +161,52 @@ export async function grantDirectRepositoryPermission(
|
|||||||
host: request.host,
|
host: request.host,
|
||||||
repo: request.repo,
|
repo: request.repo,
|
||||||
},
|
},
|
||||||
mutation: 'none',
|
mutation: 'applied',
|
||||||
reason: {
|
reason: {
|
||||||
code: 'provider-identity-mismatch',
|
code: reasonCode,
|
||||||
message: 'Delegated grant authority did not authenticate as the explicit audit actor.',
|
message: readBackMatches
|
||||||
|
? 'Grant matched every required provider read-back.'
|
||||||
|
: 'Grant mutation completed but provider permission evidence disagreed.',
|
||||||
},
|
},
|
||||||
evidence: {
|
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,
|
repositoryPermission: null,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
collaboratorPermission: null,
|
collaboratorPermission: null,
|
||||||
@@ -93,71 +215,4 @@ export async function grantDirectRepositoryPermission(
|
|||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
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',
|
contentType: 'application/json',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async listTeamRepositories() {
|
||||||
|
return {
|
||||||
|
repositories: [],
|
||||||
|
endpoint: 'GET /api/v1/teams/7/repos',
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
},
|
||||||
async addTeamMember(): Promise<void> {},
|
async addTeamMember(): Promise<void> {},
|
||||||
async attachTeamRepository(): Promise<void> {},
|
async attachTeamRepository(): Promise<void> {},
|
||||||
async readTeamMember() {
|
async readTeamMember() {
|
||||||
@@ -136,4 +143,81 @@ describe('team repository grant', (): void => {
|
|||||||
expect(result.evidence.teamMembership?.state).toBe('present');
|
expect(result.evidence.teamMembership?.state).toBe('present');
|
||||||
expect(result.evidence.teamRepository?.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 {
|
import type {
|
||||||
CredentialValidationDependencies,
|
CredentialValidationDependencies,
|
||||||
ResolvedCredential,
|
ResolvedCredential,
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
OrganizationMembershipEvidenceDto,
|
OrganizationMembershipEvidenceDto,
|
||||||
} from './grant.dto.js';
|
} from './grant.dto.js';
|
||||||
import type { RepositoryPermission } from './credential-result.dto.js';
|
import type { RepositoryPermission } from './credential-result.dto.js';
|
||||||
|
import { CredentialGrantExecutionError } from './grant.js';
|
||||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
||||||
|
|
||||||
export interface TeamResolutionEvidence {
|
export interface TeamResolutionEvidence {
|
||||||
@@ -23,6 +24,11 @@ export interface PresenceEvidence {
|
|||||||
readonly endpoint: string;
|
readonly endpoint: string;
|
||||||
readonly contentType: string;
|
readonly contentType: string;
|
||||||
}
|
}
|
||||||
|
export interface TeamRepositorySetEvidence {
|
||||||
|
readonly repositories: readonly string[];
|
||||||
|
readonly endpoint: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
}
|
||||||
export interface TeamGrantRequest extends DirectGrantRequestDto {
|
export interface TeamGrantRequest extends DirectGrantRequestDto {
|
||||||
readonly team: string;
|
readonly team: string;
|
||||||
}
|
}
|
||||||
@@ -31,6 +37,7 @@ export interface TeamGrantResult extends CredentialGrantResultDto {
|
|||||||
readonly team: TeamResolutionEvidence | null;
|
readonly team: TeamResolutionEvidence | null;
|
||||||
readonly teamMembership: PresenceEvidence | null;
|
readonly teamMembership: PresenceEvidence | null;
|
||||||
readonly teamRepository: PresenceEvidence | null;
|
readonly teamRepository: PresenceEvidence | null;
|
||||||
|
readonly teamRepositorySet: TeamRepositorySetEvidence | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export interface GiteaTeamGrantProvider {
|
export interface GiteaTeamGrantProvider {
|
||||||
@@ -42,6 +49,10 @@ export interface GiteaTeamGrantProvider {
|
|||||||
organization: string,
|
organization: string,
|
||||||
team: string,
|
team: string,
|
||||||
): Promise<TeamResolutionEvidence>;
|
): Promise<TeamResolutionEvidence>;
|
||||||
|
listTeamRepositories(
|
||||||
|
authority: ResolvedCredential,
|
||||||
|
teamId: number,
|
||||||
|
): Promise<TeamRepositorySetEvidence>;
|
||||||
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
||||||
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
||||||
readTeamMember(
|
readTeamMember(
|
||||||
@@ -80,17 +91,116 @@ export async function grantTeamRepositoryPermission(
|
|||||||
repo: request.repo,
|
repo: request.repo,
|
||||||
});
|
});
|
||||||
await journal.recordIntent('provider-grant');
|
await journal.recordIntent('provider-grant');
|
||||||
const authorityIdentity = await provider.readIdentity(authority);
|
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||||
const organization = request.repo.split('/')[0] ?? '';
|
try {
|
||||||
const team = await provider.resolveTeam(authority, organization, request.team);
|
const authorityIdentity = await provider.readIdentity(authority);
|
||||||
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
const organization = request.repo.split('/')[0] ?? '';
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
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(
|
return result(
|
||||||
request,
|
request,
|
||||||
journal,
|
journal,
|
||||||
'refused',
|
ok ? 'ok' : 'indeterminate',
|
||||||
'none',
|
'applied',
|
||||||
'provider-identity-mismatch',
|
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,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -98,66 +208,20 @@ export async function grantTeamRepositoryPermission(
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await provider.addTeamMember(authority, team.id, request.identity);
|
|
||||||
await provider.attachTeamRepository(authority, team.id, request.repo);
|
|
||||||
const teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
|
||||||
const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
|
||||||
const subject = await dependencies.resolver.resolve(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
);
|
|
||||||
const organizationMembership =
|
|
||||||
subject === undefined ? null : await provider.readOrganizationMembership(subject, organization);
|
|
||||||
const validation =
|
|
||||||
request.permission === 'read'
|
|
||||||
? await evaluateGiteaReadValidation(request, dependencies)
|
|
||||||
: await evaluateGiteaWriteValidation(request, dependencies);
|
|
||||||
const ok =
|
|
||||||
teamMembership.state === 'present' &&
|
|
||||||
teamRepository.state === 'present' &&
|
|
||||||
organizationMembership?.state === 'present' &&
|
|
||||||
validation.outcome === 'ok' &&
|
|
||||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamMembership.endpoint,
|
|
||||||
contentType: teamMembership.contentType,
|
|
||||||
decision: 'team-member-present',
|
|
||||||
});
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamRepository.endpoint,
|
|
||||||
contentType: teamRepository.contentType,
|
|
||||||
decision: 'team-repository-present',
|
|
||||||
});
|
|
||||||
await journal.seal(
|
|
||||||
ok ? 'ok' : 'indeterminate',
|
|
||||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
|
||||||
);
|
|
||||||
return result(
|
|
||||||
request,
|
|
||||||
journal,
|
|
||||||
ok ? 'ok' : 'indeterminate',
|
|
||||||
'applied',
|
|
||||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
|
||||||
validation,
|
|
||||||
team,
|
|
||||||
teamMembership,
|
|
||||||
teamRepository,
|
|
||||||
organizationMembership,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function result(
|
function result(
|
||||||
request: TeamGrantRequest,
|
request: TeamGrantRequest,
|
||||||
journal: CredentialAuditJournal,
|
journal: CredentialAuditJournal,
|
||||||
outcome: 'ok' | 'refused' | 'indeterminate',
|
outcome: 'ok' | 'refused' | 'indeterminate',
|
||||||
mutation: 'none' | 'applied',
|
mutation: 'none' | 'unknown' | 'applied',
|
||||||
code: string,
|
code: string,
|
||||||
validation: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>> | null,
|
validation: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>> | null,
|
||||||
team: TeamResolutionEvidence | null,
|
team: TeamResolutionEvidence | null,
|
||||||
teamMembership: PresenceEvidence | null,
|
teamMembership: PresenceEvidence | null,
|
||||||
teamRepository: PresenceEvidence | null,
|
teamRepository: PresenceEvidence | null,
|
||||||
organizationMembership: OrganizationMembershipEvidenceDto | null,
|
organizationMembership: OrganizationMembershipEvidenceDto | null,
|
||||||
|
teamRepositorySet: TeamRepositorySetEvidence | null,
|
||||||
): TeamGrantResult {
|
): TeamGrantResult {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -188,6 +252,7 @@ function result(
|
|||||||
team,
|
team,
|
||||||
teamMembership,
|
teamMembership,
|
||||||
teamRepository,
|
teamRepository,
|
||||||
|
teamRepositorySet,
|
||||||
},
|
},
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface FixtureOptions {
|
|||||||
readonly controlTransportPrincipal?: string;
|
readonly controlTransportPrincipal?: string;
|
||||||
readonly unauthenticatedTransportState?: 'advertised' | 'refused';
|
readonly unauthenticatedTransportState?: 'advertised' | 'refused';
|
||||||
readonly omitControl?: boolean;
|
readonly omitControl?: boolean;
|
||||||
|
readonly requiredPermission?: 'read' | 'write' | 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Fixture {
|
interface Fixture {
|
||||||
@@ -150,6 +151,7 @@ async function validate(options: FixtureOptions = {}): Promise<{
|
|||||||
host: HOST,
|
host: HOST,
|
||||||
repo: REPO,
|
repo: REPO,
|
||||||
readOnlyControlIdentity: CONTROL,
|
readOnlyControlIdentity: CONTROL,
|
||||||
|
requiredPermission: options.requiredPermission,
|
||||||
},
|
},
|
||||||
observed.dependencies,
|
observed.dependencies,
|
||||||
);
|
);
|
||||||
@@ -269,6 +271,25 @@ describe('principal-bound Gitea write validation contract v1.1', (): void => {
|
|||||||
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
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> => {
|
it('makes a write-capable read-only control invalidate the entire result', async (): Promise<void> => {
|
||||||
const { result } = await validate({ controlPermission: 'write' });
|
const { result } = await validate({ controlPermission: 'write' });
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,16 @@ async function evaluateGiteaWriteValidationUnsafe(
|
|||||||
baseEvidence,
|
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') {
|
if (subjectEvidence.permission.effective === 'read') {
|
||||||
return refused(
|
return refused(
|
||||||
request,
|
request,
|
||||||
|
|||||||
Reference in New Issue
Block a user