Files
stack/packages/mosaic/src/credentials/gitea-provider.ts
T

574 lines
18 KiB
TypeScript

import { z } from 'zod';
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
import type { GiteaGrantProvider } from './grant.js';
import type {
GiteaTeamGrantProvider,
PresenceEvidence,
TeamResolutionEvidence,
} from './team-grant.js';
import type {
CollaboratorPermissionEvidenceDto,
OrganizationMembershipEvidenceDto,
} from './grant.dto.js';
import type {
ProviderIdentityEvidenceDto,
ReceivePackEvidenceDto,
RepositoryPermission,
RepositoryPermissionEvidenceDto,
} from './credential-result.dto.js';
const MAX_PROVIDER_BYTES = 1024 * 1024;
const USER_AGENT = 'mosaic-cred/1';
const JSON_CONTENT_TYPE = 'application/json';
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
const REPO_COMPONENT = /^[A-Za-z0-9_.-]+$/;
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
const userSchema = z
.object({
id: z.number().int(),
login: z.string().min(1),
is_admin: z.boolean().optional(),
visibility: z.enum(['public', 'limited', 'private']).optional(),
})
.passthrough();
const collaboratorPermissionSchema = z
.object({
permission: z.enum(['read', 'write', 'admin']),
user: z.object({ login: z.string().min(1) }).passthrough(),
})
.passthrough();
const organizationSchema = z.object({ username: z.string().min(1) }).passthrough();
const teamSchema = z
.object({
id: z.number().int().positive(),
name: z.string().min(1),
permission: z.enum(['read', 'write', 'admin']),
})
.passthrough();
const repoSchema = z
.object({
id: z.number().int(),
full_name: z.string().min(3),
permissions: z
.object({
admin: z.boolean(),
push: z.boolean(),
pull: z.boolean(),
})
.strict(),
})
.passthrough();
export class CredentialProviderEvidenceError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Gitea credential evidence unavailable: code=${code} ${message}`);
this.name = 'CredentialProviderEvidenceError';
}
}
function contentType(response: Response): string {
return response.headers.get('content-type') ?? '';
}
function isJson(response: Response): boolean {
return contentType(response).toLowerCase().startsWith(JSON_CONTENT_TYPE);
}
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) {
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) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider response exceeded the bounded size',
);
}
return body;
}
async function jsonObject(response: Response): Promise<unknown> {
if (!isJson(response)) {
throw new CredentialProviderEvidenceError(
'unexpected-content-type',
'provider response was not JSON',
);
}
const bytes = await boundedBody(response);
try {
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
} catch {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider JSON could not be parsed',
);
}
}
function tokenText(resolved: ResolvedCredential): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(resolved.secret);
} catch {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'credential bytes were not valid text',
);
}
}
function apiAuthorization(resolved: ResolvedCredential): string {
return `token ${tokenText(resolved)}`;
}
function gitAuthorization(resolved: ResolvedCredential): string {
const basic = Buffer.from(`${resolved.identity}:${tokenText(resolved)}`, 'utf8').toString(
'base64',
);
return `Basic ${basic}`;
}
function repoPath(repo: string): { readonly owner: string; readonly name: string } {
const pieces = repo.split('/');
const owner = pieces[0];
const name = pieces[1];
if (
pieces.length !== 2 ||
owner === undefined ||
name === undefined ||
!REPO_COMPONENT.test(owner) ||
!REPO_COMPONENT.test(name)
) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'repository must be exactly owner/name in the allowlist grammar',
);
}
return { owner, name };
}
function effectivePermission(permissions: {
readonly admin: boolean;
readonly push: boolean;
readonly pull: boolean;
}): RepositoryPermission {
if (permissions.admin) return 'admin';
if (permissions.push) return 'write';
return 'read';
}
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
protected readonly origin: string;
constructor(
apiBaseUrl: string,
private readonly fetchImpl: FetchLike = fetch,
) {
const parsed = new URL(apiBaseUrl);
this.origin = parsed.origin;
}
protected async request(url: string, init: RequestInit): Promise<Response> {
try {
return await this.fetchImpl(url, init);
} catch {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'provider request failed before evidence was available',
);
}
}
private async classifyRejectedIdentity(rejected: Response): Promise<never> {
if (!isJson(rejected)) {
throw new CredentialProviderEvidenceError(
'unexpected-content-type',
'provider credential rejection was not JSON',
);
}
const status = rejected.status;
await boundedBody(rejected);
if (status === 403 || status === 404) {
throw new CredentialProviderEvidenceError(
'identity-read-forbidden',
'provider denied the identity endpoint; credential capability must be tested in scope',
);
}
throw new CredentialProviderEvidenceError(
'credential-rejected',
'provider rejected the supplied credential; account existence was not inferred',
);
}
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
const endpoint = 'GET /api/v1/user';
const response = await this.request(`${this.origin}/api/v1/user`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(resolved),
'User-Agent': USER_AGENT,
},
});
if (response.status === 401 || response.status === 403 || response.status === 404) {
return this.classifyRejectedIdentity(response);
}
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
`provider identity request returned HTTP ${response.status.toString()}`,
);
}
const parsed = userSchema.safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider identity object lacked required fields',
);
}
return {
login: parsed.data.login,
endpoint,
contentType: contentType(response),
};
}
async readRepositoryPermission(
resolved: ResolvedCredential,
repo: string,
): Promise<RepositoryPermissionEvidenceDto> {
const { owner, name } = repoPath(repo);
const endpoint = `GET /api/v1/repos/${owner}/${name}`;
const response = await this.request(`${this.origin}/api/v1/repos/${owner}/${name}`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(resolved),
'User-Agent': USER_AGENT,
},
});
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
`provider repository request returned HTTP ${response.status.toString()}`,
);
}
const parsed = repoSchema.safeParse(await jsonObject(response));
if (!parsed.success || parsed.data.full_name !== repo) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider repository object did not identify the requested repository',
);
}
return {
effective: effectivePermission(parsed.data.permissions),
endpoint,
contentType: contentType(response),
};
}
async probeReceivePack(
resolved: ResolvedCredential | undefined,
repo: string,
): Promise<ReceivePackEvidenceDto> {
const { owner, name } = repoPath(repo);
const headers = new Headers({
Accept: RECEIVE_PACK_CONTENT_TYPE,
'User-Agent': USER_AGENT,
});
if (resolved !== undefined) headers.set('Authorization', gitAuthorization(resolved));
const response = await this.request(
`${this.origin}/${owner}/${name}.git/info/refs?service=git-receive-pack`,
{ method: 'GET', headers },
);
const responseType = contentType(response);
if (response.status === 401 || response.status === 403) {
await boundedBody(response);
return {
state: 'refused',
principal: resolved?.identity ?? null,
resolutionId: resolved?.resolutionId ?? null,
contentType: responseType,
};
}
if (!response.ok || !responseType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)) {
throw new CredentialProviderEvidenceError(
response.ok ? 'unexpected-content-type' : 'provider-unavailable',
`receive-pack response was not an advertisement (HTTP ${response.status.toString()})`,
);
}
const body = new TextDecoder('utf-8', { fatal: true }).decode(await boundedBody(response));
if (!body.includes('# service=git-receive-pack')) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'receive-pack advertisement lacked the protocol service preamble',
);
}
return {
state: 'advertised',
principal: resolved?.identity ?? null,
resolutionId: resolved?.resolutionId ?? null,
contentType: responseType,
};
}
}
export class GiteaGrantProviderAdapter
extends GiteaCredentialProviderAdapter
implements GiteaGrantProvider
{
async grantCollaborator(
authority: ResolvedCredential,
identity: string,
repo: string,
permission: RepositoryPermission,
): Promise<void> {
const { owner, name } = repoPath(repo);
const response = await this.request(
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}`,
{
method: 'PUT',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
'Content-Type': JSON_CONTENT_TYPE,
'User-Agent': USER_AGENT,
},
body: JSON.stringify({ permission }),
},
);
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError(
response.status === 401 || response.status === 403
? 'credential-rejected'
: 'provider-unavailable',
`provider grant request returned HTTP ${response.status.toString()}`,
);
}
}
async readCollaboratorPermission(
authority: ResolvedCredential,
identity: string,
repo: string,
): Promise<CollaboratorPermissionEvidenceDto> {
const { owner, name } = repoPath(repo);
const endpoint = `GET /api/v1/repos/${owner}/${name}/collaborators/${identity}/permission`;
const response = await this.request(
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}/permission`,
{
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',
`collaborator permission read-back returned HTTP ${response.status.toString()}`,
);
}
const parsed = collaboratorPermissionSchema.safeParse(await jsonObject(response));
if (!parsed.success || parsed.data.user.login !== identity) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'collaborator permission object did not identify the declared subject',
);
}
return {
identity: parsed.data.user.login,
permission: parsed.data.permission,
endpoint,
contentType: contentType(response),
};
}
async readOrganizationMembership(
subject: ResolvedCredential,
organization: string,
): Promise<OrganizationMembershipEvidenceDto> {
const endpoint = `GET /api/v1/users/${subject.identity}/orgs`;
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(subject.identity)}/orgs`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(subject),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
response.status === 401 || response.status === 403
? 'scope-not-evaluable'
: 'provider-unavailable',
`organization membership read-back returned HTTP ${response.status.toString()}`,
);
}
const parsed = z.array(organizationSchema).safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'organization membership response was not an organization array',
);
}
return {
state: parsed.data.some((entry): boolean => entry.username === organization)
? 'present'
: 'absent',
endpoint,
contentType: contentType(response),
};
}
}
export class GiteaTeamGrantProviderAdapter
extends GiteaGrantProviderAdapter
implements GiteaTeamGrantProvider
{
async resolveTeam(
authority: ResolvedCredential,
organization: string,
team: string,
): Promise<TeamResolutionEvidence> {
const endpoint = `GET /api/v1/orgs/${organization}/teams`;
const response = await this.request(
`${this.origin}/api/v1/orgs/${encodeURIComponent(organization)}/teams`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'team list was unavailable',
);
}
const parsed = z.array(teamSchema).safeParse(await jsonObject(response));
const matches = parsed.success
? parsed.data.filter((entry): boolean => entry.name === team)
: [];
if (matches.length !== 1 || matches[0] === undefined) {
throw new CredentialProviderEvidenceError(
'readback-missing',
'team did not resolve uniquely',
);
}
return { ...matches[0], endpoint, contentType: contentType(response) };
}
async addTeamMember(
authority: ResolvedCredential,
teamId: number,
identity: string,
): Promise<void> {
await this.putTeamPath(
authority,
`/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
);
}
async attachTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<void> {
const { owner, name } = repoPath(repo);
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
}
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
const response = await this.request(`${this.origin}${path}`, {
method: 'PUT',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'team grant mutation failed',
);
}
}
async readTeamMember(
authority: ResolvedCredential,
teamId: number,
identity: string,
): Promise<PresenceEvidence> {
return this.readPresence(
authority,
`GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
);
}
async readTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<PresenceEvidence> {
const { owner, name } = repoPath(repo);
return this.readPresence(
authority,
`GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
);
}
private async readPresence(
authority: ResolvedCredential,
endpoint: string,
): Promise<PresenceEvidence> {
const response = await this.request(`${this.origin}${endpoint.slice(4)}`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
if (response.status === 404) {
await boundedBody(response);
return { state: 'absent', endpoint, contentType: contentType(response) };
}
if (!response.ok || !isJson(response)) {
await boundedBody(response);
throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed');
}
await boundedBody(response);
return { state: 'present', endpoint, contentType: contentType(response) };
}
}