281 lines
9.0 KiB
TypeScript
281 lines
9.0 KiB
TypeScript
import { z } from 'zod';
|
|
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
|
import type { MigrationOwnerResolution } from './brain-store.js';
|
|
|
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
const LOGIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
|
|
const REQUESTED_OWNER = /^user:(.+)$/;
|
|
|
|
const ownerPolicySchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
estates: z
|
|
.array(
|
|
z
|
|
.object({
|
|
estate: z.string().min(1),
|
|
laneArchiveOwners: z
|
|
.array(
|
|
z
|
|
.object({
|
|
kind: z.literal('provider-user'),
|
|
login: z.string().min(1),
|
|
})
|
|
.strict(),
|
|
)
|
|
.min(1),
|
|
standingProcess: z
|
|
.object({
|
|
kind: z.literal('glpi-queue'),
|
|
queue: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
})
|
|
.strict(),
|
|
controls: z
|
|
.object({
|
|
publicIdentity: z.string().min(1),
|
|
privateIdentity: z.string().min(1),
|
|
})
|
|
.strict(),
|
|
})
|
|
.strict(),
|
|
)
|
|
.min(1),
|
|
})
|
|
.strict();
|
|
|
|
const providerUserSchema = z
|
|
.object({
|
|
id: z.number().int(),
|
|
login: z.string().min(1),
|
|
visibility: z.literal('public'),
|
|
})
|
|
.passthrough();
|
|
|
|
export type OwnerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
|
|
function unresolved(reasonCode: string): MigrationOwnerResolution {
|
|
return {
|
|
verdict: 'not-measured',
|
|
reasonCode,
|
|
principal: null,
|
|
authority: null,
|
|
};
|
|
}
|
|
|
|
function refused(reasonCode: string): MigrationOwnerResolution {
|
|
return {
|
|
verdict: 'refused',
|
|
reasonCode,
|
|
principal: null,
|
|
authority: null,
|
|
};
|
|
}
|
|
|
|
function exactCanonicalLogin(value: string): boolean {
|
|
return value.normalize('NFKC') === value && LOGIN.test(value);
|
|
}
|
|
|
|
async function boundedJson(response: Response): Promise<unknown> {
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
throw new Error('owner-unexpected-content-type');
|
|
}
|
|
const declared = response.headers.get('content-length');
|
|
let declaredSize: number | null = null;
|
|
if (declared !== null) {
|
|
if (!/^\d+$/.test(declared)) throw new Error('owner-unexpected-provider-shape');
|
|
declaredSize = Number.parseInt(declared, 10);
|
|
if (!Number.isSafeInteger(declaredSize) || declaredSize > MAX_BODY_BYTES) {
|
|
throw new Error('owner-unexpected-provider-shape');
|
|
}
|
|
}
|
|
if (response.body === null) throw new Error('owner-unexpected-provider-shape');
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let total = 0;
|
|
while (true) {
|
|
const next = await reader.read();
|
|
if (next.done) break;
|
|
total += next.value.byteLength;
|
|
if (total > MAX_BODY_BYTES) {
|
|
await reader.cancel('owner response exceeds byte ceiling');
|
|
throw new Error('owner-unexpected-provider-shape');
|
|
}
|
|
chunks.push(next.value);
|
|
}
|
|
if (declaredSize !== null && declaredSize !== total) {
|
|
throw new Error('owner-unexpected-provider-shape');
|
|
}
|
|
const body = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
body.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
try {
|
|
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
|
|
} catch {
|
|
throw new Error('owner-unexpected-provider-shape');
|
|
}
|
|
}
|
|
|
|
async function readPublicIdentity(
|
|
origin: string,
|
|
identity: string,
|
|
fetchImpl: OwnerFetch,
|
|
): Promise<{ readonly status: number; readonly user: unknown }> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetchImpl(`${origin}/api/v1/users/${encodeURIComponent(identity)}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'User-Agent': 'mosaic-brain-owner/1',
|
|
},
|
|
redirect: 'manual',
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
} catch {
|
|
throw new Error('owner-provider-unavailable');
|
|
}
|
|
return { status: response.status, user: await boundedJson(response) };
|
|
}
|
|
|
|
function publicIdentityMatches(value: unknown, identity: string): boolean {
|
|
const parsed = providerUserSchema.safeParse(value);
|
|
return parsed.success && parsed.data.login === identity;
|
|
}
|
|
|
|
export interface BrainOwnerPolicyBinding {
|
|
readonly brainNamespace: string;
|
|
readonly publicControl: string;
|
|
readonly privateControl: string;
|
|
readonly standingQueue: string;
|
|
}
|
|
|
|
export function resolveBrainOwnerPolicy(
|
|
ownerPolicySource: string,
|
|
estate: string,
|
|
): BrainOwnerPolicyBinding | undefined {
|
|
let rawPolicy: unknown;
|
|
try {
|
|
rawPolicy = JSON.parse(ownerPolicySource);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
const policy = ownerPolicySchema.safeParse(rawPolicy);
|
|
if (!policy.success) return undefined;
|
|
const estatePolicies = policy.data.estates.filter(
|
|
(candidate): boolean => candidate.estate === estate,
|
|
);
|
|
if (estatePolicies.length !== 1) return undefined;
|
|
const estatePolicy = estatePolicies[0];
|
|
if (estatePolicy === undefined || estatePolicy.laneArchiveOwners.length !== 1) return undefined;
|
|
const brainNamespace = estatePolicy.laneArchiveOwners[0]?.login;
|
|
if (brainNamespace === undefined || !exactCanonicalLogin(brainNamespace)) return undefined;
|
|
return {
|
|
brainNamespace,
|
|
publicControl: estatePolicy.controls.publicIdentity,
|
|
privateControl: estatePolicy.controls.privateIdentity,
|
|
standingQueue: estatePolicy.standingProcess.queue,
|
|
};
|
|
}
|
|
|
|
export function parseRequestedOwner(requestedOwner: string): string | null {
|
|
if (requestedOwner.normalize('NFKC') !== requestedOwner) return null;
|
|
const match = REQUESTED_OWNER.exec(requestedOwner);
|
|
const login = match?.[1];
|
|
if (login === undefined || !exactCanonicalLogin(login)) return null;
|
|
return login;
|
|
}
|
|
|
|
export async function resolveProviderDurableOwner(
|
|
input: {
|
|
readonly estateRegistrySource: string;
|
|
readonly ownerPolicySource: string;
|
|
readonly host: string;
|
|
readonly requestedOwner: string;
|
|
},
|
|
dependencies: {
|
|
readonly fetch: OwnerFetch;
|
|
readonly absentControlName: () => string;
|
|
},
|
|
): Promise<MigrationOwnerResolution> {
|
|
const requestedLogin = parseRequestedOwner(input.requestedOwner);
|
|
if (requestedLogin === null) return refused('owner-name-invalid');
|
|
|
|
const target = parseCredentialEstateRegistry(input.estateRegistrySource).resolveByHost(
|
|
input.host,
|
|
);
|
|
if (target === undefined) return refused('estate-host-unmapped');
|
|
|
|
const policy = resolveBrainOwnerPolicy(input.ownerPolicySource, target.estate);
|
|
if (policy === undefined) return refused('owner-policy-invalid');
|
|
if (policy.brainNamespace !== requestedLogin) return refused('owner-not-allowlisted');
|
|
|
|
const publicControl = policy.publicControl;
|
|
const privateControl = policy.privateControl;
|
|
const absentControl = dependencies.absentControlName();
|
|
if (
|
|
!exactCanonicalLogin(publicControl) ||
|
|
!exactCanonicalLogin(privateControl) ||
|
|
!exactCanonicalLogin(absentControl) ||
|
|
new Set([publicControl, privateControl, absentControl, requestedLogin]).size !== 4
|
|
) {
|
|
return refused('owner-policy-invalid');
|
|
}
|
|
|
|
try {
|
|
const publicResult = await readPublicIdentity(
|
|
target.host.apiBaseUrl,
|
|
publicControl,
|
|
dependencies.fetch,
|
|
);
|
|
if (publicResult.status !== 200 || !publicIdentityMatches(publicResult.user, publicControl)) {
|
|
return unresolved('owner-control-invalid');
|
|
}
|
|
|
|
const privateResult = await readPublicIdentity(
|
|
target.host.apiBaseUrl,
|
|
privateControl,
|
|
dependencies.fetch,
|
|
);
|
|
if (privateResult.status !== 404) return unresolved('owner-control-invalid');
|
|
|
|
const absentResult = await readPublicIdentity(
|
|
target.host.apiBaseUrl,
|
|
absentControl,
|
|
dependencies.fetch,
|
|
);
|
|
if (absentResult.status !== 404) return unresolved('owner-control-invalid');
|
|
|
|
const ownerResult = await readPublicIdentity(
|
|
target.host.apiBaseUrl,
|
|
requestedLogin,
|
|
dependencies.fetch,
|
|
);
|
|
if (ownerResult.status === 401 || ownerResult.status === 403 || ownerResult.status === 404) {
|
|
return unresolved('owner-not-resolvable');
|
|
}
|
|
if (ownerResult.status !== 200) return unresolved('owner-provider-unavailable');
|
|
if (!publicIdentityMatches(ownerResult.user, requestedLogin)) {
|
|
return unresolved('owner-provider-identity-mismatch');
|
|
}
|
|
return {
|
|
verdict: 'resolved',
|
|
reasonCode: 'owner-verified',
|
|
principal: { name: `user:${requestedLogin}`, kind: 'durable-human' },
|
|
authority: {
|
|
system: 'gitea',
|
|
endpoint: `GET /api/v1/users/${requestedLogin}`,
|
|
contentType: 'application/json',
|
|
},
|
|
};
|
|
} catch (error: unknown) {
|
|
const reason = error instanceof Error ? error.message : 'owner-provider-unavailable';
|
|
if (reason === 'owner-unexpected-content-type') return unresolved(reason);
|
|
if (reason === 'owner-unexpected-provider-shape') return unresolved(reason);
|
|
return unresolved('owner-provider-unavailable');
|
|
}
|
|
}
|