This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
ProviderIdentityEvidenceDto,
|
||||
ReceivePackEvidenceDto,
|
||||
RepositoryPermission,
|
||||
RepositoryPermissionEvidenceDto,
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
export interface ResolvedCredential {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly resolutionId: string;
|
||||
readonly secret: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CredentialResolver {
|
||||
resolve(identity: string, estate: string, host: string): Promise<ResolvedCredential | undefined>;
|
||||
}
|
||||
|
||||
export interface GiteaCredentialProvider {
|
||||
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
|
||||
readRepositoryPermission(
|
||||
resolved: ResolvedCredential,
|
||||
repo: string,
|
||||
): Promise<RepositoryPermissionEvidenceDto>;
|
||||
probeReceivePack(
|
||||
resolved: ResolvedCredential | undefined,
|
||||
repo: string,
|
||||
): Promise<ReceivePackEvidenceDto>;
|
||||
}
|
||||
|
||||
export interface CredentialEstateRegistry {
|
||||
matches(estate: string, host: string): boolean;
|
||||
}
|
||||
|
||||
export interface CredentialValidationDependencies {
|
||||
readonly resolver: CredentialResolver;
|
||||
readonly provider: GiteaCredentialProvider;
|
||||
readonly estateRegistry: CredentialEstateRegistry;
|
||||
}
|
||||
|
||||
export interface GiteaReadValidationRequestDto {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string;
|
||||
readonly requiredPermission?: RepositoryPermission;
|
||||
}
|
||||
|
||||
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
|
||||
readonly readOnlyControlIdentity: string;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
|
||||
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
|
||||
export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
|
||||
export type ReceivePackState = 'advertised' | 'refused';
|
||||
|
||||
export interface CredentialReasonDto {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface CredentialSubjectDto {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string;
|
||||
}
|
||||
|
||||
export interface ProviderIdentityEvidenceDto {
|
||||
readonly login: string;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface RepositoryPermissionEvidenceDto {
|
||||
readonly effective: RepositoryPermission;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface ReceivePackEvidenceDto {
|
||||
readonly state: ReceivePackState;
|
||||
readonly principal: string | null;
|
||||
readonly resolutionId: string | null;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface ReadOnlyControlEvidenceDto {
|
||||
readonly identity: string;
|
||||
readonly providerPermission: RepositoryPermission;
|
||||
readonly receivePack: ReceivePackState;
|
||||
}
|
||||
|
||||
export interface WriteDifferentialEvidenceDto {
|
||||
readonly state: 'can-write';
|
||||
readonly credentialBinding: 'same-resolution';
|
||||
readonly transportPrincipal: string;
|
||||
readonly authenticatedReceivePack: 'advertised';
|
||||
readonly readOnlyControl: ReadOnlyControlEvidenceDto;
|
||||
readonly unauthenticatedReceivePack: 'refused';
|
||||
readonly artifactCreated: false;
|
||||
readonly proves: string;
|
||||
readonly doesNotProve: string;
|
||||
}
|
||||
|
||||
export interface TokenCapabilitiesEvidenceDto {
|
||||
readonly state: 'measured' | 'not-measured';
|
||||
readonly scopes: readonly string[];
|
||||
readonly source: 'provider-token-object' | 'runtime-not-authorized';
|
||||
}
|
||||
|
||||
export interface CredentialValidationEvidenceDto {
|
||||
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
||||
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
|
||||
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
||||
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
|
||||
}
|
||||
|
||||
export interface CredentialAuditResultDto {
|
||||
readonly journalId: string | null;
|
||||
readonly state: 'not-started' | 'open' | 'sealed';
|
||||
}
|
||||
|
||||
export interface CredentialValidationResultDto {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'validate' | 'whoami';
|
||||
readonly outcome: CredentialOutcome;
|
||||
readonly exitCode: 0 | 10 | 20 | 30;
|
||||
readonly retryable: boolean;
|
||||
readonly subject: CredentialSubjectDto;
|
||||
readonly mutation: CredentialMutationState;
|
||||
readonly reason: CredentialReasonDto;
|
||||
readonly evidence: CredentialValidationEvidenceDto;
|
||||
readonly audit: CredentialAuditResultDto;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type CredentialProviderKind = 'gitea';
|
||||
|
||||
export interface CredentialHostConfigDto {
|
||||
readonly host: string;
|
||||
readonly provider: CredentialProviderKind;
|
||||
readonly apiBaseUrl: string;
|
||||
readonly tokenPrefix: string;
|
||||
}
|
||||
|
||||
export interface CredentialEstateConfigDto {
|
||||
readonly name: string;
|
||||
readonly readOnlyControlIdentity?: string;
|
||||
readonly inventoryAuthorityIdentity?: string;
|
||||
readonly hosts: readonly CredentialHostConfigDto[];
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||
|
||||
const validRegistry = JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('credential estate registry', (): void => {
|
||||
it('requires an exact declared estate-host pair', (): void => {
|
||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||
|
||||
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
|
||||
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
|
||||
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
|
||||
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
|
||||
estate: 'homelab',
|
||||
host: { host: 'git.example.invalid', provider: 'gitea' },
|
||||
});
|
||||
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a provider URL whose host differs from the declared host', (): void => {
|
||||
const source = validRegistry.replace(
|
||||
'https://git.example.invalid',
|
||||
'https://other.example.invalid',
|
||||
);
|
||||
|
||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/);
|
||||
});
|
||||
|
||||
it('rejects one host assigned to multiple estates', (): void => {
|
||||
const source = JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-other',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/);
|
||||
});
|
||||
|
||||
it('rejects URLs with userinfo, path, query, fragment, trailing slash, or non-HTTPS scheme', (): void => {
|
||||
for (const apiBaseUrl of [
|
||||
'http://git.example.invalid',
|
||||
'https://[email protected]',
|
||||
'https://git.example.invalid/',
|
||||
'https://git.example.invalid/api',
|
||||
'https://git.example.invalid?x=1',
|
||||
'https://git.example.invalid#x',
|
||||
]) {
|
||||
const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl);
|
||||
expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a configured read-only control for write validation', (): void => {
|
||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||
const withoutControl = parseCredentialEstateRegistry(
|
||||
validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''),
|
||||
);
|
||||
|
||||
expect(registry.readOnlyControl('homelab')).toBe('read-control');
|
||||
expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { z } from 'zod';
|
||||
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
|
||||
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
|
||||
|
||||
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
||||
|
||||
const hostSchema = z
|
||||
.object({
|
||||
host: z.string().regex(HOST),
|
||||
provider: z.literal('gitea'),
|
||||
apiBaseUrl: z.string(),
|
||||
tokenPrefix: z.string().regex(NAME),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const estateSchema = z
|
||||
.object({
|
||||
name: z.string().regex(NAME),
|
||||
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
||||
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
|
||||
hosts: z.array(hostSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const registrySchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
estates: z.array(estateSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class CredentialEstateRegistryError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Credential estate registry rejected: code=${code} ${message}`);
|
||||
this.name = 'CredentialEstateRegistryError';
|
||||
}
|
||||
}
|
||||
|
||||
function validateApiUrl(host: CredentialHostConfigDto): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(host.apiBaseUrl);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CredentialEstateRegistryError('invalid-api-url', detail);
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username !== '' ||
|
||||
url.password !== '' ||
|
||||
url.pathname !== '/' ||
|
||||
host.apiBaseUrl !== url.origin ||
|
||||
url.search !== '' ||
|
||||
url.hash !== ''
|
||||
) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'invalid-api-url',
|
||||
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
|
||||
);
|
||||
}
|
||||
if (url.hostname !== host.host) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'api-host-mismatch',
|
||||
'provider API URL hostname does not equal the declared host',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
|
||||
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
|
||||
|
||||
constructor(estates: readonly CredentialEstateConfigDto[]) {
|
||||
this.estates = new Map(
|
||||
estates.map(
|
||||
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
|
||||
estate.name,
|
||||
estate,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
matches(estate: string, host: string): boolean {
|
||||
return this.resolve(estate, host) !== undefined;
|
||||
}
|
||||
|
||||
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
|
||||
return this.estates
|
||||
.get(estate)
|
||||
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
||||
}
|
||||
|
||||
resolveByHost(
|
||||
host: string,
|
||||
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
|
||||
for (const [estate, config] of this.estates) {
|
||||
const match = config.hosts.find(
|
||||
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
|
||||
);
|
||||
if (match !== undefined) return { estate, host: match };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
inventoryAuthority(estate: string): string {
|
||||
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
|
||||
if (identity === undefined) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'inventory-authority-missing',
|
||||
`estate ${estate} has no delegated inventory authority identity`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
readOnlyControl(estate: string): string {
|
||||
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
||||
if (identity === undefined) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'read-only-control-missing',
|
||||
`estate ${estate} has no provider-confirmed read-only control identity`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CredentialEstateRegistryError('invalid-json', detail);
|
||||
}
|
||||
|
||||
const parsed = registrySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'invalid-schema',
|
||||
parsed.error.issues[0]?.message ?? 'invalid',
|
||||
);
|
||||
}
|
||||
|
||||
const estateNames = new Set<string>();
|
||||
const hostNames = new Set<string>();
|
||||
for (const estate of parsed.data.estates) {
|
||||
if (estateNames.has(estate.name)) {
|
||||
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
|
||||
}
|
||||
estateNames.add(estate.name);
|
||||
for (const host of estate.hosts) {
|
||||
validateApiUrl(host);
|
||||
if (hostNames.has(host.host)) {
|
||||
throw new CredentialEstateRegistryError('duplicate-host', host.host);
|
||||
}
|
||||
hostNames.add(host.host);
|
||||
}
|
||||
}
|
||||
|
||||
return new ParsedCredentialEstateRegistry(parsed.data.estates);
|
||||
}
|
||||
Reference in New Issue
Block a user