231 lines
7.7 KiB
TypeScript
231 lines
7.7 KiB
TypeScript
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { listCredentialJournals } from './audit-journal.js';
|
|
import { grantDirectRepositoryPermission } from './grant.js';
|
|
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
import type { GiteaGrantProvider } from './grant.js';
|
|
import type { CredentialValidationDependencies } from './validate.js';
|
|
|
|
let cleanup: string | undefined;
|
|
const authority: ResolvedCredential = Object.freeze({
|
|
identity: 'provisioner',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
resolutionId: 'authority',
|
|
secret: new TextEncoder().encode('authority-canary'),
|
|
});
|
|
|
|
async function stateRoot(): Promise<string> {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-grant-'));
|
|
return join(cleanup, 'state');
|
|
}
|
|
|
|
afterEach(async (): Promise<void> => {
|
|
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
cleanup = undefined;
|
|
});
|
|
|
|
function validationDependencies(permission: 'read' | 'write'): CredentialValidationDependencies {
|
|
const subject: ResolvedCredential = Object.freeze({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
resolutionId: 'subject',
|
|
secret: new TextEncoder().encode('subject-canary'),
|
|
});
|
|
const control: ResolvedCredential = Object.freeze({
|
|
identity: 'read-control',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
resolutionId: 'control',
|
|
secret: new TextEncoder().encode('control-canary'),
|
|
});
|
|
return {
|
|
estateRegistry: { matches: (): boolean => true },
|
|
resolver: {
|
|
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
|
|
if (identity === 'seat-name') return subject;
|
|
if (identity === 'read-control') return control;
|
|
return undefined;
|
|
},
|
|
},
|
|
provider: {
|
|
async readIdentity(resolved: ResolvedCredential) {
|
|
return {
|
|
login: resolved.identity,
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readRepositoryPermission(resolved: ResolvedCredential) {
|
|
return {
|
|
effective: resolved.identity === 'seat-name' ? permission : 'read',
|
|
endpoint: 'GET /api/v1/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async probeReceivePack(resolved: ResolvedCredential | undefined) {
|
|
const subjectWrite = resolved?.identity === 'seat-name' && permission === 'write';
|
|
return {
|
|
state: subjectWrite ? 'advertised' : 'refused',
|
|
principal: resolved?.identity ?? null,
|
|
resolutionId: resolved?.resolutionId ?? null,
|
|
contentType: subjectWrite ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('direct repository grant', (): void => {
|
|
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
|
|
const root = await stateRoot();
|
|
const provider: GiteaGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async grantCollaborator(): Promise<void> {
|
|
expect((await listCredentialJournals(root))[0]?.state).toBe('open');
|
|
},
|
|
async readCollaboratorPermission() {
|
|
return {
|
|
identity: 'seat-name',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'absent',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
|
|
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('ok');
|
|
expect(result.mutation).toBe('applied');
|
|
expect(result.evidence.repositoryPermission?.effective).toBe('write');
|
|
expect(result.evidence.organizationMembership?.state).toBe('absent');
|
|
expect(result.audit.state).toBe('sealed');
|
|
const [sealed] = await listCredentialJournals(root);
|
|
const source = await readFile(sealed?.path ?? '', 'utf8');
|
|
expect(source).toContain('"phase":"mutation"');
|
|
expect(source).toContain('"decision":"collaborator-grant-applied"');
|
|
expect(source).toContain('"decision":"identity-verified"');
|
|
expect(source).toContain('"decision":"organization-member-absent"');
|
|
expect(source).toContain('"decision":"transport-write-verified"');
|
|
});
|
|
|
|
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
|
|
const root = await stateRoot();
|
|
const provider: GiteaGrantProvider = {
|
|
async readBasicIdentity() {
|
|
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 = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async grantCollaborator(): Promise<void> {},
|
|
async readCollaboratorPermission() {
|
|
return {
|
|
identity: 'seat-name',
|
|
permission: 'read',
|
|
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'absent',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
|
|
const result = await grantDirectRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validationDependencies('read'),
|
|
{ stateRoot: root, actor: 'provisioner' },
|
|
);
|
|
|
|
expect(result.outcome).toBe('indeterminate');
|
|
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
|
expect(result.mutation).toBe('applied');
|
|
});
|
|
});
|