mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token';
|
||||
import { assertWorkerCredentialPepper } from '@qinglong/runtime-core/worker-credential-token';
|
||||
import type { SecurityAuditQueryRepository } from '@qinglong/runtime-core/security-audit-query';
|
||||
import {
|
||||
PostgresApiCredentialAdministrationRepository,
|
||||
PostgresIdentityAdministrationRepository,
|
||||
PostgresSecurityAuditQueryRepository,
|
||||
PostgresWorkerCredentialAdministrationRepository,
|
||||
assertPostgresAdminSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/admin';
|
||||
import {
|
||||
createClusterAdministrationService,
|
||||
type ClusterAdministrationOptions,
|
||||
type ClusterAdministrationService,
|
||||
} from '../security-administration/clusterAdministration';
|
||||
import {
|
||||
createWorkerCredentialAdministrationService,
|
||||
type WorkerCredentialAdministrationService,
|
||||
} from '../worker-credential/workerCredentialAdministration';
|
||||
|
||||
export interface ClusterAdminBootstrapOptions
|
||||
extends ClusterAdministrationOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly apiCredentialPepper: string;
|
||||
readonly workerCredentialPepper: string;
|
||||
}
|
||||
|
||||
export interface ClusterAdminRuntime {
|
||||
readonly evidence: PostgresSchemaReadinessReport;
|
||||
readonly administration: ClusterAdministrationService;
|
||||
readonly audit: SecurityAuditQueryRepository;
|
||||
readonly workerCredentials: WorkerCredentialAdministrationService;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function bootstrapClusterAdmin(
|
||||
options: ClusterAdminBootstrapOptions,
|
||||
): Promise<ClusterAdminRuntime> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Cluster admin bootstrap options are invalid');
|
||||
}
|
||||
if (typeof options.openDatabase !== 'function') {
|
||||
throw new TypeError('Cluster admin database opener is invalid');
|
||||
}
|
||||
const optionKeys = new Set([
|
||||
'openDatabase',
|
||||
'apiCredentialPepper',
|
||||
'workerCredentialPepper',
|
||||
'now',
|
||||
'randomBytes',
|
||||
]);
|
||||
if (Object.keys(options).some((key) => !optionKeys.has(key))) {
|
||||
throw new TypeError('Cluster admin bootstrap options shape is invalid');
|
||||
}
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw new TypeError('Cluster admin clock is invalid');
|
||||
}
|
||||
if (
|
||||
options.randomBytes !== undefined &&
|
||||
typeof options.randomBytes !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster admin random source is invalid');
|
||||
}
|
||||
try {
|
||||
assertApiCredentialPepper(options.apiCredentialPepper);
|
||||
} catch {
|
||||
throw new TypeError('Cluster admin API credential pepper is invalid');
|
||||
}
|
||||
try {
|
||||
assertWorkerCredentialPepper(options.workerCredentialPepper);
|
||||
} catch {
|
||||
throw new TypeError('Cluster admin Worker credential pepper is invalid');
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
if (!database) return Promise.resolve();
|
||||
closePromise ??= database.close();
|
||||
return closePromise;
|
||||
};
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresAdminSchemaReady(database.pool);
|
||||
const identities = new PostgresIdentityAdministrationRepository(
|
||||
database.pool,
|
||||
);
|
||||
const credentials = new PostgresApiCredentialAdministrationRepository(
|
||||
database.pool,
|
||||
);
|
||||
return Object.freeze({
|
||||
evidence,
|
||||
administration: createClusterAdministrationService(
|
||||
identities,
|
||||
credentials,
|
||||
options.apiCredentialPepper,
|
||||
{
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
...(options.randomBytes ? { randomBytes: options.randomBytes } : {}),
|
||||
},
|
||||
),
|
||||
audit: new PostgresSecurityAuditQueryRepository(database.pool),
|
||||
workerCredentials: createWorkerCredentialAdministrationService(
|
||||
new PostgresWorkerCredentialAdministrationRepository(database.pool),
|
||||
options.workerCredentialPepper,
|
||||
{
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
...(options.randomBytes ? { randomBytes: options.randomBytes } : {}),
|
||||
},
|
||||
),
|
||||
close,
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await close();
|
||||
} catch {
|
||||
// Preserve configuration/readiness/assembly failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export * from '../security-administration/clusterAdministration';
|
||||
export * from '../worker-credential/workerCredentialAdministration';
|
||||
@@ -0,0 +1,62 @@
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createApprovalDecisionService,
|
||||
type ApprovalDecisionService,
|
||||
} from '@qinglong/runtime-core/approval-decision';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
export interface ClusterApprovalDecisionManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly confirmAuthorization?: () => void | Promise<void>;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterApprovalDecisionManagementConfigurationError extends TypeError {
|
||||
readonly code = 'CLUSTER_APPROVAL_DECISION_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Cluster Approval decision configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterApprovalDecisionManagementConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the profile-neutral human decision contract over a caller-owned
|
||||
* PostgreSQL pool. Transport authentication and lifecycle remain outside this
|
||||
* authority so cluster nodes can reuse their existing mTLS/OIDC boundary.
|
||||
*/
|
||||
export function createClusterApprovalDecisionManagementService(
|
||||
options: ClusterApprovalDecisionManagementOptions,
|
||||
): Readonly<ApprovalDecisionService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'pool' && key !== 'confirmAuthorization' && key !== 'now',
|
||||
) ||
|
||||
!options.pool ||
|
||||
typeof options.pool.query !== 'function' ||
|
||||
typeof options.pool.connect !== 'function' ||
|
||||
(options.confirmAuthorization !== undefined &&
|
||||
typeof options.confirmAuthorization !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new ClusterApprovalDecisionManagementConfigurationError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
return createApprovalDecisionService({
|
||||
approvals: new PostgresApprovalRequestRepository(options.pool),
|
||||
policy: new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
),
|
||||
...(options.confirmAuthorization === undefined
|
||||
? {}
|
||||
: { confirmAuthorization: options.confirmAuthorization }),
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
PostgresApprovalManagementIdentityKeysetLedgerRepository,
|
||||
PostgresApprovalRequestRepository,
|
||||
PostgresApprovalRequestSource,
|
||||
PostgresProjectPolicyRepository,
|
||||
PostgresSecurityAuditRepository,
|
||||
} from '@qinglong/cluster-postgres/approval-manager';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createApprovalDecisionService,
|
||||
type ApprovalDecisionRequest,
|
||||
type ApprovalDecisionService,
|
||||
} from '@qinglong/runtime-core/approval-decision';
|
||||
import {
|
||||
createApprovalInspectionService,
|
||||
type ApprovalInspectionRequest,
|
||||
type ApprovalInspectionService,
|
||||
} from '@qinglong/runtime-core/approval-inspection';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
export interface ClusterApprovalManagementService {
|
||||
inspect(
|
||||
request: ApprovalInspectionRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
): ReturnType<ApprovalInspectionService['inspect']>;
|
||||
decide(
|
||||
request: ApprovalDecisionRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
): ReturnType<ApprovalDecisionService['decide']>;
|
||||
recordFailure(record: SecurityAuditRecord): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterApprovalManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementConfigurationError extends TypeError {
|
||||
readonly code = 'CLUSTER_APPROVAL_MANAGEMENT_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Cluster Approval management configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterApprovalManagementConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Dedicated Approval manager composition over one caller-owned Pool. */
|
||||
export function createClusterApprovalManagementService(
|
||||
options: ClusterApprovalManagementOptions,
|
||||
): Readonly<ClusterApprovalManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'pool' && key !== 'now') ||
|
||||
!options.pool ||
|
||||
typeof options.pool.query !== 'function' ||
|
||||
typeof options.pool.connect !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new ClusterApprovalManagementConfigurationError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const source = new PostgresApprovalRequestSource(options.pool);
|
||||
const audit = new PostgresSecurityAuditRepository(options.pool);
|
||||
return Object.freeze({
|
||||
inspect(
|
||||
request: ApprovalInspectionRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
) {
|
||||
return createApprovalInspectionService({
|
||||
source,
|
||||
policy,
|
||||
audit,
|
||||
confirmAuthorization,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
}).inspect(request);
|
||||
},
|
||||
decide(
|
||||
request: ApprovalDecisionRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
) {
|
||||
return createApprovalDecisionService({
|
||||
approvals,
|
||||
policy,
|
||||
confirmAuthorization,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
}).decide(request);
|
||||
},
|
||||
recordFailure(record: SecurityAuditRecord) {
|
||||
return audit.record(record);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { PostgresApprovalManagementIdentityKeysetLedgerRepository };
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
startClusterApprovalManagementProcess,
|
||||
type ClusterApprovalManagementProcessRuntime,
|
||||
} from './approvalManagementProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-approval-manage';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management',
|
||||
event: 'management_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(`${JSON.stringify({
|
||||
code: 'QL3_APPROVAL_MANAGEMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
let runtime: Readonly<ClusterApprovalManagementProcessRuntime>;
|
||||
try {
|
||||
runtime = await startClusterApprovalManagementProcess({
|
||||
environment: process.env,
|
||||
onError() {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management',
|
||||
event: 'management_unavailable',
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (runtime.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management',
|
||||
event: 'management_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management',
|
||||
event: 'management_started',
|
||||
address: runtime.address,
|
||||
identityGeneration: runtime.identity.generation,
|
||||
databaseContractVersion: runtime.database.contractVersion,
|
||||
databaseMigrationCount: runtime.database.migrationIds.length,
|
||||
});
|
||||
let stopping: Promise<void> | undefined;
|
||||
const stop = (): Promise<void> => {
|
||||
stopping ??= runtime.close().then(() => {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management',
|
||||
event: 'management_stopped',
|
||||
});
|
||||
});
|
||||
return stopping;
|
||||
};
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void stop().then(
|
||||
() => {
|
||||
process.exitCode = 0;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
@@ -0,0 +1,201 @@
|
||||
import { normalizeApprovedActionBinding } from '@qinglong/runtime-core/approved-action';
|
||||
import { normalizeApprovalDetailPreview } from '@qinglong/runtime-core/approval-discovery';
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
executeClusterAuthenticatedManagementClient,
|
||||
type ClusterAuthenticatedManagementClientResult,
|
||||
type ClusterPluginPackageManagementClientConnectionOptions,
|
||||
type ClusterPluginPackageManagementClientPaths,
|
||||
} from '../management-support/pluginPackageManagementClient';
|
||||
import {
|
||||
normalizeClusterApprovalManagementCommand,
|
||||
type ClusterApprovalManagementCommand,
|
||||
type ClusterApprovalManagementTransportResult,
|
||||
} from './approvalManagementTransport';
|
||||
|
||||
const MANAGEMENT_PATH = '/api/v3/approvals/management';
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export type ClusterApprovalManagementClientPaths =
|
||||
ClusterPluginPackageManagementClientPaths;
|
||||
export type ClusterApprovalManagementClientConnectionOptions =
|
||||
ClusterPluginPackageManagementClientConnectionOptions;
|
||||
export type ClusterApprovalManagementClientResult =
|
||||
ClusterAuthenticatedManagementClientResult<ClusterApprovalManagementTransportResult>;
|
||||
|
||||
function invalid(): never {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
|
||||
function exact(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const actual = Object.keys(value as object).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function subject(value: unknown): void {
|
||||
const record = exact(value, ['type', 'id']);
|
||||
if (
|
||||
(record.type !== 'user' && record.type !== 'system' && record.type !== 'agent') ||
|
||||
typeof record.id !== 'string'
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
identifier(record.id);
|
||||
}
|
||||
|
||||
function safeTime(value: unknown, nullable = false): void {
|
||||
if (nullable && value === null) return;
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 0) invalid();
|
||||
}
|
||||
|
||||
function action(value: unknown): void {
|
||||
try {
|
||||
normalizeApprovedActionBinding(value as never);
|
||||
} catch {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function inspectApproval(
|
||||
value: unknown,
|
||||
command: Extract<ClusterApprovalManagementCommand, { operation: 'approval.inspect' }>,
|
||||
): void {
|
||||
const approval = exact(value, [
|
||||
'projectId',
|
||||
'approvalRequestId',
|
||||
'version',
|
||||
'state',
|
||||
'risk',
|
||||
'decisionMode',
|
||||
'expectedAction',
|
||||
'requestedBy',
|
||||
'requestedAtMs',
|
||||
'expiresAtMs',
|
||||
'preview',
|
||||
]);
|
||||
if (
|
||||
identifier(approval.projectId) !== command.request.projectId ||
|
||||
identifier(approval.approvalRequestId) !== command.request.approvalRequestId ||
|
||||
!Number.isSafeInteger(approval.version) ||
|
||||
Number(approval.version) < 1 ||
|
||||
typeof approval.state !== 'string' ||
|
||||
typeof approval.risk !== 'string' ||
|
||||
typeof approval.decisionMode !== 'string'
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
action(approval.expectedAction);
|
||||
subject(approval.requestedBy);
|
||||
safeTime(approval.requestedAtMs);
|
||||
safeTime(approval.expiresAtMs);
|
||||
if (approval.preview !== null) {
|
||||
try {
|
||||
normalizeApprovalDetailPreview(approval.preview as never);
|
||||
} catch {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decisionApproval(
|
||||
value: unknown,
|
||||
command: Extract<ClusterApprovalManagementCommand, { operation: 'approval.decide' }>,
|
||||
): void {
|
||||
const approval = exact(value, [
|
||||
'projectId',
|
||||
'approvalRequestId',
|
||||
'version',
|
||||
'state',
|
||||
'expectedAction',
|
||||
'decisionId',
|
||||
'decision',
|
||||
'reasonCode',
|
||||
'decidedBy',
|
||||
'decidedAtMs',
|
||||
]);
|
||||
if (
|
||||
identifier(approval.projectId) !== command.request.projectId ||
|
||||
identifier(approval.approvalRequestId) !== command.request.approvalRequestId ||
|
||||
approval.version !== 2 ||
|
||||
approval.state !== command.request.decision ||
|
||||
identifier(approval.decisionId) !== command.request.decisionId ||
|
||||
approval.decision !== command.request.decision ||
|
||||
approval.reasonCode !== command.request.reasonCode
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
action(approval.expectedAction);
|
||||
subject(approval.decidedBy);
|
||||
safeTime(approval.decidedAtMs);
|
||||
}
|
||||
|
||||
export function validateClusterApprovalManagementClientResult(
|
||||
value: unknown,
|
||||
command: Readonly<ClusterApprovalManagementCommand>,
|
||||
): Readonly<ClusterApprovalManagementTransportResult> {
|
||||
const envelope = exact(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'status',
|
||||
'approval',
|
||||
]);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
envelope.operation !== command.operation
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (command.operation === 'approval.inspect') {
|
||||
if (
|
||||
(envelope.status !== 'found' && envelope.status !== 'absent') ||
|
||||
(envelope.status === 'absent') !== (envelope.approval === null)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (envelope.approval !== null) {
|
||||
inspectApproval(envelope.approval, command);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
envelope.status !== 'decided' &&
|
||||
envelope.status !== 'existing'
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
decisionApproval(envelope.approval, command);
|
||||
}
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterApprovalManagementTransportResult,
|
||||
);
|
||||
}
|
||||
|
||||
const PROTOCOL = Object.freeze({
|
||||
managementPath: MANAGEMENT_PATH,
|
||||
clientCertificate: 'required' as const,
|
||||
normalizeCommand: normalizeClusterApprovalManagementCommand,
|
||||
validateResult: validateClusterApprovalManagementClientResult,
|
||||
});
|
||||
|
||||
export async function executeClusterApprovalManagementClient(
|
||||
paths: ClusterApprovalManagementClientPaths,
|
||||
connectionOptions?: ClusterApprovalManagementClientConnectionOptions,
|
||||
): Promise<Readonly<ClusterApprovalManagementClientResult>> {
|
||||
return executeClusterAuthenticatedManagementClient(
|
||||
paths,
|
||||
PROTOCOL,
|
||||
connectionOptions,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { executeClusterApprovalManagementClient } from './approvalManagementClient';
|
||||
import { ClusterPluginPackageManagementClientRemoteError } from '../management-support/pluginPackageManagementClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-approval-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
|
||||
|
||||
function argumentsFrom(argv: readonly string[]): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 3) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(config|command|assertion)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_APPROVAL_MANAGEMENT_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = argumentsFrom(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management-client',
|
||||
event: 'usage_invalid',
|
||||
code: 'QL3_APPROVAL_MANAGEMENT_CLIENT_USAGE_INVALID',
|
||||
})}\n`);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await executeClusterApprovalManagementClient(paths);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-approval-management-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CLUSTER_APPROVAL_MANAGEMENT_PATH,
|
||||
startClusterPluginPackageManagementHttp,
|
||||
type ClusterPluginPackageManagementHttpApplication,
|
||||
type StartClusterPluginPackageManagementHttpOptions,
|
||||
} from '../management-support/pluginPackageManagementHttp';
|
||||
|
||||
export type ClusterApprovalManagementHttpApplication =
|
||||
ClusterPluginPackageManagementHttpApplication;
|
||||
|
||||
export type StartClusterApprovalManagementHttpOptions = Omit<
|
||||
StartClusterPluginPackageManagementHttpOptions,
|
||||
'managementPath'
|
||||
>;
|
||||
|
||||
/** Starts the shared bounded OIDC/mTLS HTTPS adapter on the Approval-only path. */
|
||||
export function startClusterApprovalManagementHttp(
|
||||
options: StartClusterApprovalManagementHttpOptions,
|
||||
): Promise<Readonly<ClusterApprovalManagementHttpApplication>> {
|
||||
return startClusterPluginPackageManagementHttp({
|
||||
...options,
|
||||
managementPath: CLUSTER_APPROVAL_MANAGEMENT_PATH,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
PostgresApprovalManagementIdentityKeysetLedgerRepository,
|
||||
assertPostgresApprovalManagerSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/approval-manager';
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
readManagementTlsFile,
|
||||
} from '../management-support/managementProcessSupport';
|
||||
import {
|
||||
createClusterApprovalIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetSnapshot,
|
||||
} from '../management-support/pluginPackageIdentityKeyset';
|
||||
import { validateClusterManagementClientTrust } from '../worker-credential/management-server/workerCredentialManagementMutualTls';
|
||||
import { createClusterApprovalManagementService } from './approvalManagement';
|
||||
import {
|
||||
startClusterApprovalManagementHttp,
|
||||
type ClusterApprovalManagementHttpApplication,
|
||||
type StartClusterApprovalManagementHttpOptions,
|
||||
} from './approvalManagementHttp';
|
||||
import { createClusterApprovalManagementTransport } from './approvalManagementTransport';
|
||||
|
||||
const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.:-]{0,254}$/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
|
||||
export type ClusterApprovalManagementProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterApprovalManagementProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
host: string;
|
||||
port: number;
|
||||
certificateFile: string;
|
||||
privateKeyFile: string;
|
||||
clientCertificateAuthorityFile: string;
|
||||
clientCertificateRevocationListFile: string;
|
||||
identityKeysetFile: string;
|
||||
http: Readonly<{
|
||||
maxBodyBytes: number;
|
||||
maxConnections: number;
|
||||
maxConcurrentRequests: number;
|
||||
requestTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
rateWindowMs: number;
|
||||
peerRequestLimit: number;
|
||||
globalRequestLimit: number;
|
||||
maxRateLimitPeers: number;
|
||||
}>;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterApprovalManagementProcessRuntime =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
close(): Promise<void>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
address: Readonly<{ host: string; port: number }>;
|
||||
database: PostgresSchemaReadinessReport;
|
||||
identity: ClusterPluginPackageIdentityKeysetSnapshot;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export interface StartClusterApprovalManagementProcessOptions {
|
||||
readonly environment: ClusterApprovalManagementProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly identities?: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly assertReady?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
) => Promise<PostgresSchemaReadinessReport>;
|
||||
readonly startHttp?: (
|
||||
options: StartClusterApprovalManagementHttpOptions,
|
||||
) => Promise<Readonly<ClusterApprovalManagementHttpApplication>>;
|
||||
readonly now?: () => number;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_APPROVAL_MANAGEMENT_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Approval management process configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterApprovalManagementProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function failure(message: string): ClusterApprovalManagementProcessConfigError {
|
||||
return new ClusterApprovalManagementProcessConfigError(message);
|
||||
}
|
||||
|
||||
function bounded(
|
||||
environment: ClusterApprovalManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
failure,
|
||||
required,
|
||||
);
|
||||
}
|
||||
|
||||
function bool(
|
||||
environment: ClusterApprovalManagementProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, failure);
|
||||
}
|
||||
|
||||
function integer(
|
||||
environment: ClusterApprovalManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
failure,
|
||||
);
|
||||
}
|
||||
|
||||
function absolute(
|
||||
environment: ClusterApprovalManagementProcessEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
return absoluteManagementEnvironmentFile(environment, name, failure);
|
||||
}
|
||||
|
||||
function loadDatabase(
|
||||
environment: ClusterApprovalManagementProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_APPROVAL_MANAGER_URL',
|
||||
host: 'QL3_POSTGRES_APPROVAL_MANAGER_HOST',
|
||||
port: 'QL3_POSTGRES_APPROVAL_MANAGER_PORT',
|
||||
database: 'QL3_POSTGRES_APPROVAL_MANAGER_DATABASE',
|
||||
user: 'QL3_POSTGRES_APPROVAL_MANAGER_USER',
|
||||
password: 'QL3_POSTGRES_APPROVAL_MANAGER_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw failure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL approval manager connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_APPROVAL_MANAGER_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw failure(
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!bool(environment, 'QL3_POSTGRES_APPROVAL_MANAGER_ALLOW_INSECURE')
|
||||
) {
|
||||
throw failure(
|
||||
'disabling approval manager PostgreSQL TLS requires QL3_POSTGRES_APPROVAL_MANAGER_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = bounded(
|
||||
environment,
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw failure(
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = bounded(
|
||||
environment,
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw failure(
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw failure('QL3_POSTGRES_APPROVAL_MANAGER_TLS_CA_FILE is invalid');
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
bounded(
|
||||
environment,
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-approval-manager';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw failure('QL3_POSTGRES_APPROVAL_MANAGER_APPLICATION_NAME is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
}),
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integer(
|
||||
environment,
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_POOL_MAX',
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
),
|
||||
idleTimeoutMs: integer(
|
||||
environment,
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_IDLE_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
connectionTimeoutMs: integer(
|
||||
environment,
|
||||
'QL3_POSTGRES_APPROVAL_MANAGER_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterApprovalManagementProcessConfig(
|
||||
environment: ClusterApprovalManagementProcessEnvironment,
|
||||
): Readonly<ClusterApprovalManagementProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw failure('environment is invalid');
|
||||
}
|
||||
if (!bool(environment, 'QL3_APPROVAL_MANAGEMENT_ENABLED')) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw failure(
|
||||
'QL3_PROFILE must be cluster-admin when Approval management is enabled',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
bounded(environment, 'QL3_APPROVAL_MANAGEMENT_HOST', 255) ?? '0.0.0.0';
|
||||
if (!SAFE_HOST.test(host)) {
|
||||
throw failure('QL3_APPROVAL_MANAGEMENT_HOST is invalid');
|
||||
}
|
||||
const http = Object.freeze({
|
||||
maxBodyBytes: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_MAX_BODY_BYTES',
|
||||
64 * 1024,
|
||||
1_024,
|
||||
256 * 1024,
|
||||
),
|
||||
maxConnections: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_MAX_CONNECTIONS',
|
||||
32,
|
||||
1,
|
||||
512,
|
||||
),
|
||||
maxConcurrentRequests: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
|
||||
16,
|
||||
1,
|
||||
256,
|
||||
),
|
||||
requestTimeoutMs: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_REQUEST_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
drainTimeoutMs: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_DRAIN_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
rateWindowMs: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
peerRequestLimit: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_PEER_REQUEST_LIMIT',
|
||||
30,
|
||||
1,
|
||||
10_000,
|
||||
),
|
||||
globalRequestLimit: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
|
||||
300,
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
maxRateLimitPeers: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_MAX_RATE_LIMIT_PEERS',
|
||||
1_024,
|
||||
1,
|
||||
16_384,
|
||||
),
|
||||
});
|
||||
if (http.globalRequestLimit < http.peerRequestLimit) {
|
||||
throw failure('global request limit cannot be below peer request limit');
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
host,
|
||||
port: integer(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_PORT',
|
||||
8_447,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
certificateFile: absolute(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_TLS_CERT_FILE',
|
||||
),
|
||||
privateKeyFile: absolute(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_TLS_KEY_FILE',
|
||||
),
|
||||
clientCertificateAuthorityFile: absolute(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_CLIENT_CA_FILE',
|
||||
),
|
||||
clientCertificateRevocationListFile: absolute(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_CLIENT_CRL_FILE',
|
||||
),
|
||||
identityKeysetFile: absolute(
|
||||
environment,
|
||||
'QL3_APPROVAL_MANAGEMENT_IDENTITY_KEYSET_FILE',
|
||||
),
|
||||
http,
|
||||
database: loadDatabase(environment),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startClusterApprovalManagementProcess(
|
||||
options: StartClusterApprovalManagementProcessOptions,
|
||||
): Promise<Readonly<ClusterApprovalManagementProcessRuntime>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'environment',
|
||||
'openDatabase',
|
||||
'identities',
|
||||
'assertReady',
|
||||
'startHttp',
|
||||
'now',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
!options.environment ||
|
||||
typeof options.environment !== 'object' ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.identities !== undefined &&
|
||||
(typeof options.identities.reload !== 'function' ||
|
||||
typeof options.identities.bind !== 'function')) ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.startHttp !== undefined &&
|
||||
typeof options.startHttp !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw failure('options are invalid');
|
||||
}
|
||||
const config = loadClusterApprovalManagementProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
let http: Readonly<ClusterApprovalManagementHttpApplication> | undefined;
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let unavailableError: unknown;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics never own availability or cleanup.
|
||||
}
|
||||
};
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'approval-manager',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError(error) {
|
||||
const first = unavailableError === undefined;
|
||||
unavailableError ??= error;
|
||||
http?.withdraw(error);
|
||||
if (first) report(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
database = await openDatabase();
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresApprovalManagerSchemaReady
|
||||
)(database.pool);
|
||||
if (unavailableError !== undefined) throw unavailableError;
|
||||
const identities =
|
||||
options.identities ??
|
||||
createClusterApprovalIdentityKeysetFile({
|
||||
filePath: config.identityKeysetFile,
|
||||
now,
|
||||
ledger: new PostgresApprovalManagementIdentityKeysetLedgerRepository(
|
||||
database.pool,
|
||||
'approval-management',
|
||||
),
|
||||
});
|
||||
const identity = await identities.reload();
|
||||
const service = createClusterApprovalManagementService({
|
||||
pool: database.pool,
|
||||
now,
|
||||
});
|
||||
const transport = createClusterApprovalManagementTransport({
|
||||
service,
|
||||
now,
|
||||
});
|
||||
const privateKey = readManagementTlsFile(
|
||||
config.privateKeyFile,
|
||||
true,
|
||||
failure,
|
||||
);
|
||||
try {
|
||||
const certificate = readManagementTlsFile(
|
||||
config.certificateFile,
|
||||
false,
|
||||
failure,
|
||||
);
|
||||
const clientCertificateAuthority = readManagementTlsFile(
|
||||
config.clientCertificateAuthorityFile,
|
||||
false,
|
||||
failure,
|
||||
);
|
||||
const clientCertificateRevocationList = readManagementTlsFile(
|
||||
config.clientCertificateRevocationListFile,
|
||||
false,
|
||||
failure,
|
||||
);
|
||||
validateClusterManagementClientTrust(
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
now(),
|
||||
failure,
|
||||
);
|
||||
http = await (options.startHttp ?? startClusterApprovalManagementHttp)({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: {
|
||||
privateKey,
|
||||
certificate,
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
},
|
||||
transport,
|
||||
identities,
|
||||
limits: config.http,
|
||||
now,
|
||||
onError: report,
|
||||
});
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
if (unavailableError !== undefined) http.withdraw(unavailableError);
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: http.address,
|
||||
database: evidence,
|
||||
identity,
|
||||
availabilityStatus: () => http?.availabilityStatus() ?? 'stopped',
|
||||
close(): Promise<void> {
|
||||
closePromise ??= (async () => {
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
await http?.close();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await http?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import {
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalPolicyFenceConflictError,
|
||||
ApprovalRequestExpiredError,
|
||||
ApprovalRequestStateConflictError,
|
||||
ApprovalRequestVersionConflictError,
|
||||
normalizeApprovedActionBinding,
|
||||
type ApprovedActionBinding,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
ApprovalDecisionAuthorizationError,
|
||||
ApprovalDecisionBindingConflictError,
|
||||
ApprovalDecisionTargetUnavailableError,
|
||||
ApprovalDecisionUnavailableError,
|
||||
} from '@qinglong/runtime-core/approval-decision';
|
||||
import {
|
||||
ApprovalInspectionAuthorizationError,
|
||||
ApprovalInspectionUnavailableError,
|
||||
} from '@qinglong/runtime-core/approval-inspection';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import type { ClusterApprovalManagementService } from './approvalManagement';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const STRONG_ASSURANCES = new Set(['multi_factor', 'hardware']);
|
||||
|
||||
interface BaseRequest {
|
||||
readonly projectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export type ClusterApprovalManagementCommand =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'approval.inspect';
|
||||
request: BaseRequest;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'approval.decide';
|
||||
request: BaseRequest & {
|
||||
readonly expectedVersion: 1;
|
||||
readonly expectedAction: Readonly<ApprovedActionBinding>;
|
||||
readonly decisionId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type ClusterApprovalManagementTransportResult = Readonly<
|
||||
Record<string, unknown> & {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: ClusterApprovalManagementCommand['operation'];
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClusterApprovalManagementAuthentication {
|
||||
authenticate(): Promise<Readonly<SecurityPrincipal> | null>;
|
||||
}
|
||||
|
||||
export interface ClusterApprovalManagementTransport {
|
||||
execute(
|
||||
command: unknown,
|
||||
authentication: ClusterApprovalManagementAuthentication,
|
||||
): Promise<Readonly<ClusterApprovalManagementTransportResult>>;
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementTransportConfigurationError extends TypeError {
|
||||
readonly code = 'CLUSTER_APPROVAL_TRANSPORT_CONFIGURATION_INVALID';
|
||||
constructor() {
|
||||
super('Cluster Approval transport configuration is invalid');
|
||||
this.name = 'ClusterApprovalManagementTransportConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementTransportRequestError extends TypeError {
|
||||
readonly code = 'CLUSTER_APPROVAL_TRANSPORT_REQUEST_INVALID';
|
||||
constructor() {
|
||||
super('Cluster Approval transport request is invalid');
|
||||
this.name = 'ClusterApprovalManagementTransportRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementTransportAuthenticationError extends Error {
|
||||
readonly code = 'CLUSTER_APPROVAL_TRANSPORT_AUTHENTICATION_REQUIRED';
|
||||
constructor() {
|
||||
super('Cluster Approval transport requires a strong User principal');
|
||||
this.name = 'ClusterApprovalManagementTransportAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementTransportAuthorizationError extends Error {
|
||||
readonly code = 'CLUSTER_APPROVAL_TRANSPORT_AUTHORIZATION_REJECTED';
|
||||
constructor() {
|
||||
super('Cluster Approval transport authorization was rejected');
|
||||
this.name = 'ClusterApprovalManagementTransportAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementTransportTargetUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_APPROVAL_TRANSPORT_TARGET_UNAVAILABLE';
|
||||
constructor() {
|
||||
super('Cluster Approval target is unavailable');
|
||||
this.name = 'ClusterApprovalManagementTransportTargetUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementTransportConflictError extends Error {
|
||||
readonly code = 'CLUSTER_APPROVAL_TRANSPORT_CONFLICT';
|
||||
constructor() {
|
||||
super('Cluster Approval transport observed a conflict');
|
||||
this.name = 'ClusterApprovalManagementTransportConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterApprovalManagementTransportUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_APPROVAL_TRANSPORT_UNAVAILABLE';
|
||||
constructor() {
|
||||
super('Cluster Approval transport is unavailable');
|
||||
this.name = 'ClusterApprovalManagementTransportUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new ClusterApprovalManagementTransportRequestError();
|
||||
}
|
||||
|
||||
function exact(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const actual = Object.keys(value as object).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function uuid(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeClusterApprovalManagementCommand(
|
||||
value: unknown,
|
||||
): Readonly<ClusterApprovalManagementCommand> {
|
||||
const envelope = exact(value, ['schemaVersion', 'operation', 'request']);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
(envelope.operation !== 'approval.inspect' &&
|
||||
envelope.operation !== 'approval.decide')
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const operation = envelope.operation;
|
||||
const base = [
|
||||
'projectId',
|
||||
'approvalRequestId',
|
||||
'requestId',
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
];
|
||||
const request = exact(
|
||||
envelope.request,
|
||||
operation === 'approval.inspect'
|
||||
? base
|
||||
: [
|
||||
...base,
|
||||
'expectedVersion',
|
||||
'expectedAction',
|
||||
'decisionId',
|
||||
'decision',
|
||||
'reasonCode',
|
||||
],
|
||||
);
|
||||
const normalizedBase = {
|
||||
projectId: identifier(request.projectId),
|
||||
approvalRequestId: identifier(request.approvalRequestId),
|
||||
requestId: identifier(request.requestId),
|
||||
auditEventId: uuid(request.auditEventId),
|
||||
failureAuditEventId: uuid(request.failureAuditEventId),
|
||||
};
|
||||
if (normalizedBase.auditEventId === normalizedBase.failureAuditEventId) invalid();
|
||||
if (operation === 'approval.inspect') {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
request: Object.freeze(normalizedBase),
|
||||
});
|
||||
}
|
||||
if (
|
||||
request.expectedVersion !== 1 ||
|
||||
(request.decision !== 'approved' && request.decision !== 'rejected') ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
let expectedAction: Readonly<ApprovedActionBinding>;
|
||||
try {
|
||||
expectedAction = normalizeApprovedActionBinding(
|
||||
request.expectedAction as ApprovedActionBinding,
|
||||
);
|
||||
} catch {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
request: Object.freeze({
|
||||
...normalizedBase,
|
||||
expectedVersion: 1,
|
||||
expectedAction,
|
||||
decisionId: identifier(request.decisionId),
|
||||
decision: request.decision,
|
||||
reasonCode: request.reasonCode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function authenticatedPrincipal(
|
||||
candidate: Readonly<SecurityPrincipal> | null,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(candidate as SecurityPrincipal, nowMs);
|
||||
} catch {
|
||||
throw new ClusterApprovalManagementTransportAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new ClusterApprovalManagementTransportAuthenticationError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
function samePrincipal(
|
||||
left: Readonly<SecurityPrincipal>,
|
||||
right: Readonly<SecurityPrincipal>,
|
||||
): boolean {
|
||||
return (
|
||||
left.subject.type === right.subject.type &&
|
||||
left.subject.id === right.subject.id &&
|
||||
left.authenticationId === right.authenticationId &&
|
||||
left.authenticatedAtMs === right.authenticatedAtMs &&
|
||||
left.expiresAtMs === right.expiresAtMs &&
|
||||
left.assurance === right.assurance
|
||||
);
|
||||
}
|
||||
|
||||
function failureReason(error: unknown, authenticated: boolean): Readonly<{
|
||||
outcome: SecurityAuditRecord['outcome'];
|
||||
reason: string;
|
||||
}> {
|
||||
if (error instanceof ClusterApprovalManagementTransportAuthenticationError) {
|
||||
return authenticated
|
||||
? Object.freeze({
|
||||
outcome: 'denied',
|
||||
reason: 'identity_confirmation_rejected',
|
||||
})
|
||||
: Object.freeze({
|
||||
outcome: 'authentication_rejected',
|
||||
reason: 'identity_assertion_rejected',
|
||||
});
|
||||
}
|
||||
if (
|
||||
error instanceof ApprovalInspectionAuthorizationError ||
|
||||
error instanceof ApprovalDecisionAuthorizationError
|
||||
) {
|
||||
return Object.freeze({ outcome: 'denied', reason: 'policy_rejected' });
|
||||
}
|
||||
if (error instanceof ApprovalDecisionTargetUnavailableError) {
|
||||
return Object.freeze({ outcome: 'denied', reason: 'approval_target_unavailable' });
|
||||
}
|
||||
if (error instanceof ApprovalDecisionBindingConflictError) {
|
||||
return Object.freeze({ outcome: 'denied', reason: 'approval_binding_conflict' });
|
||||
}
|
||||
if (
|
||||
error instanceof ApprovalRequestVersionConflictError ||
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalRequestExpiredError ||
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError
|
||||
) {
|
||||
return Object.freeze({ outcome: 'denied', reason: 'approval_state_or_fence_conflict' });
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: 'authorization_unavailable',
|
||||
reason: 'approval_authority_unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
function observedTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new ClusterApprovalManagementTransportUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapped(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ApprovalInspectionAuthorizationError ||
|
||||
error instanceof ApprovalDecisionAuthorizationError
|
||||
) {
|
||||
return new ClusterApprovalManagementTransportAuthorizationError();
|
||||
}
|
||||
if (error instanceof ApprovalDecisionTargetUnavailableError) {
|
||||
return new ClusterApprovalManagementTransportTargetUnavailableError();
|
||||
}
|
||||
if (
|
||||
error instanceof ApprovalDecisionBindingConflictError ||
|
||||
error instanceof ApprovalRequestVersionConflictError ||
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalRequestExpiredError ||
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError
|
||||
) {
|
||||
return new ClusterApprovalManagementTransportConflictError();
|
||||
}
|
||||
if (
|
||||
error instanceof ApprovalInspectionUnavailableError ||
|
||||
error instanceof ApprovalDecisionUnavailableError
|
||||
) {
|
||||
return new ClusterApprovalManagementTransportUnavailableError();
|
||||
}
|
||||
return error instanceof Error
|
||||
? error
|
||||
: new ClusterApprovalManagementTransportUnavailableError();
|
||||
}
|
||||
|
||||
export function createClusterApprovalManagementTransport(options: Readonly<{
|
||||
service: ClusterApprovalManagementService;
|
||||
now?: () => number;
|
||||
}>): Readonly<ClusterApprovalManagementTransport> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'service' && key !== 'now') ||
|
||||
typeof options.service?.inspect !== 'function' ||
|
||||
typeof options.service?.decide !== 'function' ||
|
||||
typeof options.service?.recordFailure !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new ClusterApprovalManagementTransportConfigurationError();
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
return Object.freeze({
|
||||
async execute(
|
||||
commandValue: unknown,
|
||||
authentication: ClusterApprovalManagementAuthentication,
|
||||
) {
|
||||
const command = normalizeClusterApprovalManagementCommand(commandValue);
|
||||
if (
|
||||
!authentication ||
|
||||
typeof authentication !== 'object' ||
|
||||
Array.isArray(authentication) ||
|
||||
Object.keys(authentication).length !== 1 ||
|
||||
typeof authentication.authenticate !== 'function'
|
||||
) {
|
||||
throw new ClusterApprovalManagementTransportConfigurationError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal> | undefined;
|
||||
try {
|
||||
try {
|
||||
principal = authenticatedPrincipal(
|
||||
await authentication.authenticate(),
|
||||
observedTime(now),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterApprovalManagementTransportAuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterApprovalManagementTransportUnavailableError();
|
||||
}
|
||||
const confirmAuthorization = async (): Promise<void> => {
|
||||
let confirmed: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
confirmed = authenticatedPrincipal(
|
||||
await authentication.authenticate(),
|
||||
observedTime(now),
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterApprovalManagementTransportAuthenticationError();
|
||||
}
|
||||
if (!samePrincipal(principal!, confirmed)) {
|
||||
throw new ClusterApprovalManagementTransportAuthenticationError();
|
||||
}
|
||||
};
|
||||
if (command.operation === 'approval.inspect') {
|
||||
const detail = await options.service.inspect(
|
||||
{
|
||||
projectId: command.request.projectId,
|
||||
approvalRequestId: command.request.approvalRequestId,
|
||||
auditEventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
principal,
|
||||
},
|
||||
confirmAuthorization,
|
||||
);
|
||||
if (!detail) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: 'absent' as const,
|
||||
approval: null,
|
||||
});
|
||||
}
|
||||
const request = detail.request;
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: 'found' as const,
|
||||
approval: Object.freeze({
|
||||
projectId: request.projectId,
|
||||
approvalRequestId: request.id,
|
||||
version: request.version,
|
||||
state: request.state,
|
||||
risk: request.risk,
|
||||
decisionMode: request.decisionMode,
|
||||
expectedAction: request.action,
|
||||
requestedBy: request.requestedBy,
|
||||
requestedAtMs: request.requestedAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
preview: detail.preview,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const result = await options.service.decide(
|
||||
{
|
||||
projectId: command.request.projectId,
|
||||
approvalRequestId: command.request.approvalRequestId,
|
||||
expectedVersion: command.request.expectedVersion,
|
||||
expectedAction: command.request.expectedAction,
|
||||
decisionId: command.request.decisionId,
|
||||
decision: command.request.decision,
|
||||
reasonCode: command.request.reasonCode,
|
||||
auditEventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
principal,
|
||||
},
|
||||
confirmAuthorization,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
approval: Object.freeze({
|
||||
projectId: result.request.projectId,
|
||||
approvalRequestId: result.request.id,
|
||||
version: result.request.version,
|
||||
state: result.request.state,
|
||||
expectedAction: result.request.action,
|
||||
decisionId: result.request.decisionId,
|
||||
decision: result.request.decision,
|
||||
reasonCode: result.request.decisionReasonCode,
|
||||
decidedBy: result.request.decidedBy,
|
||||
decidedAtMs: result.request.decidedAtMs,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof ClusterApprovalManagementTransportConfigurationError)) {
|
||||
const fact = failureReason(error, principal !== undefined);
|
||||
try {
|
||||
await options.service.recordFailure({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
subject: principal?.subject ?? null,
|
||||
authenticationId: principal?.authenticationId ?? null,
|
||||
outcome: fact.outcome,
|
||||
reasons: Object.freeze([fact.reason]),
|
||||
fence: null,
|
||||
occurredAtMs: observedTime(now),
|
||||
});
|
||||
} catch {
|
||||
throw new ClusterApprovalManagementTransportUnavailableError();
|
||||
}
|
||||
}
|
||||
throw mapped(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/** Automation-management application service boundary. */
|
||||
import {
|
||||
InvalidTaskDefinitionError,
|
||||
TaskDefinitionConflictError,
|
||||
TaskDefinitionUnavailableError,
|
||||
assertTaskDefinitionIdentifier,
|
||||
assertTaskDefinitionPageSize,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
normalizeTaskDefinitionCursor,
|
||||
type AppendTaskDefinitionRevisionCommand,
|
||||
type TaskDefinitionCursor,
|
||||
type TaskDefinitionPage,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
InvalidTaskDefinitionAdministrationReadError,
|
||||
InvalidTaskDefinitionAdministrationMutationError,
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError,
|
||||
TaskDefinitionAdministrationMutationConflictError,
|
||||
TaskDefinitionAdministrationReadConflictError,
|
||||
type TaskDefinitionAdministrationRepository,
|
||||
type TaskDefinitionAdministrationSource,
|
||||
} from '@qinglong/runtime-core/task-definition-administration';
|
||||
import {
|
||||
InvalidTriggerError,
|
||||
TriggerConflictError,
|
||||
TriggerUnavailableError,
|
||||
assertTriggerIdentifier,
|
||||
assertTriggerPageSize,
|
||||
normalizeAppendTriggerRevisionCommand,
|
||||
normalizeTriggerCursor,
|
||||
type AppendTriggerRevisionCommand,
|
||||
type TriggerCursor,
|
||||
type TriggerPage,
|
||||
type TriggerRecord,
|
||||
} from '@qinglong/runtime-core/trigger';
|
||||
import {
|
||||
InvalidTriggerAdministrationReadError,
|
||||
InvalidTriggerAdministrationMutationError,
|
||||
TriggerAdministrationAuthorizationFenceConflictError,
|
||||
TriggerAdministrationMutationConflictError,
|
||||
TriggerAdministrationReadConflictError,
|
||||
type TriggerAdministrationRepository,
|
||||
type TriggerAdministrationSource,
|
||||
} from '@qinglong/runtime-core/trigger-administration';
|
||||
import type { ProjectPermission } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const AUDIT_EVENT_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const STRONG_USER_ASSURANCES = new Set(['multi_factor', 'hardware']);
|
||||
|
||||
export interface ClusterAutomationManagementPolicy {
|
||||
authorize(
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
projectId: string,
|
||||
permission: ProjectPermission,
|
||||
): Promise<Readonly<SecurityPolicyDecision>>;
|
||||
}
|
||||
|
||||
export interface ClusterAutomationManagementService {
|
||||
publishTask(request: Readonly<{
|
||||
requestId: string;
|
||||
command: AppendTaskDefinitionRevisionCommand;
|
||||
principal: SecurityPrincipal;
|
||||
}>): Promise<Readonly<{
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
definition: TaskDefinitionRecord;
|
||||
}>>;
|
||||
publishTrigger(request: Readonly<{
|
||||
requestId: string;
|
||||
command: AppendTriggerRevisionCommand;
|
||||
principal: SecurityPrincipal;
|
||||
}>): Promise<Readonly<{
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
trigger: TriggerRecord;
|
||||
}>>;
|
||||
inspectTask(request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
principal: SecurityPrincipal;
|
||||
}>): Promise<TaskDefinitionRecord | null>;
|
||||
listTasks(request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
limit: number;
|
||||
after?: TaskDefinitionCursor;
|
||||
principal: SecurityPrincipal;
|
||||
}>): Promise<TaskDefinitionPage>;
|
||||
inspectTrigger(request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
triggerId: string;
|
||||
principal: SecurityPrincipal;
|
||||
}>): Promise<TriggerRecord | null>;
|
||||
listTriggers(request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
limit: number;
|
||||
after?: TriggerCursor;
|
||||
principal: SecurityPrincipal;
|
||||
}>): Promise<TriggerPage>;
|
||||
}
|
||||
|
||||
export interface ClusterAutomationManagementOptions {
|
||||
readonly policy: ClusterAutomationManagementPolicy;
|
||||
readonly taskDefinitions: TaskDefinitionAdministrationRepository &
|
||||
TaskDefinitionAdministrationSource;
|
||||
readonly triggers: TriggerAdministrationRepository &
|
||||
TriggerAdministrationSource;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementRequestError extends TypeError {
|
||||
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_REQUEST_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation management request is invalid');
|
||||
this.name = 'ClusterAutomationManagementRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementAuthorizationError extends Error {
|
||||
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation management is forbidden');
|
||||
this.name = 'ClusterAutomationManagementAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementConflictError extends Error {
|
||||
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation management conflicts with durable state');
|
||||
this.name = 'ClusterAutomationManagementConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_AUTOMATION_MANAGEMENT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation management is unavailable');
|
||||
this.name = 'ClusterAutomationManagementUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactRequest(value: unknown): void {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 3 ||
|
||||
!Object.hasOwn(value, 'requestId') ||
|
||||
!Object.hasOwn(value, 'command') ||
|
||||
!Object.hasOwn(value, 'principal') ||
|
||||
typeof (value as { requestId?: unknown }).requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test((value as { requestId: string }).requestId)
|
||||
) {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
}
|
||||
|
||||
function mapMutationError(error: unknown): never {
|
||||
if (
|
||||
error instanceof InvalidTaskDefinitionError ||
|
||||
error instanceof InvalidTaskDefinitionAdministrationMutationError ||
|
||||
error instanceof InvalidTriggerError ||
|
||||
error instanceof InvalidTriggerAdministrationMutationError
|
||||
) {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
if (
|
||||
error instanceof TaskDefinitionConflictError ||
|
||||
error instanceof TaskDefinitionAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof TaskDefinitionAdministrationMutationConflictError ||
|
||||
error instanceof TriggerConflictError ||
|
||||
error instanceof TriggerAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof TriggerAdministrationMutationConflictError
|
||||
) {
|
||||
throw new ClusterAutomationManagementConflictError();
|
||||
}
|
||||
if (
|
||||
error instanceof TaskDefinitionUnavailableError ||
|
||||
error instanceof TriggerUnavailableError
|
||||
) {
|
||||
throw new ClusterAutomationManagementUnavailableError();
|
||||
}
|
||||
throw new ClusterAutomationManagementUnavailableError();
|
||||
}
|
||||
|
||||
function mapReadError(error: unknown): never {
|
||||
if (
|
||||
error instanceof InvalidTaskDefinitionError ||
|
||||
error instanceof InvalidTaskDefinitionAdministrationReadError ||
|
||||
error instanceof InvalidTriggerError ||
|
||||
error instanceof InvalidTriggerAdministrationReadError
|
||||
) {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
if (
|
||||
error instanceof TaskDefinitionAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof TaskDefinitionAdministrationReadConflictError ||
|
||||
error instanceof TriggerAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof TriggerAdministrationReadConflictError
|
||||
) {
|
||||
throw new ClusterAutomationManagementConflictError();
|
||||
}
|
||||
throw new ClusterAutomationManagementUnavailableError();
|
||||
}
|
||||
|
||||
function exactReadBase(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
!required.every((key) => keys.includes(key)) ||
|
||||
keys.some((key) => !required.includes(key) && !optional.includes(key)) ||
|
||||
typeof (value as { requestId?: unknown }).requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test((value as { requestId: string }).requestId) ||
|
||||
typeof (value as { auditEventId?: unknown }).auditEventId !== 'string' ||
|
||||
!AUDIT_EVENT_ID_PATTERN.test(
|
||||
(value as { auditEventId: string }).auditEventId,
|
||||
)
|
||||
) {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
}
|
||||
|
||||
function readAudit(
|
||||
request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
}>,
|
||||
operationId: 'task.read' | 'trigger.read',
|
||||
authority: Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
decision: Readonly<SecurityPolicyDecision>;
|
||||
observedAtMs: number;
|
||||
}>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
eventId: request.auditEventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
subject: authority.principal.subject,
|
||||
authenticationId: authority.principal.authenticationId,
|
||||
outcome: 'allowed' as const,
|
||||
reasons: authority.decision.reasons,
|
||||
fence: authority.decision.fence,
|
||||
occurredAtMs: authority.observedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterAutomationManagementService(
|
||||
options: ClusterAutomationManagementOptions,
|
||||
): Readonly<ClusterAutomationManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'policy' &&
|
||||
key !== 'taskDefinitions' &&
|
||||
key !== 'triggers' &&
|
||||
key !== 'now',
|
||||
) ||
|
||||
!options.policy ||
|
||||
typeof options.policy.authorize !== 'function' ||
|
||||
!options.taskDefinitions ||
|
||||
typeof options.taskDefinitions.appendAuthorizedTaskDefinitionRevision !==
|
||||
'function' ||
|
||||
typeof options.taskDefinitions.findAuthorizedCurrentTaskDefinition !==
|
||||
'function' ||
|
||||
typeof options.taskDefinitions.listAuthorizedTaskDefinitions !==
|
||||
'function' ||
|
||||
!options.triggers ||
|
||||
typeof options.triggers.appendAuthorizedTriggerRevision !== 'function' ||
|
||||
typeof options.triggers.findAuthorizedCurrentTrigger !== 'function' ||
|
||||
typeof options.triggers.listAuthorizedTriggers !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Cluster automation management options are invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
permission: ProjectPermission,
|
||||
) => {
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new ClusterAutomationManagementUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementAuthorizationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new ClusterAutomationManagementAuthorizationError();
|
||||
}
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = await options.policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
permission,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementUnavailableError();
|
||||
}
|
||||
if (
|
||||
decision.effect !== 'allow' ||
|
||||
decision.fence === null ||
|
||||
decision.fence.bindingVersion === null
|
||||
) {
|
||||
throw new ClusterAutomationManagementAuthorizationError();
|
||||
}
|
||||
return Object.freeze({ principal, decision, observedAtMs });
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async publishTask(
|
||||
request: Parameters<ClusterAutomationManagementService['publishTask']>[0],
|
||||
) {
|
||||
exactRequest(request);
|
||||
let command: Readonly<AppendTaskDefinitionRevisionCommand>;
|
||||
try {
|
||||
command = normalizeAppendTaskDefinitionRevisionCommand(request.command);
|
||||
} catch (error) {
|
||||
return mapMutationError(error);
|
||||
}
|
||||
const operation =
|
||||
command.expectedRevision === null ? 'task.create' : 'task.update';
|
||||
const authority = await authorize(
|
||||
request.principal,
|
||||
command.projectId,
|
||||
operation,
|
||||
);
|
||||
try {
|
||||
return await options.taskDefinitions.appendAuthorizedTaskDefinitionRevision(
|
||||
{
|
||||
command,
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: {
|
||||
eventId: command.mutationId,
|
||||
requestId: request.requestId,
|
||||
operationId: operation,
|
||||
projectId: command.projectId,
|
||||
subject: authority.principal.subject,
|
||||
authenticationId: authority.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: authority.decision.reasons,
|
||||
fence: authority.decision.fence,
|
||||
occurredAtMs: authority.observedAtMs,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return mapMutationError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async publishTrigger(
|
||||
request: Parameters<ClusterAutomationManagementService['publishTrigger']>[0],
|
||||
) {
|
||||
exactRequest(request);
|
||||
let command: Readonly<AppendTriggerRevisionCommand>;
|
||||
try {
|
||||
command = normalizeAppendTriggerRevisionCommand(request.command);
|
||||
} catch (error) {
|
||||
return mapMutationError(error);
|
||||
}
|
||||
const operation =
|
||||
command.expectedRevision === null
|
||||
? 'trigger.create'
|
||||
: 'trigger.update';
|
||||
const authority = await authorize(
|
||||
request.principal,
|
||||
command.projectId,
|
||||
operation,
|
||||
);
|
||||
try {
|
||||
return await options.triggers.appendAuthorizedTriggerRevision({
|
||||
command,
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: {
|
||||
eventId: command.mutationId,
|
||||
requestId: request.requestId,
|
||||
operationId: operation,
|
||||
projectId: command.projectId,
|
||||
subject: authority.principal.subject,
|
||||
authenticationId: authority.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: authority.decision.reasons,
|
||||
fence: authority.decision.fence,
|
||||
occurredAtMs: authority.observedAtMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return mapMutationError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async inspectTask(
|
||||
request: Parameters<ClusterAutomationManagementService['inspectTask']>[0],
|
||||
) {
|
||||
exactReadBase(request, [
|
||||
'auditEventId',
|
||||
'principal',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'taskId',
|
||||
]);
|
||||
try {
|
||||
assertTaskDefinitionIdentifier(request.projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(request.taskId, 'taskId');
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
const authority = await authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
'task.read',
|
||||
);
|
||||
try {
|
||||
return await options.taskDefinitions.findAuthorizedCurrentTaskDefinition(
|
||||
{
|
||||
projectId: request.projectId,
|
||||
taskId: request.taskId,
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: readAudit(request, 'task.read', authority),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return mapReadError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async listTasks(
|
||||
request: Parameters<ClusterAutomationManagementService['listTasks']>[0],
|
||||
) {
|
||||
exactReadBase(
|
||||
request,
|
||||
[
|
||||
'auditEventId',
|
||||
'limit',
|
||||
'principal',
|
||||
'projectId',
|
||||
'requestId',
|
||||
],
|
||||
['after'],
|
||||
);
|
||||
let after: TaskDefinitionCursor | undefined;
|
||||
try {
|
||||
assertTaskDefinitionIdentifier(request.projectId, 'projectId');
|
||||
assertTaskDefinitionPageSize(request.limit);
|
||||
after = Object.hasOwn(request, 'after')
|
||||
? normalizeTaskDefinitionCursor(request.after as TaskDefinitionCursor)
|
||||
: undefined;
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
const authority = await authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
'task.read',
|
||||
);
|
||||
try {
|
||||
return await options.taskDefinitions.listAuthorizedTaskDefinitions({
|
||||
projectId: request.projectId,
|
||||
limit: request.limit,
|
||||
...(after ? { after } : {}),
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: readAudit(request, 'task.read', authority),
|
||||
});
|
||||
} catch (error) {
|
||||
return mapReadError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async inspectTrigger(
|
||||
request: Parameters<
|
||||
ClusterAutomationManagementService['inspectTrigger']
|
||||
>[0],
|
||||
) {
|
||||
exactReadBase(request, [
|
||||
'auditEventId',
|
||||
'principal',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'triggerId',
|
||||
]);
|
||||
try {
|
||||
assertTriggerIdentifier(request.projectId, 'projectId');
|
||||
assertTriggerIdentifier(request.triggerId, 'triggerId');
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
const authority = await authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
'trigger.read',
|
||||
);
|
||||
try {
|
||||
return await options.triggers.findAuthorizedCurrentTrigger({
|
||||
projectId: request.projectId,
|
||||
triggerId: request.triggerId,
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: readAudit(request, 'trigger.read', authority),
|
||||
});
|
||||
} catch (error) {
|
||||
return mapReadError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async listTriggers(
|
||||
request: Parameters<
|
||||
ClusterAutomationManagementService['listTriggers']
|
||||
>[0],
|
||||
) {
|
||||
exactReadBase(
|
||||
request,
|
||||
[
|
||||
'auditEventId',
|
||||
'limit',
|
||||
'principal',
|
||||
'projectId',
|
||||
'requestId',
|
||||
],
|
||||
['after'],
|
||||
);
|
||||
let after: TriggerCursor | undefined;
|
||||
try {
|
||||
assertTriggerIdentifier(request.projectId, 'projectId');
|
||||
assertTriggerPageSize(request.limit);
|
||||
after = Object.hasOwn(request, 'after')
|
||||
? normalizeTriggerCursor(request.after as TriggerCursor)
|
||||
: undefined;
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementRequestError();
|
||||
}
|
||||
const authority = await authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
'trigger.read',
|
||||
);
|
||||
try {
|
||||
return await options.triggers.listAuthorizedTriggers({
|
||||
projectId: request.projectId,
|
||||
limit: request.limit,
|
||||
...(after ? { after } : {}),
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: readAudit(request, 'trigger.read', authority),
|
||||
});
|
||||
} catch (error) {
|
||||
return mapReadError(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
startClusterAutomationManagementProcess,
|
||||
type ClusterAutomationManagementProcessRuntime,
|
||||
} from './automationManagementProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-automation-manage';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management',
|
||||
event: 'management_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_AUTOMATION_MANAGEMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
|
||||
let runtime: Readonly<ClusterAutomationManagementProcessRuntime>;
|
||||
try {
|
||||
runtime = await startClusterAutomationManagementProcess({
|
||||
environment: process.env,
|
||||
onError() {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management',
|
||||
event: 'management_unavailable',
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (runtime.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management',
|
||||
event: 'management_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management',
|
||||
event: 'management_started',
|
||||
address: runtime.address,
|
||||
identityGeneration: runtime.identity.generation,
|
||||
databaseContractVersion: runtime.database.contractVersion,
|
||||
databaseMigrationCount: runtime.database.migrationIds.length,
|
||||
});
|
||||
|
||||
let stopping: Promise<void> | undefined;
|
||||
const stop = (): Promise<void> => {
|
||||
stopping ??= runtime.close().then(() => {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management',
|
||||
event: 'management_stopped',
|
||||
});
|
||||
});
|
||||
return stopping;
|
||||
};
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void stop().then(
|
||||
() => {
|
||||
process.exitCode = 0;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
executeClusterAuthenticatedManagementClient,
|
||||
type ClusterAuthenticatedManagementClientResult,
|
||||
type ClusterPluginPackageManagementClientConnectionOptions,
|
||||
type ClusterPluginPackageManagementClientPaths,
|
||||
} from '../management-support/pluginPackageManagementClient';
|
||||
import {
|
||||
normalizeClusterAutomationManagementCommand,
|
||||
type ClusterAutomationManagementCommand,
|
||||
type ClusterAutomationManagementTransportResult,
|
||||
} from './automationManagementTransport';
|
||||
|
||||
const MANAGEMENT_PATH = '/api/v3/automations/management';
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export type ClusterAutomationManagementClientPaths =
|
||||
ClusterPluginPackageManagementClientPaths;
|
||||
export type ClusterAutomationManagementClientConnectionOptions =
|
||||
ClusterPluginPackageManagementClientConnectionOptions;
|
||||
export type ClusterAutomationManagementClientResult =
|
||||
ClusterAuthenticatedManagementClientResult<ClusterAutomationManagementTransportResult>;
|
||||
|
||||
function invalid(): never {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function identifier(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 128 ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveRevision(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) invalid();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function taskResultSummary(
|
||||
value: unknown,
|
||||
projectId: string,
|
||||
taskId?: string,
|
||||
): Record<string, unknown> {
|
||||
const task = exactRecord(value, [
|
||||
'projectId',
|
||||
'taskId',
|
||||
'revision',
|
||||
'kind',
|
||||
'enabled',
|
||||
'contentDigest',
|
||||
'updatedAtMs',
|
||||
]);
|
||||
if (
|
||||
identifier(task.projectId) !== projectId ||
|
||||
(taskId !== undefined && identifier(task.taskId) !== taskId) ||
|
||||
identifier(task.kind).length > 64 ||
|
||||
typeof task.enabled !== 'boolean' ||
|
||||
!Number.isSafeInteger(task.updatedAtMs) ||
|
||||
(task.updatedAtMs as number) < 0
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
identifier(task.taskId);
|
||||
positiveRevision(task.revision);
|
||||
digest(task.contentDigest);
|
||||
return task;
|
||||
}
|
||||
|
||||
function triggerResultSummary(
|
||||
value: unknown,
|
||||
projectId: string,
|
||||
triggerId?: string,
|
||||
): Record<string, unknown> {
|
||||
const trigger = exactRecord(value, [
|
||||
'projectId',
|
||||
'triggerId',
|
||||
'revision',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
'taskContentDigest',
|
||||
'enabled',
|
||||
'contentDigest',
|
||||
'updatedAtMs',
|
||||
]);
|
||||
if (
|
||||
identifier(trigger.projectId) !== projectId ||
|
||||
(triggerId !== undefined && identifier(trigger.triggerId) !== triggerId) ||
|
||||
typeof trigger.enabled !== 'boolean' ||
|
||||
!Number.isSafeInteger(trigger.updatedAtMs) ||
|
||||
(trigger.updatedAtMs as number) < 0
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
identifier(trigger.triggerId);
|
||||
identifier(trigger.taskId);
|
||||
positiveRevision(trigger.revision);
|
||||
positiveRevision(trigger.taskRevision);
|
||||
digest(trigger.taskContentDigest);
|
||||
digest(trigger.contentDigest);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
function validateListPage(
|
||||
envelope: Record<string, unknown>,
|
||||
itemsKey: 'tasks' | 'triggers',
|
||||
limit: number,
|
||||
afterId: string | undefined,
|
||||
idKey: 'taskId' | 'triggerId',
|
||||
validateItem: (value: unknown) => Record<string, unknown>,
|
||||
): void {
|
||||
if (
|
||||
!Array.isArray(envelope[itemsKey]) ||
|
||||
(envelope[itemsKey] as unknown[]).length > limit ||
|
||||
typeof envelope.truncated !== 'boolean'
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
let previous = afterId;
|
||||
for (const itemValue of envelope[itemsKey] as unknown[]) {
|
||||
const item = validateItem(itemValue);
|
||||
const current = identifier(item[idKey]);
|
||||
if (previous !== undefined && current <= previous) invalid();
|
||||
previous = current;
|
||||
}
|
||||
if (envelope.truncated) {
|
||||
const next = exactRecord(envelope.next, [idKey]);
|
||||
const nextId = identifier(next[idKey]);
|
||||
if (previous === undefined || nextId !== previous) invalid();
|
||||
} else if (envelope.next !== null) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
export function validateClusterAutomationManagementClientResult(
|
||||
value: unknown,
|
||||
command: Readonly<ClusterAutomationManagementCommand>,
|
||||
): Readonly<ClusterAutomationManagementTransportResult> {
|
||||
const operation = command.operation;
|
||||
if (operation === 'task.inspect') {
|
||||
const envelope = exactRecord(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'status',
|
||||
'task',
|
||||
]);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
envelope.operation !== operation ||
|
||||
!['found', 'absent'].includes(String(envelope.status)) ||
|
||||
(envelope.status === 'absent') !== (envelope.task === null)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (envelope.task !== null) {
|
||||
taskResultSummary(
|
||||
envelope.task,
|
||||
command.request.projectId,
|
||||
command.request.taskId,
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterAutomationManagementTransportResult,
|
||||
);
|
||||
}
|
||||
if (operation === 'trigger.inspect') {
|
||||
const envelope = exactRecord(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'status',
|
||||
'trigger',
|
||||
]);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
envelope.operation !== operation ||
|
||||
!['found', 'absent'].includes(String(envelope.status)) ||
|
||||
(envelope.status === 'absent') !== (envelope.trigger === null)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (envelope.trigger !== null) {
|
||||
triggerResultSummary(
|
||||
envelope.trigger,
|
||||
command.request.projectId,
|
||||
command.request.triggerId,
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterAutomationManagementTransportResult,
|
||||
);
|
||||
}
|
||||
if (operation === 'task.list') {
|
||||
const envelope = exactRecord(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'tasks',
|
||||
'truncated',
|
||||
'next',
|
||||
]);
|
||||
if (envelope.schemaVersion !== 1 || envelope.operation !== operation) {
|
||||
invalid();
|
||||
}
|
||||
validateListPage(
|
||||
envelope,
|
||||
'tasks',
|
||||
command.request.limit,
|
||||
command.request.after?.taskId,
|
||||
'taskId',
|
||||
(item) => taskResultSummary(item, command.request.projectId),
|
||||
);
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterAutomationManagementTransportResult,
|
||||
);
|
||||
}
|
||||
if (operation === 'trigger.list') {
|
||||
const envelope = exactRecord(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'triggers',
|
||||
'truncated',
|
||||
'next',
|
||||
]);
|
||||
if (envelope.schemaVersion !== 1 || envelope.operation !== operation) {
|
||||
invalid();
|
||||
}
|
||||
validateListPage(
|
||||
envelope,
|
||||
'triggers',
|
||||
command.request.limit,
|
||||
command.request.after?.triggerId,
|
||||
'triggerId',
|
||||
(item) => triggerResultSummary(item, command.request.projectId),
|
||||
);
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterAutomationManagementTransportResult,
|
||||
);
|
||||
}
|
||||
const envelope = exactRecord(
|
||||
value,
|
||||
operation === 'task.publish'
|
||||
? ['schemaVersion', 'operation', 'status', 'task']
|
||||
: ['schemaVersion', 'operation', 'status', 'trigger'],
|
||||
);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
envelope.operation !== operation ||
|
||||
!['created', 'updated', 'existing'].includes(String(envelope.status))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (command.operation === 'task.publish') {
|
||||
const requested = command.request.command;
|
||||
taskResultSummary(envelope.task, requested.projectId, requested.taskId);
|
||||
} else {
|
||||
const requested = command.request.command;
|
||||
const trigger = triggerResultSummary(
|
||||
envelope.trigger,
|
||||
requested.projectId,
|
||||
requested.triggerId,
|
||||
);
|
||||
if (
|
||||
identifier(trigger.taskId) !== requested.taskId ||
|
||||
positiveRevision(trigger.taskRevision) !== requested.taskRevision ||
|
||||
digest(trigger.taskContentDigest) !== requested.taskContentDigest
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterAutomationManagementTransportResult,
|
||||
);
|
||||
}
|
||||
|
||||
const PROTOCOL = Object.freeze({
|
||||
managementPath: MANAGEMENT_PATH,
|
||||
clientCertificate: 'required' as const,
|
||||
normalizeCommand: normalizeClusterAutomationManagementCommand,
|
||||
validateResult: validateClusterAutomationManagementClientResult,
|
||||
});
|
||||
|
||||
export async function executeClusterAutomationManagementClient(
|
||||
paths: ClusterAutomationManagementClientPaths,
|
||||
connectionOptions?: ClusterAutomationManagementClientConnectionOptions,
|
||||
): Promise<Readonly<ClusterAutomationManagementClientResult>> {
|
||||
return executeClusterAuthenticatedManagementClient(
|
||||
paths,
|
||||
PROTOCOL,
|
||||
connectionOptions,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { executeClusterAutomationManagementClient } from './automationManagementClient';
|
||||
import { ClusterPluginPackageManagementClientRemoteError } from '../management-support/pluginPackageManagementClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-automation-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
|
||||
|
||||
function parseArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 3) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(config|command|assertion)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_AUTOMATION_MANAGEMENT_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = parseArguments(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management-client',
|
||||
event: 'usage_invalid',
|
||||
code: 'QL3_AUTOMATION_MANAGEMENT_CLIENT_USAGE_INVALID',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await executeClusterAutomationManagementClient(paths);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-automation-management-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CLUSTER_AUTOMATION_MANAGEMENT_PATH,
|
||||
startClusterPluginPackageManagementHttp,
|
||||
type ClusterPluginPackageManagementHttpApplication,
|
||||
type StartClusterPluginPackageManagementHttpOptions,
|
||||
} from '../management-support/pluginPackageManagementHttp';
|
||||
|
||||
export type ClusterAutomationManagementHttpApplication =
|
||||
ClusterPluginPackageManagementHttpApplication;
|
||||
|
||||
export type StartClusterAutomationManagementHttpOptions = Omit<
|
||||
StartClusterPluginPackageManagementHttpOptions,
|
||||
'managementPath'
|
||||
>;
|
||||
|
||||
/** Starts the shared bounded OIDC/mTLS HTTPS adapter on the automation-only path. */
|
||||
export function startClusterAutomationManagementHttp(
|
||||
options: StartClusterAutomationManagementHttpOptions,
|
||||
): Promise<Readonly<ClusterAutomationManagementHttpApplication>> {
|
||||
return startClusterPluginPackageManagementHttp({
|
||||
...options,
|
||||
managementPath: CLUSTER_AUTOMATION_MANAGEMENT_PATH,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
PostgresAutomationManagementIdentityKeysetLedgerRepository,
|
||||
PostgresProjectPolicyRepository,
|
||||
PostgresTaskDefinitionAdministrationRepository,
|
||||
PostgresTriggerAdministrationRepository,
|
||||
assertPostgresAutomationManagerSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/automation-manager';
|
||||
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
readManagementTlsFile,
|
||||
} from '../management-support/managementProcessSupport';
|
||||
import {
|
||||
createClusterAutomationIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetSnapshot,
|
||||
} from '../management-support/pluginPackageIdentityKeyset';
|
||||
import { createClusterAutomationManagementService } from './automationManagement';
|
||||
import {
|
||||
startClusterAutomationManagementHttp,
|
||||
type ClusterAutomationManagementHttpApplication,
|
||||
type StartClusterAutomationManagementHttpOptions,
|
||||
} from './automationManagementHttp';
|
||||
import { createClusterAutomationManagementTransport } from './automationManagementTransport';
|
||||
import { validateClusterManagementClientTrust } from '../worker-credential/management-server/workerCredentialManagementMutualTls';
|
||||
|
||||
const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.:-]{0,254}$/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
|
||||
export type ClusterAutomationManagementProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterAutomationManagementProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
host: string;
|
||||
port: number;
|
||||
certificateFile: string;
|
||||
privateKeyFile: string;
|
||||
clientCertificateAuthorityFile: string;
|
||||
clientCertificateRevocationListFile: string;
|
||||
identityKeysetFile: string;
|
||||
http: Readonly<{
|
||||
maxBodyBytes: number;
|
||||
maxConnections: number;
|
||||
maxConcurrentRequests: number;
|
||||
requestTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
rateWindowMs: number;
|
||||
peerRequestLimit: number;
|
||||
globalRequestLimit: number;
|
||||
maxRateLimitPeers: number;
|
||||
}>;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterAutomationManagementProcessRuntime =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
close(): Promise<void>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
address: Readonly<{ host: string; port: number }>;
|
||||
database: PostgresSchemaReadinessReport;
|
||||
identity: ClusterPluginPackageIdentityKeysetSnapshot;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export interface StartClusterAutomationManagementProcessOptions {
|
||||
readonly environment: ClusterAutomationManagementProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly identities?: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly assertReady?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
) => Promise<PostgresSchemaReadinessReport>;
|
||||
readonly startHttp?: (
|
||||
options: StartClusterAutomationManagementHttpOptions,
|
||||
) => Promise<Readonly<ClusterAutomationManagementHttpApplication>>;
|
||||
readonly now?: () => number;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_AUTOMATION_MANAGEMENT_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Automation management process configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterAutomationManagementProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function configFailure(
|
||||
message: string,
|
||||
): ClusterAutomationManagementProcessConfigError {
|
||||
return new ClusterAutomationManagementProcessConfigError(message);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterAutomationManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
configFailure,
|
||||
required,
|
||||
);
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterAutomationManagementProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterAutomationManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function absoluteFile(
|
||||
environment: ClusterAutomationManagementProcessEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
return absoluteManagementEnvironmentFile(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterAutomationManagementProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_AUTOMATION_MANAGER_URL',
|
||||
host: 'QL3_POSTGRES_AUTOMATION_MANAGER_HOST',
|
||||
port: 'QL3_POSTGRES_AUTOMATION_MANAGER_PORT',
|
||||
database: 'QL3_POSTGRES_AUTOMATION_MANAGER_DATABASE',
|
||||
user: 'QL3_POSTGRES_AUTOMATION_MANAGER_USER',
|
||||
password: 'QL3_POSTGRES_AUTOMATION_MANAGER_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw configFailure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL automation manager connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_AUTOMATION_MANAGER_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(environment, 'QL3_POSTGRES_AUTOMATION_MANAGER_ALLOW_INSECURE')
|
||||
) {
|
||||
throw configFailure(
|
||||
'disabling automation manager PostgreSQL TLS requires QL3_POSTGRES_AUTOMATION_MANAGER_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_TLS_CA_FILE is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-automation-manager';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
}),
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_POOL_MAX',
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
),
|
||||
idleTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_IDLE_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AUTOMATION_MANAGER_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterAutomationManagementProcessConfig(
|
||||
environment: ClusterAutomationManagementProcessEnvironment,
|
||||
): Readonly<ClusterAutomationManagementProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw configFailure('environment is invalid');
|
||||
}
|
||||
if (!booleanValue(environment, 'QL3_AUTOMATION_MANAGEMENT_ENABLED')) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw configFailure(
|
||||
'QL3_PROFILE must be cluster-admin when automation management is enabled',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
boundedValue(environment, 'QL3_AUTOMATION_MANAGEMENT_HOST', 255) ??
|
||||
'0.0.0.0';
|
||||
if (!SAFE_HOST.test(host)) {
|
||||
throw configFailure('QL3_AUTOMATION_MANAGEMENT_HOST is invalid');
|
||||
}
|
||||
const http = Object.freeze({
|
||||
maxBodyBytes: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_MAX_BODY_BYTES',
|
||||
64 * 1024,
|
||||
1_024,
|
||||
256 * 1024,
|
||||
),
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_MAX_CONNECTIONS',
|
||||
32,
|
||||
1,
|
||||
512,
|
||||
),
|
||||
maxConcurrentRequests: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
|
||||
16,
|
||||
1,
|
||||
256,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_REQUEST_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_DRAIN_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
rateWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
peerRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_PEER_REQUEST_LIMIT',
|
||||
60,
|
||||
1,
|
||||
10_000,
|
||||
),
|
||||
globalRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
|
||||
600,
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
maxRateLimitPeers: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_MAX_RATE_LIMIT_PEERS',
|
||||
1_024,
|
||||
1,
|
||||
16_384,
|
||||
),
|
||||
});
|
||||
if (http.globalRequestLimit < http.peerRequestLimit) {
|
||||
throw configFailure(
|
||||
'global request limit cannot be below the peer request limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
host,
|
||||
port: integerValue(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_PORT',
|
||||
8_445,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
certificateFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_TLS_CERT_FILE',
|
||||
),
|
||||
privateKeyFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_TLS_KEY_FILE',
|
||||
),
|
||||
clientCertificateAuthorityFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_CLIENT_CA_FILE',
|
||||
),
|
||||
clientCertificateRevocationListFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_CLIENT_CRL_FILE',
|
||||
),
|
||||
identityKeysetFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_AUTOMATION_MANAGEMENT_IDENTITY_KEYSET_FILE',
|
||||
),
|
||||
http,
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function readTlsFile(filePath: string, privateMaterial: boolean): Buffer {
|
||||
return readManagementTlsFile(filePath, privateMaterial, configFailure);
|
||||
}
|
||||
|
||||
export async function startClusterAutomationManagementProcess(
|
||||
options: StartClusterAutomationManagementProcessOptions,
|
||||
): Promise<Readonly<ClusterAutomationManagementProcessRuntime>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'environment',
|
||||
'openDatabase',
|
||||
'identities',
|
||||
'assertReady',
|
||||
'startHttp',
|
||||
'now',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
!options.environment ||
|
||||
typeof options.environment !== 'object' ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.identities !== undefined &&
|
||||
(typeof options.identities.reload !== 'function' ||
|
||||
typeof options.identities.bind !== 'function')) ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.startHttp !== undefined &&
|
||||
typeof options.startHttp !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw configFailure('options are invalid');
|
||||
}
|
||||
const config = loadClusterAutomationManagementProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
let http: Readonly<ClusterAutomationManagementHttpApplication> | undefined;
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let unavailableError: unknown;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics do not own availability or cleanup.
|
||||
}
|
||||
};
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'automation-manager',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError(error) {
|
||||
const firstAvailabilityError = unavailableError === undefined;
|
||||
unavailableError ??= error;
|
||||
http?.withdraw(error);
|
||||
if (firstAvailabilityError) report(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
database = await openDatabase();
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresAutomationManagerSchemaReady
|
||||
)(database.pool);
|
||||
if (unavailableError !== undefined) throw unavailableError;
|
||||
const identities =
|
||||
options.identities ??
|
||||
createClusterAutomationIdentityKeysetFile({
|
||||
filePath: config.identityKeysetFile,
|
||||
now,
|
||||
ledger: new PostgresAutomationManagementIdentityKeysetLedgerRepository(
|
||||
database.pool,
|
||||
'automation-management',
|
||||
),
|
||||
});
|
||||
const identity = await identities.reload();
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(database.pool),
|
||||
);
|
||||
const service = createClusterAutomationManagementService({
|
||||
policy,
|
||||
taskDefinitions: new PostgresTaskDefinitionAdministrationRepository(
|
||||
database.pool,
|
||||
),
|
||||
triggers: new PostgresTriggerAdministrationRepository(database.pool),
|
||||
now,
|
||||
});
|
||||
const transport = createClusterAutomationManagementTransport({
|
||||
service,
|
||||
now,
|
||||
});
|
||||
const privateKey = readTlsFile(config.privateKeyFile, true);
|
||||
try {
|
||||
const certificate = readTlsFile(config.certificateFile, false);
|
||||
const clientCertificateAuthority = readTlsFile(
|
||||
config.clientCertificateAuthorityFile,
|
||||
false,
|
||||
);
|
||||
const clientCertificateRevocationList = readTlsFile(
|
||||
config.clientCertificateRevocationListFile,
|
||||
false,
|
||||
);
|
||||
validateClusterManagementClientTrust(
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
now(),
|
||||
configFailure,
|
||||
);
|
||||
http = await (options.startHttp ?? startClusterAutomationManagementHttp)({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: {
|
||||
privateKey,
|
||||
certificate,
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
},
|
||||
transport,
|
||||
identities,
|
||||
limits: config.http,
|
||||
now,
|
||||
onError: report,
|
||||
});
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
if (unavailableError !== undefined) http.withdraw(unavailableError);
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: http.address,
|
||||
database: evidence,
|
||||
identity,
|
||||
availabilityStatus: () => http?.availabilityStatus() ?? 'stopped',
|
||||
close(): Promise<void> {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = (async () => {
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
await http?.close();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await http?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
import {
|
||||
assertTaskDefinitionIdentifier,
|
||||
assertTaskDefinitionPageSize,
|
||||
normalizeTaskDefinitionCursor,
|
||||
type AppendTaskDefinitionRevisionCommand,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
assertTriggerIdentifier,
|
||||
assertTriggerPageSize,
|
||||
normalizeTriggerCursor,
|
||||
type AppendTriggerRevisionCommand,
|
||||
type TriggerRecord,
|
||||
} from '@qinglong/runtime-core/trigger';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { ClusterAutomationManagementService } from './automationManagement';
|
||||
|
||||
const STRONG_CLUSTER_ASSURANCES = new Set(['multi_factor', 'hardware']);
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const AUDIT_EVENT_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface ClusterAutomationManagementAuthentication {
|
||||
authenticate(): Promise<Readonly<SecurityPrincipal> | null>;
|
||||
}
|
||||
|
||||
export type ClusterAutomationManagementCommand =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.publish';
|
||||
request: Readonly<{
|
||||
requestId: string;
|
||||
command: AppendTaskDefinitionRevisionCommand;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.publish';
|
||||
request: Readonly<{
|
||||
requestId: string;
|
||||
command: AppendTriggerRevisionCommand;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.inspect';
|
||||
request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.list';
|
||||
request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
limit: number;
|
||||
after?: Readonly<{ taskId: string }>;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.inspect';
|
||||
request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
triggerId: string;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.list';
|
||||
request: Readonly<{
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
projectId: string;
|
||||
limit: number;
|
||||
after?: Readonly<{ triggerId: string }>;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterAutomationManagementTransportResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.publish';
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
task: ReturnType<typeof taskSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.publish';
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
trigger: ReturnType<typeof triggerSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.inspect';
|
||||
status: 'found' | 'absent';
|
||||
task: ReturnType<typeof taskSummary> | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.list';
|
||||
tasks: readonly ReturnType<typeof taskSummary>[];
|
||||
truncated: boolean;
|
||||
next: Readonly<{ taskId: string }> | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.inspect';
|
||||
status: 'found' | 'absent';
|
||||
trigger: ReturnType<typeof triggerSummary> | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.list';
|
||||
triggers: readonly ReturnType<typeof triggerSummary>[];
|
||||
truncated: boolean;
|
||||
next: Readonly<{ triggerId: string }> | null;
|
||||
}>;
|
||||
|
||||
export interface ClusterAutomationManagementTransport {
|
||||
execute(
|
||||
command: unknown,
|
||||
authentication: ClusterAutomationManagementAuthentication,
|
||||
): Promise<Readonly<ClusterAutomationManagementTransportResult>>;
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementTransportConfigurationError extends TypeError {
|
||||
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_CONFIGURATION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation transport configuration is invalid');
|
||||
this.name = 'ClusterAutomationManagementTransportConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementTransportRequestError extends TypeError {
|
||||
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_REQUEST_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation transport request is invalid');
|
||||
this.name = 'ClusterAutomationManagementTransportRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementTransportAuthenticationError extends Error {
|
||||
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation transport requires a strong User principal');
|
||||
this.name = 'ClusterAutomationManagementTransportAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAutomationManagementTransportUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_AUTOMATION_TRANSPORT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster automation transport is unavailable');
|
||||
this.name = 'ClusterAutomationManagementTransportUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): ClusterAutomationManagementCommand {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 3 ||
|
||||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
|
||||
![
|
||||
'task.publish',
|
||||
'trigger.publish',
|
||||
'task.inspect',
|
||||
'task.list',
|
||||
'trigger.inspect',
|
||||
'trigger.list',
|
||||
].includes(String((value as { operation?: unknown }).operation)) ||
|
||||
!Object.hasOwn(value, 'request') ||
|
||||
!(value as { request?: unknown }).request ||
|
||||
typeof (value as { request: unknown }).request !== 'object' ||
|
||||
Array.isArray((value as { request: unknown }).request) ||
|
||||
!Object.hasOwn((value as { request: object }).request, 'requestId')
|
||||
) {
|
||||
throw new ClusterAutomationManagementTransportRequestError();
|
||||
}
|
||||
const operation = (value as { operation: string }).operation;
|
||||
const request = (value as { request: Record<string, unknown> }).request;
|
||||
const keys = Object.keys(request);
|
||||
const required = operation.endsWith('.publish')
|
||||
? ['command', 'requestId']
|
||||
: operation.endsWith('.inspect')
|
||||
? [
|
||||
'auditEventId',
|
||||
operation.startsWith('task.') ? 'taskId' : 'triggerId',
|
||||
'projectId',
|
||||
'requestId',
|
||||
]
|
||||
: ['auditEventId', 'limit', 'projectId', 'requestId'];
|
||||
const optional = operation.endsWith('.list') ? ['after'] : [];
|
||||
if (
|
||||
!required.every((key) => keys.includes(key)) ||
|
||||
keys.some((key) => !required.includes(key) && !optional.includes(key))
|
||||
) {
|
||||
throw new ClusterAutomationManagementTransportRequestError();
|
||||
}
|
||||
if (
|
||||
typeof request.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(request.requestId)
|
||||
) {
|
||||
throw new ClusterAutomationManagementTransportRequestError();
|
||||
}
|
||||
if (!operation.endsWith('.publish')) {
|
||||
if (
|
||||
typeof request.auditEventId !== 'string' ||
|
||||
!AUDIT_EVENT_ID_PATTERN.test(request.auditEventId) ||
|
||||
typeof request.projectId !== 'string'
|
||||
) {
|
||||
throw new ClusterAutomationManagementTransportRequestError();
|
||||
}
|
||||
try {
|
||||
if (operation.startsWith('task.')) {
|
||||
assertTaskDefinitionIdentifier(request.projectId, 'projectId');
|
||||
if (operation === 'task.inspect') {
|
||||
assertTaskDefinitionIdentifier(request.taskId as string, 'taskId');
|
||||
} else {
|
||||
assertTaskDefinitionPageSize(request.limit as number);
|
||||
if (Object.hasOwn(request, 'after')) {
|
||||
normalizeTaskDefinitionCursor(
|
||||
request.after as Readonly<{ taskId: string }>,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assertTriggerIdentifier(request.projectId, 'projectId');
|
||||
if (operation === 'trigger.inspect') {
|
||||
assertTriggerIdentifier(request.triggerId as string, 'triggerId');
|
||||
} else {
|
||||
assertTriggerPageSize(request.limit as number);
|
||||
if (Object.hasOwn(request, 'after')) {
|
||||
normalizeTriggerCursor(
|
||||
request.after as Readonly<{ triggerId: string }>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementTransportRequestError();
|
||||
}
|
||||
}
|
||||
return value as ClusterAutomationManagementCommand;
|
||||
}
|
||||
|
||||
export function normalizeClusterAutomationManagementCommand(
|
||||
value: unknown,
|
||||
): Readonly<ClusterAutomationManagementCommand> {
|
||||
return normalizeCommand(value);
|
||||
}
|
||||
|
||||
function taskSummary(definition: Readonly<TaskDefinitionRecord>) {
|
||||
return Object.freeze({
|
||||
projectId: definition.projectId,
|
||||
taskId: definition.taskId,
|
||||
revision: definition.revision,
|
||||
kind: definition.kind,
|
||||
enabled: definition.enabled,
|
||||
contentDigest: definition.contentDigest,
|
||||
updatedAtMs: definition.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function triggerSummary(trigger: Readonly<TriggerRecord>) {
|
||||
return Object.freeze({
|
||||
projectId: trigger.projectId,
|
||||
triggerId: trigger.triggerId,
|
||||
revision: trigger.revision,
|
||||
taskId: trigger.taskId,
|
||||
taskRevision: trigger.taskRevision,
|
||||
taskContentDigest: trigger.taskContentDigest,
|
||||
enabled: trigger.enabled,
|
||||
contentDigest: trigger.contentDigest,
|
||||
updatedAtMs: trigger.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterAutomationManagementTransport(options: Readonly<{
|
||||
service: ClusterAutomationManagementService;
|
||||
now?: () => number;
|
||||
}>): Readonly<ClusterAutomationManagementTransport> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'service' && key !== 'now') ||
|
||||
!options.service ||
|
||||
typeof options.service.publishTask !== 'function' ||
|
||||
typeof options.service.publishTrigger !== 'function' ||
|
||||
typeof options.service.inspectTask !== 'function' ||
|
||||
typeof options.service.listTasks !== 'function' ||
|
||||
typeof options.service.inspectTrigger !== 'function' ||
|
||||
typeof options.service.listTriggers !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new ClusterAutomationManagementTransportConfigurationError();
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
return Object.freeze({
|
||||
async execute(
|
||||
commandValue: unknown,
|
||||
authentication: ClusterAutomationManagementAuthentication,
|
||||
) {
|
||||
const command = normalizeCommand(commandValue);
|
||||
if (
|
||||
!authentication ||
|
||||
typeof authentication !== 'object' ||
|
||||
Array.isArray(authentication) ||
|
||||
Object.keys(authentication).length !== 1 ||
|
||||
typeof authentication.authenticate !== 'function'
|
||||
) {
|
||||
throw new ClusterAutomationManagementTransportConfigurationError();
|
||||
}
|
||||
let candidate: Readonly<SecurityPrincipal> | null;
|
||||
try {
|
||||
candidate = await authentication.authenticate();
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementTransportUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(candidate as SecurityPrincipal, now());
|
||||
} catch {
|
||||
throw new ClusterAutomationManagementTransportAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_CLUSTER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new ClusterAutomationManagementTransportAuthenticationError();
|
||||
}
|
||||
if (command.operation === 'task.publish') {
|
||||
const result = await options.service.publishTask({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
task: taskSummary(result.definition),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'trigger.publish') {
|
||||
const result = await options.service.publishTrigger({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
trigger: triggerSummary(result.trigger),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'task.inspect') {
|
||||
const task = await options.service.inspectTask({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: task ? ('found' as const) : ('absent' as const),
|
||||
task: task ? taskSummary(task) : null,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'task.list') {
|
||||
const page = await options.service.listTasks({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
tasks: Object.freeze(page.definitions.map(taskSummary)),
|
||||
truncated: page.truncated,
|
||||
next: page.next ?? null,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'trigger.inspect') {
|
||||
const trigger = await options.service.inspectTrigger({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: trigger ? ('found' as const) : ('absent' as const),
|
||||
trigger: trigger ? triggerSummary(trigger) : null,
|
||||
});
|
||||
}
|
||||
const page = await options.service.listTriggers({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
triggers: Object.freeze(page.triggers.map(triggerSummary)),
|
||||
truncated: page.truncated,
|
||||
next: page.next ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Shared bounded process-configuration authority for cluster management planes. */
|
||||
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const MAX_TLS_FILE_BYTES = 256 * 1024;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export type ClusterManagementProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterManagementProcessConfigurationFailure = (
|
||||
message: string,
|
||||
) => Error;
|
||||
|
||||
export function boundedManagementEnvironmentValue(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') {
|
||||
if (required) throw failure(`${name} is required`);
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > maximumLength || CONTROL_PATTERN.test(value)) {
|
||||
throw failure(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function booleanManagementEnvironmentValue(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return false;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw failure(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
export function integerManagementEnvironmentValue(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return fallback;
|
||||
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) {
|
||||
throw failure(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw failure(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function absoluteManagementEnvironmentFile(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): string {
|
||||
const value = boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
4_096,
|
||||
failure,
|
||||
true,
|
||||
)!;
|
||||
if (!isAbsolute(value)) {
|
||||
throw failure(`${name} must be an absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readManagementTlsFile(
|
||||
filePath: string,
|
||||
privateMaterial: boolean,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): Buffer {
|
||||
let descriptor: number | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
descriptor = openSync(filePath, constants.O_RDONLY);
|
||||
const stat = fstatSync(descriptor);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_TLS_FILE_BYTES ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(privateMaterial && (stat.mode & 0o007) !== 0)
|
||||
) {
|
||||
throw failure('TLS file authority is invalid');
|
||||
}
|
||||
bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const read = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (read === 0) break;
|
||||
offset += read;
|
||||
}
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
offset !== stat.size ||
|
||||
offset > MAX_TLS_FILE_BYTES ||
|
||||
stat.dev !== after.dev ||
|
||||
stat.ino !== after.ino ||
|
||||
stat.size !== after.size ||
|
||||
stat.mtimeMs !== after.mtimeMs ||
|
||||
stat.ctimeMs !== after.ctimeMs
|
||||
) {
|
||||
throw failure('TLS file changed while being read');
|
||||
}
|
||||
return bytes.subarray(0, offset);
|
||||
} catch (error) {
|
||||
if (privateMaterial) bytes?.fill(0);
|
||||
throw error;
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
/** Shared authenticated identity assertion boundary for cluster management planes. */
|
||||
import {
|
||||
constants,
|
||||
createHash,
|
||||
createPublicKey,
|
||||
verify as verifySignature,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityAuthenticationAssurance,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
const ASSERTION_ALGORITHMS = ['EdDSA', 'ES256', 'RS256'] as const;
|
||||
const MAX_KEYS = 8;
|
||||
const MAX_ASSURANCE_MAPPINGS = 8;
|
||||
const MAX_AMR_VALUES = 8;
|
||||
const MIN_ASSERTION_BYTES = 512;
|
||||
const MAX_ASSERTION_BYTES = 16 * 1024;
|
||||
const DEFAULT_ASSERTION_BYTES = 8 * 1024;
|
||||
const MIN_LIFETIME_MS = 30_000;
|
||||
const MAX_LIFETIME_MS = 15 * 60_000;
|
||||
const DEFAULT_LIFETIME_MS = 5 * 60_000;
|
||||
const MAX_AUTHENTICATION_AGE_MS = 15 * 60_000;
|
||||
const DEFAULT_AUTHENTICATION_AGE_MS = 5 * 60_000;
|
||||
const MAX_CLOCK_SKEW_MS = 60_000;
|
||||
const DEFAULT_CLOCK_SKEW_MS = 5_000;
|
||||
const TOKEN_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ASSERTION_TYPE_PATTERN = /^ql3-[a-z0-9]+(?:-[a-z0-9]+)*\+jwt$/;
|
||||
const ASSERTION_PURPOSE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
type AssertionAlgorithm = (typeof ASSERTION_ALGORITHMS)[number];
|
||||
type AssertionAssurance = Extract<
|
||||
SecurityAuthenticationAssurance,
|
||||
'multi_factor' | 'hardware'
|
||||
>;
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionAssuranceMapping {
|
||||
readonly acr: string;
|
||||
readonly assurance: AssertionAssurance;
|
||||
readonly requiredAmr: readonly string[];
|
||||
}
|
||||
|
||||
export interface ClusterManagementIdentityAssertionProfile {
|
||||
readonly type: string;
|
||||
readonly purpose: string;
|
||||
}
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-plugin-package-management+jwt',
|
||||
purpose: 'plugin-package-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-worker-credential-management+jwt',
|
||||
purpose: 'worker-credential-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_AUTOMATION_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-automation-management+jwt',
|
||||
purpose: 'automation-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-approval-management+jwt',
|
||||
purpose: 'approval-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-model-provider-credential-management+jwt',
|
||||
purpose: 'model-provider-credential-management',
|
||||
});
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionVerifierOptions {
|
||||
readonly issuer: string;
|
||||
readonly audience: string;
|
||||
readonly keys: readonly Readonly<Record<string, unknown>>[];
|
||||
readonly assuranceMappings: readonly ClusterPluginPackageIdentityAssertionAssuranceMapping[];
|
||||
readonly assertionProfile?: Readonly<ClusterManagementIdentityAssertionProfile>;
|
||||
readonly maxAssertionBytes?: number;
|
||||
readonly maxLifetimeMs?: number;
|
||||
readonly maxAuthenticationAgeMs?: number;
|
||||
readonly clockSkewMs?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionAuthentication {
|
||||
authenticate(): Promise<Readonly<SecurityPrincipal>>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionVerifier {
|
||||
verify(assertion: unknown): Readonly<SecurityPrincipal>;
|
||||
bind(
|
||||
assertion: unknown,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionAuthentication>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityAssertionConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster Plugin Package identity assertion configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageIdentityAssertionConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityAssertionAuthenticationError extends Error {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster Plugin Package identity assertion is invalid');
|
||||
this.name = 'ClusterPluginPackageIdentityAssertionAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewedAssertionKey {
|
||||
readonly kid: string;
|
||||
readonly algorithm: AssertionAlgorithm;
|
||||
readonly key: KeyObject;
|
||||
}
|
||||
|
||||
interface ReviewedAssuranceMapping {
|
||||
readonly assurance: AssertionAssurance;
|
||||
readonly requiredAmr: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function configurationFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageIdentityAssertionConfigurationError {
|
||||
return new ClusterPluginPackageIdentityAssertionConfigurationError(message);
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure(`${label} must be an object`);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw configurationFailure(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewedAssertionProfile(
|
||||
value: unknown,
|
||||
): Readonly<ClusterManagementIdentityAssertionProfile> {
|
||||
exactObject(value, ['type', 'purpose'], 'assertion profile');
|
||||
if (
|
||||
typeof value.type !== 'string' ||
|
||||
value.type.length > 128 ||
|
||||
!ASSERTION_TYPE_PATTERN.test(value.type) ||
|
||||
typeof value.purpose !== 'string' ||
|
||||
value.purpose.length > 96 ||
|
||||
!ASSERTION_PURPOSE_PATTERN.test(value.purpose)
|
||||
) {
|
||||
throw configurationFailure('assertion profile is invalid');
|
||||
}
|
||||
return Object.freeze({ type: value.type, purpose: value.purpose });
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
const candidate = value ?? fallback;
|
||||
if (
|
||||
!Number.isSafeInteger(candidate) ||
|
||||
candidate < minimum ||
|
||||
candidate > maximum
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function reviewedIssuer(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 512 ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw configurationFailure('issuer is invalid');
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw configurationFailure('issuer is invalid');
|
||||
}
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
parsed.username !== '' ||
|
||||
parsed.password !== '' ||
|
||||
parsed.search !== '' ||
|
||||
parsed.hash !== '' ||
|
||||
parsed.toString() !== value
|
||||
) {
|
||||
throw configurationFailure('issuer must be one canonical HTTPS URL');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviewedTokenValue(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximumLength = 128,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximumLength ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviewedJwkComponent(
|
||||
value: unknown,
|
||||
minimumBytes: number,
|
||||
maximumBytes: number,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
!BASE64URL_PATTERN.test(value)
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
bytes.length < minimumBytes ||
|
||||
bytes.length > maximumBytes ||
|
||||
bytes.toString('base64url') !== value
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewedJwk(
|
||||
value: unknown,
|
||||
seenKids: Set<string>,
|
||||
): Readonly<ReviewedAssertionKey> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure('key must be an object');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const algorithm = candidate.alg;
|
||||
if (
|
||||
typeof algorithm !== 'string' ||
|
||||
!ASSERTION_ALGORITHMS.includes(algorithm as AssertionAlgorithm)
|
||||
) {
|
||||
throw configurationFailure('key algorithm is invalid');
|
||||
}
|
||||
const reviewedAlgorithm = algorithm as AssertionAlgorithm;
|
||||
const expectedKeys =
|
||||
reviewedAlgorithm === 'RS256'
|
||||
? ['alg', 'e', 'kid', 'kty', 'n', 'use']
|
||||
: reviewedAlgorithm === 'ES256'
|
||||
? ['alg', 'crv', 'kid', 'kty', 'use', 'x', 'y']
|
||||
: ['alg', 'crv', 'kid', 'kty', 'use', 'x'];
|
||||
exactObject(candidate, expectedKeys, 'key');
|
||||
const kid = candidate.kid;
|
||||
if (
|
||||
typeof kid !== 'string' ||
|
||||
!TOKEN_VALUE_PATTERN.test(kid) ||
|
||||
seenKids.has(kid)
|
||||
) {
|
||||
throw configurationFailure('key id is invalid or duplicated');
|
||||
}
|
||||
if (candidate.use !== 'sig') {
|
||||
throw configurationFailure('key use must be sig');
|
||||
}
|
||||
if (
|
||||
(reviewedAlgorithm === 'RS256' && candidate.kty !== 'RSA') ||
|
||||
(reviewedAlgorithm === 'ES256' &&
|
||||
(candidate.kty !== 'EC' || candidate.crv !== 'P-256')) ||
|
||||
(reviewedAlgorithm === 'EdDSA' &&
|
||||
(candidate.kty !== 'OKP' || candidate.crv !== 'Ed25519'))
|
||||
) {
|
||||
throw configurationFailure('key type does not match its algorithm');
|
||||
}
|
||||
for (const name of expectedKeys) {
|
||||
if (
|
||||
['alg', 'kid', 'kty', 'use', 'crv'].includes(name) ||
|
||||
(typeof candidate[name] === 'string' &&
|
||||
BASE64URL_PATTERN.test(candidate[name] as string))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw configurationFailure(`key ${name} is invalid`);
|
||||
}
|
||||
if (reviewedAlgorithm === 'RS256') {
|
||||
reviewedJwkComponent(candidate.n, 256, 512, 'RSA modulus');
|
||||
reviewedJwkComponent(candidate.e, 3, 4, 'RSA exponent');
|
||||
} else if (reviewedAlgorithm === 'ES256') {
|
||||
reviewedJwkComponent(candidate.x, 32, 32, 'EC x coordinate');
|
||||
reviewedJwkComponent(candidate.y, 32, 32, 'EC y coordinate');
|
||||
} else {
|
||||
reviewedJwkComponent(candidate.x, 32, 32, 'Ed25519 public key');
|
||||
}
|
||||
let key: KeyObject;
|
||||
try {
|
||||
key = createPublicKey({
|
||||
key: candidate,
|
||||
format: 'jwk',
|
||||
});
|
||||
} catch {
|
||||
throw configurationFailure('key material is invalid');
|
||||
}
|
||||
if (
|
||||
reviewedAlgorithm === 'RS256' &&
|
||||
(key.asymmetricKeyType !== 'rsa' ||
|
||||
(key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048 ||
|
||||
(key.asymmetricKeyDetails?.modulusLength ?? 0) > 4096 ||
|
||||
key.asymmetricKeyDetails?.publicExponent !== 65_537n)
|
||||
) {
|
||||
throw configurationFailure('RSA key strength is invalid');
|
||||
}
|
||||
if (
|
||||
reviewedAlgorithm === 'ES256' &&
|
||||
(key.asymmetricKeyType !== 'ec' ||
|
||||
key.asymmetricKeyDetails?.namedCurve !== 'prime256v1')
|
||||
) {
|
||||
throw configurationFailure('EC key strength is invalid');
|
||||
}
|
||||
if (reviewedAlgorithm === 'EdDSA' && key.asymmetricKeyType !== 'ed25519') {
|
||||
throw configurationFailure('Ed25519 key is invalid');
|
||||
}
|
||||
seenKids.add(kid);
|
||||
return Object.freeze({ kid, algorithm: reviewedAlgorithm, key });
|
||||
}
|
||||
|
||||
function reviewedKeys(
|
||||
value: unknown,
|
||||
): ReadonlyMap<string, Readonly<ReviewedAssertionKey>> {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > MAX_KEYS) {
|
||||
throw configurationFailure('keys must contain between one and eight keys');
|
||||
}
|
||||
const seenKids = new Set<string>();
|
||||
const keys = new Map<string, Readonly<ReviewedAssertionKey>>();
|
||||
for (const candidate of value) {
|
||||
const reviewed = reviewedJwk(candidate, seenKids);
|
||||
keys.set(reviewed.kid, reviewed);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function reviewedAssuranceMappings(
|
||||
value: unknown,
|
||||
): ReadonlyMap<string, Readonly<ReviewedAssuranceMapping>> {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > MAX_ASSURANCE_MAPPINGS
|
||||
) {
|
||||
throw configurationFailure(
|
||||
'assurance mappings must contain between one and eight entries',
|
||||
);
|
||||
}
|
||||
const mappings = new Map<string, Readonly<ReviewedAssuranceMapping>>();
|
||||
for (const candidate of value) {
|
||||
exactObject(candidate, ['acr', 'assurance', 'requiredAmr'], 'mapping');
|
||||
const acr = reviewedTokenValue(candidate.acr, 'mapping acr', 256);
|
||||
if (
|
||||
mappings.has(acr) ||
|
||||
(candidate.assurance !== 'multi_factor' &&
|
||||
candidate.assurance !== 'hardware') ||
|
||||
!Array.isArray(candidate.requiredAmr) ||
|
||||
candidate.requiredAmr.length < 1 ||
|
||||
candidate.requiredAmr.length > MAX_AMR_VALUES
|
||||
) {
|
||||
throw configurationFailure('assurance mapping is invalid');
|
||||
}
|
||||
const requiredAmr = new Set<string>();
|
||||
for (const entry of candidate.requiredAmr) {
|
||||
if (
|
||||
typeof entry !== 'string' ||
|
||||
!TOKEN_VALUE_PATTERN.test(entry) ||
|
||||
requiredAmr.has(entry)
|
||||
) {
|
||||
throw configurationFailure('mapping AMR is invalid or duplicated');
|
||||
}
|
||||
requiredAmr.add(entry);
|
||||
}
|
||||
mappings.set(
|
||||
acr,
|
||||
Object.freeze({
|
||||
assurance: candidate.assurance,
|
||||
requiredAmr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
function canonicalBase64Url(segment: string, maximumBytes: number): Buffer {
|
||||
if (
|
||||
segment.length < 1 ||
|
||||
segment.length > Math.ceil((maximumBytes * 4) / 3) ||
|
||||
!BASE64URL_PATTERN.test(segment)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const decoded = Buffer.from(segment, 'base64url');
|
||||
if (
|
||||
decoded.length < 1 ||
|
||||
decoded.length > maximumBytes ||
|
||||
decoded.toString('base64url') !== segment
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function jsonObject(
|
||||
segment: string,
|
||||
maximumBytes: number,
|
||||
): Record<string, unknown> {
|
||||
const bytes = canonicalBase64Url(segment, maximumBytes);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertionExactObject(
|
||||
value: Record<string, unknown>,
|
||||
expectedKeys: readonly string[],
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
}
|
||||
|
||||
function numericDate(value: unknown): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
!Number.isSafeInteger((value as number) * 1_000)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return (value as number) * 1_000;
|
||||
}
|
||||
|
||||
function assertionTokenValue(value: unknown, maximumLength: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximumLength ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function verifyReviewedSignature(
|
||||
algorithm: AssertionAlgorithm,
|
||||
key: KeyObject,
|
||||
signed: Buffer,
|
||||
signature: Buffer,
|
||||
): boolean {
|
||||
switch (algorithm) {
|
||||
case 'EdDSA':
|
||||
return (
|
||||
signature.length === 64 && verifySignature(null, signed, key, signature)
|
||||
);
|
||||
case 'ES256':
|
||||
return (
|
||||
signature.length === 64 &&
|
||||
verifySignature(
|
||||
'sha256',
|
||||
signed,
|
||||
{ key, dsaEncoding: 'ieee-p1363' },
|
||||
signature,
|
||||
)
|
||||
);
|
||||
case 'RS256':
|
||||
return verifySignature(
|
||||
'RSA-SHA256',
|
||||
signed,
|
||||
{ key, padding: constants.RSA_PKCS1_PADDING },
|
||||
signature,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function authenticationId(issuer: string, jti: string): string {
|
||||
return `ql3oidc.${createHash('sha256')
|
||||
.update(issuer)
|
||||
.update('\0')
|
||||
.update(jti)
|
||||
.digest('base64url')}`;
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageIdentityAssertionVerifier(
|
||||
options: ClusterPluginPackageIdentityAssertionVerifierOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionVerifier> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'issuer',
|
||||
'audience',
|
||||
'keys',
|
||||
'assuranceMappings',
|
||||
'assertionProfile',
|
||||
'maxAssertionBytes',
|
||||
'maxLifetimeMs',
|
||||
'maxAuthenticationAgeMs',
|
||||
'clockSkewMs',
|
||||
'now',
|
||||
].includes(key),
|
||||
)
|
||||
) {
|
||||
throw configurationFailure('options shape is invalid');
|
||||
}
|
||||
const issuer = reviewedIssuer(options.issuer);
|
||||
const audience = reviewedTokenValue(options.audience, 'audience', 256);
|
||||
const assertionProfile = reviewedAssertionProfile(
|
||||
options.assertionProfile ??
|
||||
CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
);
|
||||
const keys = reviewedKeys(options.keys);
|
||||
const assuranceMappings = reviewedAssuranceMappings(
|
||||
options.assuranceMappings,
|
||||
);
|
||||
const maxAssertionBytes = boundedInteger(
|
||||
options.maxAssertionBytes,
|
||||
DEFAULT_ASSERTION_BYTES,
|
||||
MIN_ASSERTION_BYTES,
|
||||
MAX_ASSERTION_BYTES,
|
||||
'max assertion bytes',
|
||||
);
|
||||
const maxLifetimeMs = boundedInteger(
|
||||
options.maxLifetimeMs,
|
||||
DEFAULT_LIFETIME_MS,
|
||||
MIN_LIFETIME_MS,
|
||||
MAX_LIFETIME_MS,
|
||||
'max lifetime',
|
||||
);
|
||||
const maxAuthenticationAgeMs = boundedInteger(
|
||||
options.maxAuthenticationAgeMs,
|
||||
DEFAULT_AUTHENTICATION_AGE_MS,
|
||||
MIN_LIFETIME_MS,
|
||||
MAX_AUTHENTICATION_AGE_MS,
|
||||
'max authentication age',
|
||||
);
|
||||
const clockSkewMs = boundedInteger(
|
||||
options.clockSkewMs,
|
||||
DEFAULT_CLOCK_SKEW_MS,
|
||||
0,
|
||||
MAX_CLOCK_SKEW_MS,
|
||||
'clock skew',
|
||||
);
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw configurationFailure('clock is invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const verify = (assertion: unknown): Readonly<SecurityPrincipal> => {
|
||||
try {
|
||||
if (
|
||||
typeof assertion !== 'string' ||
|
||||
Buffer.byteLength(assertion, 'utf8') > maxAssertionBytes ||
|
||||
CONTROL_PATTERN.test(assertion)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const segments = assertion.split('.');
|
||||
if (segments.length !== 3) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const [protectedSegment, payloadSegment, signatureSegment] = segments;
|
||||
if (
|
||||
protectedSegment === undefined ||
|
||||
payloadSegment === undefined ||
|
||||
signatureSegment === undefined
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const header = jsonObject(protectedSegment, 1_024);
|
||||
assertionExactObject(header, ['alg', 'kid', 'typ']);
|
||||
if (
|
||||
header.typ !== assertionProfile.type ||
|
||||
typeof header.kid !== 'string' ||
|
||||
typeof header.alg !== 'string'
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const reviewedKey = keys.get(header.kid);
|
||||
if (!reviewedKey || reviewedKey.algorithm !== header.alg) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const signature = canonicalBase64Url(signatureSegment, 512);
|
||||
const signed = Buffer.from(
|
||||
`${protectedSegment}.${payloadSegment}`,
|
||||
'ascii',
|
||||
);
|
||||
if (
|
||||
!verifyReviewedSignature(
|
||||
reviewedKey.algorithm,
|
||||
reviewedKey.key,
|
||||
signed,
|
||||
signature,
|
||||
)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
|
||||
const claims = jsonObject(payloadSegment, 8 * 1_024);
|
||||
const claimKeys = [
|
||||
'acr',
|
||||
'amr',
|
||||
'aud',
|
||||
'auth_time',
|
||||
'exp',
|
||||
'iat',
|
||||
'iss',
|
||||
'jti',
|
||||
'ql3_purpose',
|
||||
'sub',
|
||||
];
|
||||
if (Object.hasOwn(claims, 'nbf')) claimKeys.push('nbf');
|
||||
assertionExactObject(claims, claimKeys);
|
||||
if (
|
||||
claims.iss !== issuer ||
|
||||
claims.aud !== audience ||
|
||||
claims.ql3_purpose !== assertionProfile.purpose
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const subjectId = assertionTokenValue(claims.sub, 255);
|
||||
const jti = assertionTokenValue(claims.jti, 255);
|
||||
const issuedAtMs = numericDate(claims.iat);
|
||||
const authenticatedAtMs = numericDate(claims.auth_time);
|
||||
const expiresAtMs = numericDate(claims.exp);
|
||||
const notBeforeAtMs = Object.hasOwn(claims, 'nbf')
|
||||
? numericDate(claims.nbf)
|
||||
: issuedAtMs;
|
||||
const observedAtMs = now();
|
||||
if (
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < 0 ||
|
||||
authenticatedAtMs > issuedAtMs ||
|
||||
issuedAtMs > observedAtMs + clockSkewMs ||
|
||||
authenticatedAtMs > observedAtMs ||
|
||||
notBeforeAtMs < issuedAtMs ||
|
||||
notBeforeAtMs >= expiresAtMs ||
|
||||
observedAtMs + clockSkewMs < notBeforeAtMs ||
|
||||
expiresAtMs <= observedAtMs ||
|
||||
expiresAtMs - issuedAtMs > maxLifetimeMs ||
|
||||
observedAtMs - authenticatedAtMs > maxAuthenticationAgeMs
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const acr = assertionTokenValue(claims.acr, 256);
|
||||
const mapping = assuranceMappings.get(acr);
|
||||
if (
|
||||
!mapping ||
|
||||
!Array.isArray(claims.amr) ||
|
||||
claims.amr.length < 1 ||
|
||||
claims.amr.length > MAX_AMR_VALUES
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const amr = new Set<string>();
|
||||
for (const entry of claims.amr) {
|
||||
if (
|
||||
typeof entry !== 'string' ||
|
||||
!TOKEN_VALUE_PATTERN.test(entry) ||
|
||||
amr.has(entry)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
amr.add(entry);
|
||||
}
|
||||
if ([...mapping.requiredAmr].some((entry) => !amr.has(entry))) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return normalizeSecurityPrincipal(
|
||||
{
|
||||
subject: { type: 'user', id: subjectId },
|
||||
authenticationId: authenticationId(issuer, jti),
|
||||
authenticatedAtMs,
|
||||
expiresAtMs,
|
||||
assurance: mapping.assurance,
|
||||
},
|
||||
observedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageIdentityAssertionAuthenticationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
verify,
|
||||
bind(
|
||||
assertion: unknown,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionAuthentication> {
|
||||
return Object.freeze({
|
||||
async authenticate(): Promise<Readonly<SecurityPrincipal>> {
|
||||
return verify(assertion);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
/** Shared bounded identity keyset and rotation boundary for cluster management planes. */
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import {
|
||||
CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_AUTOMATION_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
createClusterPluginPackageIdentityAssertionVerifier,
|
||||
type ClusterManagementIdentityAssertionProfile,
|
||||
type ClusterPluginPackageIdentityAssertionAuthentication,
|
||||
type ClusterPluginPackageIdentityAssertionVerifier,
|
||||
} from './pluginPackageIdentityAssertion';
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 64 * 1024;
|
||||
const MIN_MAX_FILE_BYTES = 4 * 1024;
|
||||
const HARD_MAX_FILE_BYTES = 256 * 1024;
|
||||
const MAX_REVOKED_KEYS = 64;
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetFileOptions {
|
||||
readonly filePath: string;
|
||||
readonly maxFileBytes?: number;
|
||||
readonly now?: () => number;
|
||||
readonly ledger?: ClusterPluginPackageIdentityKeysetLedger;
|
||||
readonly assertionProfile?: Readonly<ClusterManagementIdentityAssertionProfile>;
|
||||
}
|
||||
|
||||
export type ClusterWorkerCredentialIdentityKeysetFileOptions = Omit<
|
||||
ClusterPluginPackageIdentityKeysetFileOptions,
|
||||
'assertionProfile'
|
||||
>;
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetSnapshot {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generation: number;
|
||||
readonly digest: string;
|
||||
readonly issuer: string;
|
||||
readonly audience: string;
|
||||
readonly activeKeyIds: readonly string[];
|
||||
readonly revokedKeyIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetFile {
|
||||
reload(): Promise<Readonly<ClusterPluginPackageIdentityKeysetSnapshot>>;
|
||||
bind(
|
||||
assertion: unknown,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionAuthentication>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetLedger {
|
||||
observe(
|
||||
snapshot: Readonly<ClusterPluginPackageIdentityKeysetSnapshot>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityKeysetConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster Plugin Package identity keyset configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageIdentityKeysetConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityKeysetUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE';
|
||||
|
||||
constructor(readonly cause?: unknown) {
|
||||
super('Cluster Plugin Package identity keyset is unavailable');
|
||||
this.name = 'ClusterPluginPackageIdentityKeysetUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
interface LoadedKeyset {
|
||||
readonly generation: number;
|
||||
readonly digest: string;
|
||||
readonly verifier: Readonly<ClusterPluginPackageIdentityAssertionVerifier>;
|
||||
readonly activeKeyIds: ReadonlySet<string>;
|
||||
readonly revokedKeyIds: ReadonlySet<string>;
|
||||
readonly snapshot: Readonly<ClusterPluginPackageIdentityKeysetSnapshot>;
|
||||
}
|
||||
|
||||
function configurationFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageIdentityKeysetConfigurationError {
|
||||
return new ClusterPluginPackageIdentityKeysetConfigurationError(message);
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure(`${label} must be an object`);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw configurationFailure(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function sameFileState(
|
||||
left: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
}>,
|
||||
right: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
}>,
|
||||
): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
async function readBoundedRegularFile(
|
||||
filePath: string,
|
||||
maxFileBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const handle = await open(filePath, constants.O_RDONLY);
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1 ||
|
||||
before.size > maxFileBytes ||
|
||||
(before.mode & 0o022) !== 0
|
||||
) {
|
||||
throw configurationFailure(
|
||||
'keyset file must be a bounded non-writable regular file',
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(maxFileBytes + 1);
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
offset,
|
||||
buffer.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
}
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
offset !== before.size ||
|
||||
offset > maxFileBytes ||
|
||||
!sameFileState(before, after)
|
||||
) {
|
||||
throw configurationFailure('keyset file changed while being read');
|
||||
}
|
||||
return buffer.subarray(0, offset);
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): Record<string, unknown> {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw configurationFailure('keyset file must be strict UTF-8');
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw configurationFailure('keyset file must contain JSON');
|
||||
}
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'schemaVersion',
|
||||
'generation',
|
||||
'issuer',
|
||||
'audience',
|
||||
'keys',
|
||||
'revokedKids',
|
||||
'assuranceMappings',
|
||||
'constraints',
|
||||
],
|
||||
'keyset',
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviewedRevokedKeyIds(value: unknown): ReadonlySet<string> {
|
||||
if (!Array.isArray(value) || value.length > MAX_REVOKED_KEYS) {
|
||||
throw configurationFailure('revoked key ids are invalid');
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const candidate of value) {
|
||||
if (
|
||||
typeof candidate !== 'string' ||
|
||||
!KEY_ID_PATTERN.test(candidate) ||
|
||||
ids.has(candidate)
|
||||
) {
|
||||
throw configurationFailure('revoked key id is invalid or duplicated');
|
||||
}
|
||||
ids.add(candidate);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function activeKeys(
|
||||
value: unknown,
|
||||
revokedKeyIds: ReadonlySet<string>,
|
||||
): {
|
||||
readonly all: readonly Readonly<Record<string, unknown>>[];
|
||||
readonly active: readonly Readonly<Record<string, unknown>>[];
|
||||
readonly activeKeyIds: ReadonlySet<string>;
|
||||
} {
|
||||
if (!Array.isArray(value)) {
|
||||
throw configurationFailure('keys must be an array');
|
||||
}
|
||||
const all = value as readonly Readonly<Record<string, unknown>>[];
|
||||
const active: Readonly<Record<string, unknown>>[] = [];
|
||||
const activeKeyIds = new Set<string>();
|
||||
for (const candidate of all) {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate)
|
||||
) {
|
||||
throw configurationFailure('key must be an object');
|
||||
}
|
||||
const kid = candidate.kid;
|
||||
if (typeof kid !== 'string') {
|
||||
throw configurationFailure('key id is invalid');
|
||||
}
|
||||
if (!revokedKeyIds.has(kid)) {
|
||||
active.push(candidate);
|
||||
activeKeyIds.add(kid);
|
||||
}
|
||||
}
|
||||
if (active.length < 1) {
|
||||
throw configurationFailure('at least one key must remain active');
|
||||
}
|
||||
return Object.freeze({ all, active, activeKeyIds });
|
||||
}
|
||||
|
||||
function loadDocument(
|
||||
bytes: Buffer,
|
||||
now: (() => number) | undefined,
|
||||
digest: string,
|
||||
assertionProfile:
|
||||
| Readonly<ClusterManagementIdentityAssertionProfile>
|
||||
| undefined,
|
||||
): LoadedKeyset {
|
||||
const document = parseJson(bytes);
|
||||
if (document.schemaVersion !== 1) {
|
||||
throw configurationFailure('schemaVersion is invalid');
|
||||
}
|
||||
const generation = boundedInteger(
|
||||
document.generation,
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'generation',
|
||||
);
|
||||
const revokedKeyIds = reviewedRevokedKeyIds(document.revokedKids);
|
||||
const keySelection = activeKeys(document.keys, revokedKeyIds);
|
||||
exactObject(
|
||||
document.constraints,
|
||||
[
|
||||
'maxAssertionBytes',
|
||||
'maxLifetimeMs',
|
||||
'maxAuthenticationAgeMs',
|
||||
'clockSkewMs',
|
||||
],
|
||||
'constraints',
|
||||
);
|
||||
const verifierOptions = {
|
||||
issuer: document.issuer as string,
|
||||
audience: document.audience as string,
|
||||
assuranceMappings: document.assuranceMappings as never,
|
||||
maxAssertionBytes: document.constraints.maxAssertionBytes as number,
|
||||
maxLifetimeMs: document.constraints.maxLifetimeMs as number,
|
||||
maxAuthenticationAgeMs: document.constraints
|
||||
.maxAuthenticationAgeMs as number,
|
||||
clockSkewMs: document.constraints.clockSkewMs as number,
|
||||
...(assertionProfile === undefined ? {} : { assertionProfile }),
|
||||
...(now === undefined ? {} : { now }),
|
||||
};
|
||||
// Validate revoked definitions too; revocation must not become a channel for
|
||||
// retaining malformed or private key material in the trust document.
|
||||
createClusterPluginPackageIdentityAssertionVerifier({
|
||||
...verifierOptions,
|
||||
keys: keySelection.all,
|
||||
});
|
||||
const verifier = createClusterPluginPackageIdentityAssertionVerifier({
|
||||
...verifierOptions,
|
||||
keys: keySelection.active,
|
||||
});
|
||||
const issuer = document.issuer as string;
|
||||
const audience = document.audience as string;
|
||||
const snapshot = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
generation,
|
||||
digest,
|
||||
issuer,
|
||||
audience,
|
||||
activeKeyIds: Object.freeze([...keySelection.activeKeyIds].sort()),
|
||||
revokedKeyIds: Object.freeze([...revokedKeyIds].sort()),
|
||||
});
|
||||
return Object.freeze({
|
||||
generation,
|
||||
digest,
|
||||
verifier,
|
||||
activeKeyIds: keySelection.activeKeyIds,
|
||||
revokedKeyIds,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function assertForwardRotation(
|
||||
current: LoadedKeyset,
|
||||
candidate: LoadedKeyset,
|
||||
): void {
|
||||
if (candidate.generation < current.generation) {
|
||||
throw configurationFailure('keyset generation rollback is forbidden');
|
||||
}
|
||||
if (
|
||||
candidate.generation === current.generation &&
|
||||
candidate.digest !== current.digest
|
||||
) {
|
||||
throw configurationFailure('keyset generation rewrite is forbidden');
|
||||
}
|
||||
if (candidate.generation === current.generation) return;
|
||||
for (const kid of current.revokedKeyIds) {
|
||||
if (!candidate.revokedKeyIds.has(kid)) {
|
||||
throw configurationFailure('revoked key ids are append-only');
|
||||
}
|
||||
}
|
||||
for (const kid of current.activeKeyIds) {
|
||||
if (!candidate.activeKeyIds.has(kid) && !candidate.revokedKeyIds.has(kid)) {
|
||||
throw configurationFailure(
|
||||
'removed active keys must be explicitly revoked',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageIdentityKeysetFile(
|
||||
options: ClusterPluginPackageIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'filePath' &&
|
||||
key !== 'maxFileBytes' &&
|
||||
key !== 'now' &&
|
||||
key !== 'ledger' &&
|
||||
key !== 'assertionProfile',
|
||||
) ||
|
||||
typeof options.filePath !== 'string' ||
|
||||
options.filePath.length < 1 ||
|
||||
options.filePath.length > 4_096 ||
|
||||
CONTROL_PATTERN.test(options.filePath) ||
|
||||
!isAbsolute(options.filePath) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.ledger !== undefined &&
|
||||
(!options.ledger ||
|
||||
typeof options.ledger !== 'object' ||
|
||||
typeof options.ledger.observe !== 'function'))
|
||||
) {
|
||||
throw configurationFailure('options are invalid');
|
||||
}
|
||||
const maxFileBytes =
|
||||
options.maxFileBytes === undefined
|
||||
? DEFAULT_MAX_FILE_BYTES
|
||||
: boundedInteger(
|
||||
options.maxFileBytes,
|
||||
MIN_MAX_FILE_BYTES,
|
||||
HARD_MAX_FILE_BYTES,
|
||||
'maximum file bytes',
|
||||
);
|
||||
let current: LoadedKeyset | undefined;
|
||||
|
||||
const reload = async (): Promise<
|
||||
Readonly<ClusterPluginPackageIdentityKeysetSnapshot>
|
||||
> => {
|
||||
try {
|
||||
const bytes = await readBoundedRegularFile(
|
||||
options.filePath,
|
||||
maxFileBytes,
|
||||
);
|
||||
const digest = createHash('sha256').update(bytes).digest('base64url');
|
||||
if (current?.digest === digest) {
|
||||
await options.ledger?.observe(current.snapshot);
|
||||
return current.snapshot;
|
||||
}
|
||||
const candidate = loadDocument(
|
||||
bytes,
|
||||
options.now,
|
||||
digest,
|
||||
options.assertionProfile,
|
||||
);
|
||||
if (current) {
|
||||
assertForwardRotation(current, candidate);
|
||||
}
|
||||
await options.ledger?.observe(candidate.snapshot);
|
||||
current = candidate;
|
||||
return candidate.snapshot;
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterPluginPackageIdentityKeysetUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageIdentityKeysetUnavailableError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
reload,
|
||||
bind(assertion: unknown) {
|
||||
return Object.freeze({
|
||||
async authenticate() {
|
||||
await reload();
|
||||
if (!current) {
|
||||
throw new ClusterPluginPackageIdentityKeysetUnavailableError();
|
||||
}
|
||||
return current.verifier.verify(assertion);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterWorkerCredentialIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile:
|
||||
CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterAutomationIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile: CLUSTER_AUTOMATION_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterApprovalIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile: CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterModelProviderCredentialIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile:
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,919 @@
|
||||
/** Shared bounded TLS HTTP host boundary for cluster management planes. */
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer, type Server as HttpsServer } from 'node:https';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { TLSSocket } from 'node:tls';
|
||||
|
||||
import {
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementConflictError,
|
||||
PluginPackageManagementQuotaExceededError,
|
||||
PluginPackageManagementRequestError,
|
||||
PluginPackageManagementUnavailableError,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import { ClusterPluginPackageIdentityAssertionAuthenticationError } from './pluginPackageIdentityAssertion';
|
||||
import {
|
||||
ClusterPluginPackageIdentityKeysetUnavailableError,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
} from './pluginPackageIdentityKeyset';
|
||||
import {
|
||||
ClusterPluginPackageManagementTransportAuthenticationError,
|
||||
ClusterPluginPackageManagementTransportRequestError,
|
||||
ClusterPluginPackageManagementTransportUnavailableError,
|
||||
} from '../plugin-package/management/pluginPackageManagementTransport';
|
||||
import {
|
||||
WorkerCredentialManagementAuthorizationError,
|
||||
WorkerCredentialManagementConflictError,
|
||||
WorkerCredentialManagementQuotaExceededError,
|
||||
WorkerCredentialManagementRequestError,
|
||||
WorkerCredentialManagementUnavailableError,
|
||||
} from '../worker-credential/management-server/workerCredentialManagement';
|
||||
import {
|
||||
ClusterWorkerCredentialManagementTransportAuthenticationError,
|
||||
ClusterWorkerCredentialManagementTransportRequestError,
|
||||
ClusterWorkerCredentialManagementTransportUnavailableError,
|
||||
} from '../worker-credential/management-server/workerCredentialManagementTransport';
|
||||
import {
|
||||
ClusterAutomationManagementAuthorizationError,
|
||||
ClusterAutomationManagementConflictError,
|
||||
ClusterAutomationManagementRequestError,
|
||||
ClusterAutomationManagementUnavailableError,
|
||||
} from '../automation-management/automationManagement';
|
||||
import {
|
||||
ClusterAutomationManagementTransportAuthenticationError,
|
||||
ClusterAutomationManagementTransportRequestError,
|
||||
ClusterAutomationManagementTransportUnavailableError,
|
||||
} from '../automation-management/automationManagementTransport';
|
||||
import {
|
||||
ClusterApprovalManagementTransportAuthenticationError,
|
||||
ClusterApprovalManagementTransportAuthorizationError,
|
||||
ClusterApprovalManagementTransportConflictError,
|
||||
ClusterApprovalManagementTransportRequestError,
|
||||
ClusterApprovalManagementTransportTargetUnavailableError,
|
||||
ClusterApprovalManagementTransportUnavailableError,
|
||||
} from '../approval-management/approvalManagementTransport';
|
||||
import {
|
||||
ClusterModelProviderCredentialManagementAuthenticationError,
|
||||
ClusterModelProviderCredentialManagementAuthorizationError,
|
||||
ClusterModelProviderCredentialManagementConflictError,
|
||||
ClusterModelProviderCredentialManagementQuotaExceededError,
|
||||
ClusterModelProviderCredentialManagementRequestError,
|
||||
ClusterModelProviderCredentialManagementUnavailableError,
|
||||
} from '../model-provider-credential/modelProviderCredentialManagement';
|
||||
import {
|
||||
ClusterModelProviderCredentialManagementTransportAuthenticationError,
|
||||
ClusterModelProviderCredentialManagementTransportRequestError,
|
||||
ClusterModelProviderCredentialManagementTransportUnavailableError,
|
||||
} from '../model-provider-credential/modelProviderCredentialManagementTransport';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH =
|
||||
'/api/v3/plugin-packages/management';
|
||||
export const CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH =
|
||||
'/api/v3/worker-credentials/management';
|
||||
export const CLUSTER_AUTOMATION_MANAGEMENT_PATH =
|
||||
'/api/v3/automations/management';
|
||||
export const CLUSTER_APPROVAL_MANAGEMENT_PATH = '/api/v3/approvals/management';
|
||||
export const CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH =
|
||||
'/api/v3/provider-credentials/management';
|
||||
export type ClusterAuthenticatedManagementPath =
|
||||
| typeof CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_AUTOMATION_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_APPROVAL_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH;
|
||||
const MANAGEMENT_PATHS = new Set<ClusterAuthenticatedManagementPath>([
|
||||
CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH,
|
||||
CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
CLUSTER_AUTOMATION_MANAGEMENT_PATH,
|
||||
CLUSTER_APPROVAL_MANAGEMENT_PATH,
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
]);
|
||||
const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
||||
const DEFAULT_MAX_CONNECTIONS = 64;
|
||||
const DEFAULT_MAX_CONCURRENT_REQUESTS = 32;
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_RATE_WINDOW_MS = 60_000;
|
||||
const DEFAULT_PEER_REQUEST_LIMIT = 60;
|
||||
const DEFAULT_GLOBAL_REQUEST_LIMIT = 600;
|
||||
const DEFAULT_MAX_RATE_LIMIT_PEERS = 1_024;
|
||||
const MAX_AUTHORIZATION_BYTES = 16 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 128 * 1024;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export interface ClusterPluginPackageManagementHttpLimits {
|
||||
readonly maxBodyBytes?: number;
|
||||
readonly maxConnections?: number;
|
||||
readonly maxConcurrentRequests?: number;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly drainTimeoutMs?: number;
|
||||
readonly rateWindowMs?: number;
|
||||
readonly peerRequestLimit?: number;
|
||||
readonly globalRequestLimit?: number;
|
||||
readonly maxRateLimitPeers?: number;
|
||||
}
|
||||
|
||||
export interface StartClusterPluginPackageManagementHttpOptions {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly tls: Readonly<{
|
||||
readonly privateKey: Buffer;
|
||||
readonly certificate: Buffer;
|
||||
readonly clientCertificateAuthority?: Buffer;
|
||||
readonly clientCertificateRevocationList?: Buffer;
|
||||
}>;
|
||||
readonly transport: ClusterAuthenticatedManagementTransport;
|
||||
readonly identities: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly managementPath?: ClusterAuthenticatedManagementPath;
|
||||
readonly limits?: ClusterPluginPackageManagementHttpLimits;
|
||||
readonly now?: () => number;
|
||||
readonly createRequestId?: () => string;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface ClusterAuthenticatedManagementTransport {
|
||||
execute(
|
||||
command: unknown,
|
||||
authentication: Readonly<{
|
||||
authenticate(): Promise<unknown>;
|
||||
}>,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementHttpApplication {
|
||||
readonly status: 'active';
|
||||
readonly address: Readonly<{ host: string; port: number }>;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
withdraw(error?: unknown): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementHttpConfigurationError extends TypeError {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_HTTP_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster Plugin Package management HTTP configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageManagementHttpConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
class HttpRequestError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly responseCode: string,
|
||||
readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(responseCode);
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewedLimits {
|
||||
readonly maxBodyBytes: number;
|
||||
readonly maxConnections: number;
|
||||
readonly maxConcurrentRequests: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly drainTimeoutMs: number;
|
||||
readonly rateWindowMs: number;
|
||||
readonly peerRequestLimit: number;
|
||||
readonly globalRequestLimit: number;
|
||||
readonly maxRateLimitPeers: number;
|
||||
}
|
||||
|
||||
interface RateBucket {
|
||||
windowStartedAtMs: number;
|
||||
count: number;
|
||||
lastSeenAtMs: number;
|
||||
}
|
||||
|
||||
class BoundedRateLimiter {
|
||||
readonly #peers = new Map<string, RateBucket>();
|
||||
#global: RateBucket;
|
||||
|
||||
constructor(
|
||||
private readonly limits: ReviewedLimits,
|
||||
private readonly now: () => number,
|
||||
) {
|
||||
const nowMs = this.currentTime();
|
||||
this.#global = {
|
||||
windowStartedAtMs: nowMs,
|
||||
count: 0,
|
||||
lastSeenAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
private currentTime(): number {
|
||||
const value = this.now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error('HTTP rate-limit clock is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private retryAfter(
|
||||
bucket: RateBucket,
|
||||
limit: number,
|
||||
nowMs: number,
|
||||
): number | null {
|
||||
if (nowMs >= bucket.windowStartedAtMs + this.limits.rateWindowMs) {
|
||||
bucket.windowStartedAtMs = nowMs;
|
||||
bucket.count = 0;
|
||||
}
|
||||
bucket.lastSeenAtMs = nowMs;
|
||||
if (bucket.count >= limit) {
|
||||
return Math.max(
|
||||
1,
|
||||
bucket.windowStartedAtMs + this.limits.rateWindowMs - nowMs,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private evictOldestPeer(): void {
|
||||
let oldestKey: string | undefined;
|
||||
let oldestAtMs = Number.POSITIVE_INFINITY;
|
||||
for (const [key, bucket] of this.#peers) {
|
||||
if (bucket.lastSeenAtMs < oldestAtMs) {
|
||||
oldestAtMs = bucket.lastSeenAtMs;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey !== undefined) this.#peers.delete(oldestKey);
|
||||
}
|
||||
|
||||
consume(peerValue: string | undefined): number | null {
|
||||
const nowMs = this.currentTime();
|
||||
const globalRetry = this.retryAfter(
|
||||
this.#global,
|
||||
this.limits.globalRequestLimit,
|
||||
nowMs,
|
||||
);
|
||||
if (globalRetry !== null) return globalRetry;
|
||||
const peer =
|
||||
typeof peerValue === 'string' &&
|
||||
peerValue.length >= 1 &&
|
||||
peerValue.length <= 128 &&
|
||||
!CONTROL_PATTERN.test(peerValue)
|
||||
? peerValue
|
||||
: '<unknown>';
|
||||
let bucket = this.#peers.get(peer);
|
||||
if (!bucket) {
|
||||
if (this.#peers.size >= this.limits.maxRateLimitPeers) {
|
||||
this.evictOldestPeer();
|
||||
}
|
||||
bucket = {
|
||||
windowStartedAtMs: nowMs,
|
||||
count: 0,
|
||||
lastSeenAtMs: nowMs,
|
||||
};
|
||||
this.#peers.set(peer, bucket);
|
||||
}
|
||||
const peerRetry = this.retryAfter(
|
||||
bucket,
|
||||
this.limits.peerRequestLimit,
|
||||
nowMs,
|
||||
);
|
||||
if (peerRetry !== null) return peerRetry;
|
||||
this.#global.count += 1;
|
||||
bucket.count += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function configurationFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageManagementHttpConfigurationError {
|
||||
return new ClusterPluginPackageManagementHttpConfigurationError(message);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
const candidate = value ?? fallback;
|
||||
if (
|
||||
!Number.isSafeInteger(candidate) ||
|
||||
candidate < minimum ||
|
||||
candidate > maximum
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function reviewedLimits(
|
||||
value: ClusterPluginPackageManagementHttpLimits | undefined,
|
||||
): ReviewedLimits {
|
||||
if (
|
||||
value !== undefined &&
|
||||
(!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).some(
|
||||
(key) =>
|
||||
![
|
||||
'maxBodyBytes',
|
||||
'maxConnections',
|
||||
'maxConcurrentRequests',
|
||||
'requestTimeoutMs',
|
||||
'drainTimeoutMs',
|
||||
'rateWindowMs',
|
||||
'peerRequestLimit',
|
||||
'globalRequestLimit',
|
||||
'maxRateLimitPeers',
|
||||
].includes(key),
|
||||
))
|
||||
) {
|
||||
throw configurationFailure('limits are invalid');
|
||||
}
|
||||
const limits = value ?? {};
|
||||
const reviewed = {
|
||||
maxBodyBytes: integer(
|
||||
limits.maxBodyBytes,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
1_024,
|
||||
256 * 1024,
|
||||
'maximum body bytes',
|
||||
),
|
||||
maxConnections: integer(
|
||||
limits.maxConnections,
|
||||
DEFAULT_MAX_CONNECTIONS,
|
||||
1,
|
||||
512,
|
||||
'maximum connections',
|
||||
),
|
||||
maxConcurrentRequests: integer(
|
||||
limits.maxConcurrentRequests,
|
||||
DEFAULT_MAX_CONCURRENT_REQUESTS,
|
||||
1,
|
||||
256,
|
||||
'maximum concurrent requests',
|
||||
),
|
||||
requestTimeoutMs: integer(
|
||||
limits.requestTimeoutMs,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
1_000,
|
||||
60_000,
|
||||
'request timeout',
|
||||
),
|
||||
drainTimeoutMs: integer(
|
||||
limits.drainTimeoutMs,
|
||||
DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
100,
|
||||
60_000,
|
||||
'drain timeout',
|
||||
),
|
||||
rateWindowMs: integer(
|
||||
limits.rateWindowMs,
|
||||
DEFAULT_RATE_WINDOW_MS,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
'rate window',
|
||||
),
|
||||
peerRequestLimit: integer(
|
||||
limits.peerRequestLimit,
|
||||
DEFAULT_PEER_REQUEST_LIMIT,
|
||||
1,
|
||||
10_000,
|
||||
'peer request limit',
|
||||
),
|
||||
globalRequestLimit: integer(
|
||||
limits.globalRequestLimit,
|
||||
DEFAULT_GLOBAL_REQUEST_LIMIT,
|
||||
1,
|
||||
100_000,
|
||||
'global request limit',
|
||||
),
|
||||
maxRateLimitPeers: integer(
|
||||
limits.maxRateLimitPeers,
|
||||
DEFAULT_MAX_RATE_LIMIT_PEERS,
|
||||
1,
|
||||
16_384,
|
||||
'maximum rate-limit peers',
|
||||
),
|
||||
};
|
||||
if (reviewed.globalRequestLimit < reviewed.peerRequestLimit) {
|
||||
throw configurationFailure(
|
||||
'global request limit cannot be below the peer limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze(reviewed);
|
||||
}
|
||||
|
||||
function rawHeaderCount(request: IncomingMessage, name: string): number {
|
||||
let count = 0;
|
||||
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
||||
if (request.rawHeaders[index]?.toLowerCase() === name) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function bearerAssertion(request: IncomingMessage): string {
|
||||
if (
|
||||
rawHeaderCount(request, 'authorization') !== 1 ||
|
||||
typeof request.headers.authorization !== 'string'
|
||||
) {
|
||||
throw new HttpRequestError(401, 'authentication_required');
|
||||
}
|
||||
const value = request.headers.authorization;
|
||||
if (
|
||||
!value.startsWith('Bearer ') ||
|
||||
value.length <= 7 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_AUTHORIZATION_BYTES ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw new HttpRequestError(401, 'authentication_required');
|
||||
}
|
||||
return value.slice(7);
|
||||
}
|
||||
|
||||
function assertRequestHeaders(
|
||||
request: IncomingMessage,
|
||||
maxBodyBytes: number,
|
||||
): void {
|
||||
if (
|
||||
rawHeaderCount(request, 'content-type') !== 1 ||
|
||||
request.headers['content-type'] !== 'application/json'
|
||||
) {
|
||||
throw new HttpRequestError(415, 'unsupported_media_type');
|
||||
}
|
||||
if (
|
||||
request.headers['content-encoding'] !== undefined ||
|
||||
request.headers.expect !== undefined
|
||||
) {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
if (rawHeaderCount(request, 'content-length') > 1) {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
const contentLength = request.headers['content-length'];
|
||||
if (contentLength !== undefined) {
|
||||
if (
|
||||
!/^(?:0|[1-9][0-9]*)$/.test(contentLength) ||
|
||||
Number(contentLength) > maxBodyBytes
|
||||
) {
|
||||
throw new HttpRequestError(413, 'request_too_large');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
request: IncomingMessage,
|
||||
maxBodyBytes: number,
|
||||
): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
for await (const value of request) {
|
||||
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
length += chunk.length;
|
||||
if (length > maxBodyBytes) {
|
||||
throw new HttpRequestError(413, 'request_too_large');
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (length < 1) {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
Buffer.concat(chunks, length),
|
||||
);
|
||||
} catch {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(
|
||||
response: ServerResponse,
|
||||
statusCode: number,
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
retryAfterMs?: number,
|
||||
): void {
|
||||
const body = Buffer.from(JSON.stringify(value));
|
||||
if (body.length > MAX_RESPONSE_BYTES) {
|
||||
throw new Error('management response exceeds its hard limit');
|
||||
}
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8');
|
||||
response.setHeader('content-length', String(body.length));
|
||||
response.setHeader('cache-control', 'no-store');
|
||||
response.setHeader('x-content-type-options', 'nosniff');
|
||||
if (retryAfterMs !== undefined) {
|
||||
response.setHeader(
|
||||
'retry-after',
|
||||
String(Math.max(1, Math.ceil(retryAfterMs / 1_000))),
|
||||
);
|
||||
}
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function responseError(error: unknown): HttpRequestError {
|
||||
if (error instanceof HttpRequestError) return error;
|
||||
if (
|
||||
error instanceof ClusterPluginPackageIdentityAssertionAuthenticationError ||
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementTransportAuthenticationError ||
|
||||
error instanceof
|
||||
ClusterWorkerCredentialManagementTransportAuthenticationError ||
|
||||
error instanceof ClusterAutomationManagementTransportAuthenticationError ||
|
||||
error instanceof ClusterApprovalManagementTransportAuthenticationError ||
|
||||
error instanceof
|
||||
ClusterModelProviderCredentialManagementTransportAuthenticationError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementAuthenticationError
|
||||
) {
|
||||
return new HttpRequestError(401, 'authentication_required');
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementTransportRequestError ||
|
||||
error instanceof ClusterWorkerCredentialManagementTransportRequestError ||
|
||||
error instanceof ClusterAutomationManagementTransportRequestError ||
|
||||
error instanceof ClusterApprovalManagementTransportRequestError ||
|
||||
error instanceof ClusterAutomationManagementRequestError ||
|
||||
error instanceof
|
||||
ClusterModelProviderCredentialManagementTransportRequestError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementRequestError ||
|
||||
error instanceof PluginPackageManagementRequestError ||
|
||||
error instanceof WorkerCredentialManagementRequestError
|
||||
) {
|
||||
return new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
if (
|
||||
error instanceof PluginPackageManagementAuthorizationError ||
|
||||
error instanceof WorkerCredentialManagementAuthorizationError ||
|
||||
error instanceof ClusterAutomationManagementAuthorizationError ||
|
||||
error instanceof ClusterApprovalManagementTransportAuthorizationError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementAuthorizationError
|
||||
) {
|
||||
return new HttpRequestError(403, 'forbidden');
|
||||
}
|
||||
if (
|
||||
error instanceof PluginPackageManagementConflictError ||
|
||||
error instanceof WorkerCredentialManagementConflictError ||
|
||||
error instanceof ClusterAutomationManagementConflictError ||
|
||||
error instanceof ClusterApprovalManagementTransportConflictError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementConflictError
|
||||
) {
|
||||
return new HttpRequestError(409, 'conflict');
|
||||
}
|
||||
if (error instanceof PluginPackageManagementQuotaExceededError) {
|
||||
return new HttpRequestError(429, 'quota_exceeded', error.retryAfterMs);
|
||||
}
|
||||
if (error instanceof WorkerCredentialManagementQuotaExceededError) {
|
||||
return new HttpRequestError(429, 'quota_exceeded', error.retryAfterMs);
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterModelProviderCredentialManagementQuotaExceededError
|
||||
) {
|
||||
return new HttpRequestError(429, 'quota_exceeded', error.retryAfterMs);
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterApprovalManagementTransportTargetUnavailableError
|
||||
) {
|
||||
return new HttpRequestError(404, 'not_found');
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterPluginPackageIdentityKeysetUnavailableError ||
|
||||
error instanceof ClusterPluginPackageManagementTransportUnavailableError ||
|
||||
error instanceof
|
||||
ClusterWorkerCredentialManagementTransportUnavailableError ||
|
||||
error instanceof ClusterAutomationManagementTransportUnavailableError ||
|
||||
error instanceof ClusterApprovalManagementTransportUnavailableError ||
|
||||
error instanceof ClusterAutomationManagementUnavailableError ||
|
||||
error instanceof
|
||||
ClusterModelProviderCredentialManagementTransportUnavailableError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementUnavailableError ||
|
||||
error instanceof PluginPackageManagementUnavailableError ||
|
||||
error instanceof WorkerCredentialManagementUnavailableError
|
||||
) {
|
||||
return new HttpRequestError(503, 'unavailable');
|
||||
}
|
||||
return new HttpRequestError(500, 'internal_error');
|
||||
}
|
||||
|
||||
async function listen(
|
||||
server: HttpsServer,
|
||||
port: number,
|
||||
host: string,
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
server.removeListener('listening', onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.removeListener('error', onError);
|
||||
resolve();
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startClusterPluginPackageManagementHttp(
|
||||
options: StartClusterPluginPackageManagementHttpOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageManagementHttpApplication>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'host',
|
||||
'port',
|
||||
'tls',
|
||||
'transport',
|
||||
'identities',
|
||||
'managementPath',
|
||||
'limits',
|
||||
'now',
|
||||
'createRequestId',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof options.host !== 'string' ||
|
||||
options.host.length < 1 ||
|
||||
options.host.length > 255 ||
|
||||
CONTROL_PATTERN.test(options.host) ||
|
||||
!Number.isInteger(options.port) ||
|
||||
options.port < 0 ||
|
||||
options.port > 65_535 ||
|
||||
!options.tls ||
|
||||
typeof options.tls !== 'object' ||
|
||||
Array.isArray(options.tls) ||
|
||||
Object.keys(options.tls).some(
|
||||
(key) =>
|
||||
key !== 'privateKey' &&
|
||||
key !== 'certificate' &&
|
||||
key !== 'clientCertificateAuthority' &&
|
||||
key !== 'clientCertificateRevocationList',
|
||||
) ||
|
||||
!Buffer.isBuffer(options.tls.privateKey) ||
|
||||
options.tls.privateKey.length < 1 ||
|
||||
options.tls.privateKey.length > 256 * 1024 ||
|
||||
!Buffer.isBuffer(options.tls.certificate) ||
|
||||
options.tls.certificate.length < 1 ||
|
||||
options.tls.certificate.length > 256 * 1024 ||
|
||||
(options.tls.clientCertificateAuthority !== undefined &&
|
||||
(!Buffer.isBuffer(options.tls.clientCertificateAuthority) ||
|
||||
options.tls.clientCertificateAuthority.length < 1 ||
|
||||
options.tls.clientCertificateAuthority.length > 256 * 1024)) ||
|
||||
(options.tls.clientCertificateRevocationList !== undefined &&
|
||||
(!Buffer.isBuffer(options.tls.clientCertificateRevocationList) ||
|
||||
options.tls.clientCertificateRevocationList.length < 1 ||
|
||||
options.tls.clientCertificateRevocationList.length > 256 * 1024)) ||
|
||||
(options.tls.clientCertificateAuthority === undefined) !==
|
||||
(options.tls.clientCertificateRevocationList === undefined) ||
|
||||
!options.transport ||
|
||||
typeof options.transport.execute !== 'function' ||
|
||||
!options.identities ||
|
||||
typeof options.identities.bind !== 'function' ||
|
||||
typeof options.identities.reload !== 'function' ||
|
||||
(options.managementPath !== undefined &&
|
||||
!MANAGEMENT_PATHS.has(options.managementPath)) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.createRequestId !== undefined &&
|
||||
typeof options.createRequestId !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw configurationFailure('options are invalid');
|
||||
}
|
||||
const limits = reviewedLimits(options.limits);
|
||||
const managementPath =
|
||||
options.managementPath ?? CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH;
|
||||
const now = options.now ?? Date.now;
|
||||
const createRequestId = options.createRequestId ?? randomUUID;
|
||||
const clientCertificateRequired =
|
||||
options.tls.clientCertificateAuthority !== undefined;
|
||||
const rateLimiter = new BoundedRateLimiter(limits, now);
|
||||
let availability: 'ready' | 'unavailable' | 'stopped' = 'ready';
|
||||
let inFlight = 0;
|
||||
const sockets = new Set<Duplex>();
|
||||
|
||||
let server: HttpsServer;
|
||||
try {
|
||||
server = createServer({
|
||||
key: options.tls.privateKey,
|
||||
cert: options.tls.certificate,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
honorCipherOrder: true,
|
||||
...(clientCertificateRequired
|
||||
? {
|
||||
ca: options.tls.clientCertificateAuthority,
|
||||
crl: options.tls.clientCertificateRevocationList,
|
||||
}
|
||||
: {}),
|
||||
requestCert: clientCertificateRequired,
|
||||
// Health probes intentionally remain reachable without a client
|
||||
// certificate. Every non-health route checks TLSSocket.authorized before
|
||||
// reading Authorization or request body bytes.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
} finally {
|
||||
options.tls.privateKey.fill(0);
|
||||
}
|
||||
server.maxHeadersCount = 32;
|
||||
server.maxConnections = limits.maxConnections;
|
||||
server.requestTimeout = limits.requestTimeoutMs;
|
||||
server.headersTimeout = Math.min(5_000, limits.requestTimeoutMs);
|
||||
server.keepAliveTimeout = 5_000;
|
||||
server.maxRequestsPerSocket = 100;
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never replace the stable HTTP response.
|
||||
}
|
||||
};
|
||||
|
||||
server.on('checkContinue', (request, response) => {
|
||||
response.setHeader('connection', 'close');
|
||||
response.once('finish', () => request.destroy());
|
||||
writeJson(response, 417, {
|
||||
schemaVersion: 1,
|
||||
error: { code: 'request_invalid' },
|
||||
});
|
||||
});
|
||||
server.on('clientError', (_error, socket) => {
|
||||
if (socket.writable) {
|
||||
socket.end(
|
||||
'HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
|
||||
);
|
||||
}
|
||||
});
|
||||
server.on('tlsClientError', () => {
|
||||
// TLS failures are unauthenticated network noise, not diagnostics.
|
||||
});
|
||||
|
||||
server.on('request', (request, response) => {
|
||||
void (async () => {
|
||||
const requestId = createRequestId();
|
||||
if (
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length < 1 ||
|
||||
requestId.length > 128 ||
|
||||
CONTROL_PATTERN.test(requestId)
|
||||
) {
|
||||
throw new Error('HTTP request id is invalid');
|
||||
}
|
||||
response.setHeader('x-request-id', requestId);
|
||||
const url = request.url;
|
||||
if (request.method === 'GET' && url === '/livez') {
|
||||
writeJson(response, 200, {
|
||||
schemaVersion: 1,
|
||||
status: 'live',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && url === '/readyz') {
|
||||
writeJson(response, availability === 'ready' ? 200 : 503, {
|
||||
schemaVersion: 1,
|
||||
status: availability === 'ready' ? 'ready' : 'not_ready',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
clientCertificateRequired &&
|
||||
!(request.socket as TLSSocket).authorized
|
||||
) {
|
||||
throw new HttpRequestError(401, 'client_certificate_required');
|
||||
}
|
||||
if (request.method !== 'POST' || url !== managementPath) {
|
||||
throw new HttpRequestError(404, 'not_found');
|
||||
}
|
||||
if (availability !== 'ready') {
|
||||
throw new HttpRequestError(503, 'unavailable');
|
||||
}
|
||||
const retryAfterMs = rateLimiter.consume(request.socket.remoteAddress);
|
||||
if (retryAfterMs !== null) {
|
||||
writeJson(
|
||||
response,
|
||||
429,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
requestId,
|
||||
error: { code: 'rate_limited' },
|
||||
},
|
||||
retryAfterMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlight >= limits.maxConcurrentRequests) {
|
||||
throw new HttpRequestError(503, 'overloaded');
|
||||
}
|
||||
inFlight += 1;
|
||||
try {
|
||||
const assertion = bearerAssertion(request);
|
||||
const authentication = options.identities.bind(assertion);
|
||||
const principal = await authentication.authenticate();
|
||||
assertRequestHeaders(request, limits.maxBodyBytes);
|
||||
const command = await readJsonBody(request, limits.maxBodyBytes);
|
||||
const result = await options.transport.execute(
|
||||
command,
|
||||
Object.freeze({
|
||||
async authenticate() {
|
||||
return principal;
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeJson(response, 200, {
|
||||
schemaVersion: 1,
|
||||
requestId,
|
||||
result,
|
||||
});
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
}
|
||||
})().catch((error) => {
|
||||
const mapped = responseError(error);
|
||||
if (mapped.statusCode === 500) report(error);
|
||||
if (!request.destroyed) {
|
||||
response.once('finish', () => request.destroy());
|
||||
}
|
||||
if (!response.headersSent && !response.destroyed) {
|
||||
response.setHeader('connection', 'close');
|
||||
writeJson(
|
||||
response,
|
||||
mapped.statusCode,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
requestId:
|
||||
typeof response.getHeader('x-request-id') === 'string'
|
||||
? response.getHeader('x-request-id')
|
||||
: 'unavailable',
|
||||
error: { code: mapped.responseCode },
|
||||
},
|
||||
mapped.retryAfterMs,
|
||||
);
|
||||
} else if (!response.destroyed) {
|
||||
response.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await listen(server, options.port, options.host);
|
||||
} catch (error) {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
throw error;
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
throw new Error('management HTTP server address is unavailable');
|
||||
}
|
||||
const networkAddress = address as AddressInfo;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: Object.freeze({
|
||||
host: networkAddress.address,
|
||||
port: networkAddress.port,
|
||||
}),
|
||||
availabilityStatus: () => availability,
|
||||
withdraw(error?: unknown) {
|
||||
if (availability !== 'ready') return;
|
||||
availability = 'unavailable';
|
||||
if (error !== undefined) report(error);
|
||||
},
|
||||
close(): Promise<void> {
|
||||
if (closePromise) return closePromise;
|
||||
availability = 'stopped';
|
||||
closePromise = new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
finish();
|
||||
}, limits.drainTimeoutMs);
|
||||
server.close(finish);
|
||||
});
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
+608
@@ -0,0 +1,608 @@
|
||||
/** Model-provider credential management application service boundary. */
|
||||
import {
|
||||
InvalidModelProviderCredentialAdministrationMutationError,
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialAdministrationMutationConflictError,
|
||||
modelProviderCredentialAdministrationOperationId,
|
||||
type ModelProviderCredentialAdministrationRepository,
|
||||
} from '@qinglong/ai/model-provider-credential-administration';
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA,
|
||||
InvalidModelProviderCredentialTransitionError,
|
||||
ModelProviderCredentialCatalogUnavailableError,
|
||||
ModelProviderCredentialTransitionConflictError,
|
||||
createModelProviderCredentialTransitionCommand,
|
||||
type CommitModelProviderCredentialTransitionResult,
|
||||
} from '@qinglong/ai/model-provider-credential-catalog';
|
||||
import { MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA } from '@qinglong/ai/provider-credential';
|
||||
import {
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS,
|
||||
InvalidModelProviderCredentialTestConnectionError,
|
||||
createModelProviderCredentialTestPlan,
|
||||
normalizeModelProviderCredentialTestAllowlist,
|
||||
resolveModelProviderCredentialTestEndpoint,
|
||||
type ModelProviderCredentialTestAllowlist,
|
||||
} from '@qinglong/ai/model-provider-credential-test-connection';
|
||||
import {
|
||||
InvalidModelProviderCredentialManagementAuditQueryError,
|
||||
MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_QUERY_OPERATION_ID,
|
||||
ModelProviderCredentialManagementAuditAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialManagementAuditConflictError,
|
||||
ModelProviderCredentialManagementAuditUnavailableError,
|
||||
normalizeModelProviderCredentialManagementAuditQuery,
|
||||
type ModelProviderCredentialManagementAuditCursor,
|
||||
type ModelProviderCredentialManagementAuditPage,
|
||||
type ModelProviderCredentialManagementAuditQueryRepository,
|
||||
} from '@qinglong/ai/postgres-model-provider-credential-management-audit-query';
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_OPERATION_ID,
|
||||
ModelProviderCredentialTestPlanAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialTestPlanConflictError,
|
||||
ModelProviderCredentialTestPlanQuotaExceededError,
|
||||
ModelProviderCredentialTestPlanUnavailableError,
|
||||
type CreateModelProviderCredentialTestPlanResult,
|
||||
type ModelProviderCredentialTestPlanRepository,
|
||||
} from '@qinglong/ai/postgres-model-provider-credential-test-connection';
|
||||
import type { ProjectPermission } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const STRONG_USER_ASSURANCES = new Set(['multi_factor', 'hardware']);
|
||||
const MAX_PRINCIPAL_AGE_MS = 5 * 60 * 1_000;
|
||||
|
||||
interface BaseMutationRequest {
|
||||
readonly requestId: string;
|
||||
readonly mutationId: string;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface BindModelProviderCredentialRequest
|
||||
extends BaseMutationRequest {
|
||||
readonly revision: string;
|
||||
readonly secretRef: string;
|
||||
}
|
||||
|
||||
export interface RevokeModelProviderCredentialRequest
|
||||
extends BaseMutationRequest {}
|
||||
|
||||
export interface ListModelProviderCredentialManagementAuditRequest {
|
||||
readonly requestId: string;
|
||||
readonly queryId: string;
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly before?: ModelProviderCredentialManagementAuditCursor;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface PlanModelProviderCredentialTestRequest {
|
||||
readonly requestId: string;
|
||||
readonly testId: string;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ClusterModelProviderCredentialManagementPolicy {
|
||||
authorize(
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
projectId: string,
|
||||
permission: ProjectPermission,
|
||||
): Promise<Readonly<SecurityPolicyDecision>>;
|
||||
}
|
||||
|
||||
export interface ClusterModelProviderCredentialManagementService {
|
||||
bind(
|
||||
request: Readonly<BindModelProviderCredentialRequest>,
|
||||
): Promise<Readonly<CommitModelProviderCredentialTransitionResult>>;
|
||||
revoke(
|
||||
request: Readonly<RevokeModelProviderCredentialRequest>,
|
||||
): Promise<Readonly<CommitModelProviderCredentialTransitionResult>>;
|
||||
listAudit(
|
||||
request: Readonly<ListModelProviderCredentialManagementAuditRequest>,
|
||||
): Promise<Readonly<ModelProviderCredentialManagementAuditPage>>;
|
||||
planTestConnection(
|
||||
request: Readonly<PlanModelProviderCredentialTestRequest>,
|
||||
): Promise<Readonly<CreateModelProviderCredentialTestPlanResult>>;
|
||||
}
|
||||
|
||||
export interface ClusterModelProviderCredentialManagementOptions {
|
||||
readonly policy: ClusterModelProviderCredentialManagementPolicy;
|
||||
readonly credentials: ModelProviderCredentialAdministrationRepository;
|
||||
readonly audit: ModelProviderCredentialManagementAuditQueryRepository;
|
||||
readonly testPlans: ModelProviderCredentialTestPlanRepository;
|
||||
readonly testAllowlist: ModelProviderCredentialTestAllowlist;
|
||||
readonly testPlanLifetimeMs?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementRequestError extends TypeError {
|
||||
readonly code =
|
||||
'CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_REQUEST_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster model provider credential management request is invalid');
|
||||
this.name = 'ClusterModelProviderCredentialManagementRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementAuthenticationError extends Error {
|
||||
readonly code =
|
||||
'CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Cluster model provider credential management requires a recent strong User',
|
||||
);
|
||||
this.name = 'ClusterModelProviderCredentialManagementAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementAuthorizationError extends Error {
|
||||
readonly code = 'CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Cluster model provider credential management is forbidden');
|
||||
this.name = 'ClusterModelProviderCredentialManagementAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementConflictError extends Error {
|
||||
readonly code = 'CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Cluster model provider credential management conflicts with durable state',
|
||||
);
|
||||
this.name = 'ClusterModelProviderCredentialManagementConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster model provider credential management is unavailable');
|
||||
this.name = 'ClusterModelProviderCredentialManagementUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementQuotaExceededError extends Error {
|
||||
readonly code = 'CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_QUOTA_EXCEEDED';
|
||||
|
||||
constructor(readonly retryAfterMs: number) {
|
||||
super('Cluster model provider credential management quota is exceeded');
|
||||
this.name = 'ClusterModelProviderCredentialManagementQuotaExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBaseRequest(
|
||||
value: BaseMutationRequest,
|
||||
expectedKeys: readonly string[],
|
||||
): Readonly<BaseMutationRequest> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, expectedKeys) ||
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!UUID_PATTERN.test(value.mutationId) ||
|
||||
typeof value.projectId !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(value.projectId) ||
|
||||
typeof value.provider !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(value.provider) ||
|
||||
!Number.isSafeInteger(value.expectedGeneration) ||
|
||||
value.expectedGeneration < 0 ||
|
||||
value.expectedGeneration > 2_147_483_646
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapMutationError(error: unknown): never {
|
||||
if (
|
||||
error instanceof
|
||||
InvalidModelProviderCredentialAdministrationMutationError ||
|
||||
error instanceof InvalidModelProviderCredentialTransitionError
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
if (
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationMutationConflictError ||
|
||||
error instanceof ModelProviderCredentialTransitionConflictError
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementConflictError();
|
||||
}
|
||||
if (error instanceof ModelProviderCredentialCatalogUnavailableError) {
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
|
||||
function mapAuditError(error: unknown): never {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialManagementAuditQueryError
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
if (
|
||||
error instanceof
|
||||
ModelProviderCredentialManagementAuditAuthorizationFenceConflictError ||
|
||||
error instanceof ModelProviderCredentialManagementAuditConflictError
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementConflictError();
|
||||
}
|
||||
if (error instanceof ModelProviderCredentialManagementAuditUnavailableError) {
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
|
||||
function mapTestPlanError(error: unknown): never {
|
||||
if (error instanceof InvalidModelProviderCredentialTestConnectionError) {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
if (
|
||||
error instanceof
|
||||
ModelProviderCredentialTestPlanAuthorizationFenceConflictError ||
|
||||
error instanceof ModelProviderCredentialTestPlanConflictError
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementConflictError();
|
||||
}
|
||||
if (error instanceof ModelProviderCredentialTestPlanQuotaExceededError) {
|
||||
throw new ClusterModelProviderCredentialManagementQuotaExceededError(
|
||||
error.retryAfterMs,
|
||||
);
|
||||
}
|
||||
if (error instanceof ModelProviderCredentialTestPlanUnavailableError) {
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
|
||||
export function createClusterModelProviderCredentialManagementService(
|
||||
options: ClusterModelProviderCredentialManagementOptions,
|
||||
): Readonly<ClusterModelProviderCredentialManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'policy' &&
|
||||
key !== 'credentials' &&
|
||||
key !== 'audit' &&
|
||||
key !== 'testPlans' &&
|
||||
key !== 'testAllowlist' &&
|
||||
key !== 'testPlanLifetimeMs' &&
|
||||
key !== 'now',
|
||||
) ||
|
||||
!options.policy ||
|
||||
typeof options.policy.authorize !== 'function' ||
|
||||
!options.credentials ||
|
||||
typeof options.credentials.commitAuthorized !== 'function' ||
|
||||
typeof options.credentials.findCurrentTransition !== 'function' ||
|
||||
typeof options.credentials.commit !== 'function' ||
|
||||
!options.audit ||
|
||||
typeof options.audit.listAuthorized !== 'function' ||
|
||||
!options.testPlans ||
|
||||
typeof options.testPlans.createAuthorized !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster model provider credential management options are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
let testAllowlist: Readonly<ModelProviderCredentialTestAllowlist>;
|
||||
try {
|
||||
testAllowlist = normalizeModelProviderCredentialTestAllowlist(
|
||||
options.testAllowlist,
|
||||
);
|
||||
} catch {
|
||||
throw new TypeError(
|
||||
'Cluster model provider credential management options are invalid',
|
||||
);
|
||||
}
|
||||
const testPlanLifetimeMs =
|
||||
options.testPlanLifetimeMs ??
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(testPlanLifetimeMs) ||
|
||||
testPlanLifetimeMs < 1_000 ||
|
||||
testPlanLifetimeMs > MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster model provider credential management options are invalid',
|
||||
);
|
||||
}
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
) => {
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new ClusterModelProviderCredentialManagementAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance) ||
|
||||
observedAtMs - principal.authenticatedAtMs > MAX_PRINCIPAL_AGE_MS
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementAuthenticationError();
|
||||
}
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = await options.policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
'secret.manage',
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterModelProviderCredentialManagementUnavailableError();
|
||||
}
|
||||
if (
|
||||
decision.effect !== 'allow' ||
|
||||
decision.fence === null ||
|
||||
decision.fence.bindingVersion === null
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementAuthorizationError();
|
||||
}
|
||||
return Object.freeze({ principal, decision, observedAtMs });
|
||||
};
|
||||
|
||||
const commit = async (
|
||||
request:
|
||||
| Readonly<BindModelProviderCredentialRequest>
|
||||
| Readonly<RevokeModelProviderCredentialRequest>,
|
||||
action: 'bind' | 'revoke',
|
||||
) => {
|
||||
const authority = await authorize(request.principal, request.projectId);
|
||||
let command;
|
||||
try {
|
||||
command = createModelProviderCredentialTransitionCommand({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA,
|
||||
mutationId: request.mutationId,
|
||||
projectId: request.projectId,
|
||||
provider: request.provider,
|
||||
expectedGeneration: request.expectedGeneration,
|
||||
action,
|
||||
binding:
|
||||
action === 'bind'
|
||||
? {
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA,
|
||||
projectId: request.projectId,
|
||||
provider: request.provider,
|
||||
revision: (request as BindModelProviderCredentialRequest)
|
||||
.revision,
|
||||
secretRef: (request as BindModelProviderCredentialRequest)
|
||||
.secretRef,
|
||||
scheme: 'bearer',
|
||||
}
|
||||
: null,
|
||||
changedBy: authority.principal.subject,
|
||||
});
|
||||
} catch (error) {
|
||||
return mapMutationError(error);
|
||||
}
|
||||
try {
|
||||
return await options.credentials.commitAuthorized({
|
||||
command,
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: {
|
||||
eventId: command.mutationId,
|
||||
requestId: request.requestId,
|
||||
operationId: modelProviderCredentialAdministrationOperationId(
|
||||
command.action,
|
||||
),
|
||||
projectId: command.projectId,
|
||||
subject: authority.principal.subject,
|
||||
authenticationId: authority.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: authority.decision.reasons,
|
||||
fence: authority.decision.fence,
|
||||
occurredAtMs: authority.observedAtMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return mapMutationError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async bind(request: Readonly<BindModelProviderCredentialRequest>) {
|
||||
normalizeBaseRequest(request, [
|
||||
'expectedGeneration',
|
||||
'mutationId',
|
||||
'principal',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'revision',
|
||||
'secretRef',
|
||||
]);
|
||||
if (
|
||||
typeof request.revision !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(request.revision) ||
|
||||
typeof request.secretRef !== 'string'
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
return commit(request, 'bind');
|
||||
},
|
||||
|
||||
async revoke(request: Readonly<RevokeModelProviderCredentialRequest>) {
|
||||
normalizeBaseRequest(request, [
|
||||
'expectedGeneration',
|
||||
'mutationId',
|
||||
'principal',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
]);
|
||||
return commit(request, 'revoke');
|
||||
},
|
||||
|
||||
async listAudit(
|
||||
request: Readonly<ListModelProviderCredentialManagementAuditRequest>,
|
||||
) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!exactKeys(
|
||||
request,
|
||||
request.before === undefined
|
||||
? ['limit', 'principal', 'projectId', 'queryId', 'requestId']
|
||||
: [
|
||||
'before',
|
||||
'limit',
|
||||
'principal',
|
||||
'projectId',
|
||||
'queryId',
|
||||
'requestId',
|
||||
],
|
||||
)
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
let query;
|
||||
try {
|
||||
query = normalizeModelProviderCredentialManagementAuditQuery({
|
||||
schemaVersion: 1,
|
||||
queryId: request.queryId,
|
||||
requestId: request.requestId,
|
||||
projectId: request.projectId,
|
||||
limit: request.limit,
|
||||
...(request.before === undefined ? {} : { before: request.before }),
|
||||
});
|
||||
} catch {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
const authority = await authorize(request.principal, request.projectId);
|
||||
try {
|
||||
return await options.audit.listAuthorized({
|
||||
query,
|
||||
actor: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
audit: {
|
||||
eventId: request.queryId,
|
||||
requestId: request.requestId,
|
||||
operationId:
|
||||
MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_QUERY_OPERATION_ID,
|
||||
projectId: request.projectId,
|
||||
subject: authority.principal.subject,
|
||||
authenticationId: authority.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: authority.decision.reasons,
|
||||
fence: authority.decision.fence,
|
||||
occurredAtMs: authority.observedAtMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return mapAuditError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async planTestConnection(
|
||||
request: Readonly<PlanModelProviderCredentialTestRequest>,
|
||||
) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!exactKeys(request, [
|
||||
'principal',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'testId',
|
||||
]) ||
|
||||
typeof request.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(request.requestId) ||
|
||||
typeof request.testId !== 'string' ||
|
||||
!UUID_PATTERN.test(request.testId) ||
|
||||
typeof request.projectId !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(request.projectId) ||
|
||||
typeof request.provider !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(request.provider)
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementRequestError();
|
||||
}
|
||||
const authority = await authorize(request.principal, request.projectId);
|
||||
let plan;
|
||||
try {
|
||||
const endpoint = resolveModelProviderCredentialTestEndpoint(
|
||||
testAllowlist,
|
||||
request.provider,
|
||||
);
|
||||
if (
|
||||
authority.observedAtMs >
|
||||
Number.MAX_SAFE_INTEGER - testPlanLifetimeMs
|
||||
) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError();
|
||||
}
|
||||
plan = createModelProviderCredentialTestPlan({
|
||||
testId: request.testId,
|
||||
requestId: request.requestId,
|
||||
projectId: request.projectId,
|
||||
provider: request.provider,
|
||||
endpoint,
|
||||
requestedBy: authority.principal.subject,
|
||||
fence: authority.decision.fence!,
|
||||
plannedAtMs: authority.observedAtMs,
|
||||
expiresAtMs: authority.observedAtMs + testPlanLifetimeMs,
|
||||
});
|
||||
return await options.testPlans.createAuthorized({
|
||||
plan,
|
||||
audit: {
|
||||
eventId: plan.testId,
|
||||
requestId: plan.requestId,
|
||||
operationId: MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_OPERATION_ID,
|
||||
projectId: plan.projectId,
|
||||
subject: authority.principal.subject,
|
||||
authenticationId: authority.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: authority.decision.reasons,
|
||||
fence: authority.decision.fence,
|
||||
occurredAtMs: authority.observedAtMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return mapTestPlanError(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
startClusterModelProviderCredentialManagementProcess,
|
||||
type ClusterModelProviderCredentialManagementProcessRuntime,
|
||||
} from './modelProviderCredentialManagementProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-provider-credential-manage';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management',
|
||||
event: 'management_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
let runtime: Readonly<ClusterModelProviderCredentialManagementProcessRuntime>;
|
||||
try {
|
||||
runtime = await startClusterModelProviderCredentialManagementProcess({
|
||||
environment: process.env,
|
||||
onError() {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management',
|
||||
event: 'management_unavailable',
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (runtime.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management',
|
||||
event: 'management_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management',
|
||||
event: 'management_started',
|
||||
address: runtime.address,
|
||||
identityGeneration: runtime.identity.generation,
|
||||
databaseMigrationCount: runtime.database.migrationIds.length,
|
||||
});
|
||||
|
||||
let stopping: Promise<void> | undefined;
|
||||
const stop = (): Promise<void> => {
|
||||
stopping ??= runtime.close().then(() => {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management',
|
||||
event: 'management_stopped',
|
||||
});
|
||||
});
|
||||
return stopping;
|
||||
};
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void stop().then(
|
||||
() => {
|
||||
process.exitCode = 0;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
executeClusterAuthenticatedManagementClient,
|
||||
type ClusterAuthenticatedManagementClientResult,
|
||||
type ClusterPluginPackageManagementClientConnectionOptions,
|
||||
type ClusterPluginPackageManagementClientPaths,
|
||||
} from '../management-support/pluginPackageManagementClient';
|
||||
import {
|
||||
normalizeClusterModelProviderCredentialManagementCommand,
|
||||
type ClusterModelProviderCredentialManagementCommand,
|
||||
} from './modelProviderCredentialManagementTransport';
|
||||
import {
|
||||
normalizeModelProviderCredentialTestPlan,
|
||||
type ModelProviderCredentialTestPlan,
|
||||
} from '@qinglong/ai/model-provider-credential-test-connection';
|
||||
|
||||
const MANAGEMENT_PATH = '/api/v3/provider-credentials/management';
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export type ClusterModelProviderCredentialManagementClientPaths =
|
||||
ClusterPluginPackageManagementClientPaths;
|
||||
export type ClusterModelProviderCredentialManagementClientConnectionOptions =
|
||||
ClusterPluginPackageManagementClientConnectionOptions;
|
||||
|
||||
export type ClusterModelProviderCredentialManagementClientTransportResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.bind' | 'provider-credential.revoke';
|
||||
status: 'created' | 'existing';
|
||||
credential: Readonly<{
|
||||
projectId: string;
|
||||
provider: string;
|
||||
generation: number;
|
||||
action: 'bind' | 'revoke';
|
||||
activeBindingRevision: string | null;
|
||||
activeBindingDigest: string | null;
|
||||
transitionDigest: string;
|
||||
changedAtMs: number;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.audit.list';
|
||||
audit: Readonly<{
|
||||
projectId: string;
|
||||
records: readonly Readonly<{
|
||||
eventId: string;
|
||||
requestId: string;
|
||||
operation: 'provider-credential.bind' | 'provider-credential.revoke';
|
||||
actor: Readonly<{ type: 'user'; id: string }>;
|
||||
fence: Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number;
|
||||
}>;
|
||||
occurredAtMs: number;
|
||||
}>[];
|
||||
nextCursor: Readonly<{
|
||||
occurredAtMs: number;
|
||||
eventId: string;
|
||||
}> | null;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.test.plan';
|
||||
status: 'created' | 'existing';
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>;
|
||||
}>;
|
||||
|
||||
export type ClusterModelProviderCredentialManagementClientResult =
|
||||
ClusterAuthenticatedManagementClientResult<ClusterModelProviderCredentialManagementClientTransportResult>;
|
||||
|
||||
function invalid(): never {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function identifier(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 128 ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function uuid(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) invalid();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) invalid();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function validateClusterModelProviderCredentialManagementClientResult(
|
||||
value: unknown,
|
||||
command: Readonly<ClusterModelProviderCredentialManagementCommand>,
|
||||
): Readonly<ClusterModelProviderCredentialManagementClientTransportResult> {
|
||||
if (command.operation === 'provider-credential.test.plan') {
|
||||
const envelope = exactRecord(value, [
|
||||
'operation',
|
||||
'plan',
|
||||
'schemaVersion',
|
||||
'status',
|
||||
]);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
envelope.operation !== command.operation ||
|
||||
(envelope.status !== 'created' && envelope.status !== 'existing')
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
let plan: Readonly<ModelProviderCredentialTestPlan>;
|
||||
try {
|
||||
plan = normalizeModelProviderCredentialTestPlan(
|
||||
envelope.plan as ModelProviderCredentialTestPlan,
|
||||
);
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
plan.testId !== command.request.testId ||
|
||||
plan.requestId !== command.request.requestId ||
|
||||
plan.projectId !== command.request.projectId ||
|
||||
plan.provider !== command.request.provider
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: envelope.status,
|
||||
plan,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'provider-credential.audit.list') {
|
||||
const envelope = exactRecord(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'audit',
|
||||
]);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
envelope.operation !== command.operation
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const audit = exactRecord(envelope.audit, [
|
||||
'projectId',
|
||||
'records',
|
||||
'nextCursor',
|
||||
]);
|
||||
if (
|
||||
identifier(audit.projectId) !== command.request.projectId ||
|
||||
!Array.isArray(audit.records) ||
|
||||
audit.records.length > command.request.limit
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const records = audit.records.map((value) => {
|
||||
const record = exactRecord(value, [
|
||||
'actor',
|
||||
'eventId',
|
||||
'fence',
|
||||
'occurredAtMs',
|
||||
'operation',
|
||||
'requestId',
|
||||
]);
|
||||
uuid(record.eventId);
|
||||
identifier(record.requestId);
|
||||
if (
|
||||
record.operation !== 'provider-credential.bind' &&
|
||||
record.operation !== 'provider-credential.revoke'
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const actor = exactRecord(record.actor, ['id', 'type']);
|
||||
if (actor.type !== 'user') invalid();
|
||||
identifier(actor.id);
|
||||
const fence = exactRecord(record.fence, [
|
||||
'bindingVersion',
|
||||
'projectVersion',
|
||||
]);
|
||||
positiveInteger(fence.projectVersion);
|
||||
positiveInteger(fence.bindingVersion);
|
||||
nonNegativeInteger(record.occurredAtMs);
|
||||
return record;
|
||||
});
|
||||
if (audit.nextCursor !== null) {
|
||||
const cursor = exactRecord(audit.nextCursor, ['eventId', 'occurredAtMs']);
|
||||
uuid(cursor.eventId);
|
||||
nonNegativeInteger(cursor.occurredAtMs);
|
||||
const last = records.at(-1);
|
||||
if (
|
||||
records.length !== command.request.limit ||
|
||||
!last ||
|
||||
cursor.eventId !== last.eventId ||
|
||||
cursor.occurredAtMs !== last.occurredAtMs
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterModelProviderCredentialManagementClientTransportResult,
|
||||
);
|
||||
}
|
||||
const envelope = exactRecord(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'status',
|
||||
'credential',
|
||||
]);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
envelope.operation !== command.operation ||
|
||||
!['created', 'existing'].includes(String(envelope.status))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const credential = exactRecord(envelope.credential, [
|
||||
'projectId',
|
||||
'provider',
|
||||
'generation',
|
||||
'action',
|
||||
'activeBindingRevision',
|
||||
'activeBindingDigest',
|
||||
'transitionDigest',
|
||||
'changedAtMs',
|
||||
]);
|
||||
const expectedAction =
|
||||
command.operation === 'provider-credential.bind' ? 'bind' : 'revoke';
|
||||
if (
|
||||
identifier(credential.projectId) !== command.request.projectId ||
|
||||
identifier(credential.provider) !== command.request.provider ||
|
||||
!Number.isSafeInteger(credential.generation) ||
|
||||
(credential.generation as number) < 1 ||
|
||||
credential.action !== expectedAction ||
|
||||
!Number.isSafeInteger(credential.changedAtMs) ||
|
||||
(credential.changedAtMs as number) < 0
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
digest(credential.transitionDigest);
|
||||
if (command.operation === 'provider-credential.bind') {
|
||||
if (
|
||||
identifier(credential.activeBindingRevision) !== command.request.revision
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
digest(credential.activeBindingDigest);
|
||||
} else if (
|
||||
credential.activeBindingRevision !== null ||
|
||||
credential.activeBindingDigest !== null
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze(
|
||||
envelope as unknown as ClusterModelProviderCredentialManagementClientTransportResult,
|
||||
);
|
||||
}
|
||||
|
||||
const PROTOCOL = Object.freeze({
|
||||
managementPath: MANAGEMENT_PATH,
|
||||
clientCertificate: 'required' as const,
|
||||
normalizeCommand: normalizeClusterModelProviderCredentialManagementCommand,
|
||||
validateResult: validateClusterModelProviderCredentialManagementClientResult,
|
||||
});
|
||||
|
||||
export async function executeClusterModelProviderCredentialManagementClient(
|
||||
paths: ClusterModelProviderCredentialManagementClientPaths,
|
||||
connectionOptions?: ClusterModelProviderCredentialManagementClientConnectionOptions,
|
||||
): Promise<Readonly<ClusterModelProviderCredentialManagementClientResult>> {
|
||||
return executeClusterAuthenticatedManagementClient(
|
||||
paths,
|
||||
PROTOCOL,
|
||||
connectionOptions,
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { executeClusterModelProviderCredentialManagementClient } from './modelProviderCredentialManagementClient';
|
||||
import { ClusterPluginPackageManagementClientRemoteError } from '../management-support/pluginPackageManagementClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-provider-credential-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
|
||||
|
||||
function parseArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 3) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(config|command|assertion)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = parseArguments(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management-client',
|
||||
event: 'usage_invalid',
|
||||
code: 'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_CLIENT_USAGE_INVALID',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
await executeClusterModelProviderCredentialManagementClient(paths);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-management-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
startClusterPluginPackageManagementHttp,
|
||||
type ClusterPluginPackageManagementHttpApplication,
|
||||
type StartClusterPluginPackageManagementHttpOptions,
|
||||
} from '../management-support/pluginPackageManagementHttp';
|
||||
|
||||
export type ClusterModelProviderCredentialManagementHttpApplication =
|
||||
ClusterPluginPackageManagementHttpApplication;
|
||||
|
||||
export type StartClusterModelProviderCredentialManagementHttpOptions = Omit<
|
||||
StartClusterPluginPackageManagementHttpOptions,
|
||||
'managementPath'
|
||||
>;
|
||||
|
||||
/** Starts the bounded OIDC/mTLS adapter on the provider-credential-only path. */
|
||||
export function startClusterModelProviderCredentialManagementHttp(
|
||||
options: StartClusterModelProviderCredentialManagementHttpOptions,
|
||||
): Promise<Readonly<ClusterModelProviderCredentialManagementHttpApplication>> {
|
||||
return startClusterPluginPackageManagementHttp({
|
||||
...options,
|
||||
managementPath: CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
});
|
||||
}
|
||||
+685
@@ -0,0 +1,685 @@
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
PostgresProjectPolicyRepository,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/ai-credential-manager';
|
||||
import { PostgresModelProviderCredentialRepository } from '@qinglong/ai/postgres-model-provider-credential-storage';
|
||||
import { PostgresModelProviderCredentialManagementAuditQueryRepository } from '@qinglong/ai/postgres-model-provider-credential-management-audit-query';
|
||||
import {
|
||||
PostgresModelProviderCredentialManagementIdentityLedgerRepository,
|
||||
assertPostgresModelProviderCredentialManagerReady,
|
||||
type PostgresModelProviderCredentialManagerReadinessReport,
|
||||
} from '@qinglong/ai/postgres-model-provider-credential-management-identity-ledger';
|
||||
import {
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS,
|
||||
normalizeModelProviderCredentialTestAllowlist,
|
||||
type ModelProviderCredentialTestAllowlist,
|
||||
} from '@qinglong/ai/model-provider-credential-test-connection';
|
||||
import { PostgresModelProviderCredentialTestPlanRepository } from '@qinglong/ai/postgres-model-provider-credential-test-connection';
|
||||
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
readManagementTlsFile,
|
||||
} from '../management-support/managementProcessSupport';
|
||||
import {
|
||||
createClusterModelProviderCredentialIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetSnapshot,
|
||||
} from '../management-support/pluginPackageIdentityKeyset';
|
||||
import { createClusterModelProviderCredentialManagementService } from './modelProviderCredentialManagement';
|
||||
import {
|
||||
startClusterModelProviderCredentialManagementHttp,
|
||||
type ClusterModelProviderCredentialManagementHttpApplication,
|
||||
type StartClusterModelProviderCredentialManagementHttpOptions,
|
||||
} from './modelProviderCredentialManagementHttp';
|
||||
import { createClusterModelProviderCredentialManagementTransport } from './modelProviderCredentialManagementTransport';
|
||||
import { validateClusterManagementClientTrust } from '../worker-credential/management-server/workerCredentialManagementMutualTls';
|
||||
|
||||
const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.:-]{0,254}$/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
|
||||
export type ClusterModelProviderCredentialManagementProcessEnvironment =
|
||||
Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export type ClusterModelProviderCredentialManagementProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
host: string;
|
||||
port: number;
|
||||
certificateFile: string;
|
||||
privateKeyFile: string;
|
||||
clientCertificateAuthorityFile: string;
|
||||
clientCertificateRevocationListFile: string;
|
||||
identityKeysetFile: string;
|
||||
testConnection: Readonly<{
|
||||
allowlistFile: string;
|
||||
planLifetimeMs: number;
|
||||
quotaWindowMs: number;
|
||||
quotaLimit: number;
|
||||
}>;
|
||||
http: Readonly<{
|
||||
maxBodyBytes: number;
|
||||
maxConnections: number;
|
||||
maxConcurrentRequests: number;
|
||||
requestTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
rateWindowMs: number;
|
||||
peerRequestLimit: number;
|
||||
globalRequestLimit: number;
|
||||
maxRateLimitPeers: number;
|
||||
}>;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterModelProviderCredentialManagementProcessRuntime =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
close(): Promise<void>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
address: Readonly<{ host: string; port: number }>;
|
||||
database: PostgresModelProviderCredentialManagerReadinessReport;
|
||||
identity: ClusterPluginPackageIdentityKeysetSnapshot;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export interface StartClusterModelProviderCredentialManagementProcessOptions {
|
||||
readonly environment: ClusterModelProviderCredentialManagementProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly identities?: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly assertReady?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
) => Promise<PostgresModelProviderCredentialManagerReadinessReport>;
|
||||
readonly startHttp?: (
|
||||
options: StartClusterModelProviderCredentialManagementHttpOptions,
|
||||
) => Promise<
|
||||
Readonly<ClusterModelProviderCredentialManagementHttpApplication>
|
||||
>;
|
||||
readonly now?: () => number;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementProcessConfigError extends TypeError {
|
||||
readonly code =
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Model provider credential management process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterModelProviderCredentialManagementProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function configFailure(
|
||||
message: string,
|
||||
): ClusterModelProviderCredentialManagementProcessConfigError {
|
||||
return new ClusterModelProviderCredentialManagementProcessConfigError(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterModelProviderCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterModelProviderCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterModelProviderCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function absoluteFile(
|
||||
environment: ClusterModelProviderCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
return absoluteManagementEnvironmentFile(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterModelProviderCredentialManagementProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_URL',
|
||||
host: 'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_HOST',
|
||||
port: 'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_PORT',
|
||||
database: 'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_DATABASE',
|
||||
user: 'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_USER',
|
||||
password: 'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw configFailure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL AI credential manager connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_AI_CREDENTIAL_MANAGER_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_ALLOW_INSECURE',
|
||||
)
|
||||
) {
|
||||
throw configFailure(
|
||||
'disabling AI credential manager PostgreSQL TLS requires QL3_POSTGRES_AI_CREDENTIAL_MANAGER_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_TLS_CA_FILE is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-ai-credential-manager';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
}),
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_POOL_MAX',
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
),
|
||||
idleTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_IDLE_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_MANAGER_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterModelProviderCredentialManagementProcessConfig(
|
||||
environment: ClusterModelProviderCredentialManagementProcessEnvironment,
|
||||
): Readonly<ClusterModelProviderCredentialManagementProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw configFailure('environment is invalid');
|
||||
}
|
||||
if (
|
||||
!booleanValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_ENABLED',
|
||||
)
|
||||
) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw configFailure(
|
||||
'QL3_PROFILE must be cluster-admin when model provider credential management is enabled',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_HOST',
|
||||
255,
|
||||
) ?? '0.0.0.0';
|
||||
if (!SAFE_HOST.test(host)) throw configFailure('host is invalid');
|
||||
const http = Object.freeze({
|
||||
maxBodyBytes: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MAX_BODY_BYTES',
|
||||
32 * 1024,
|
||||
1_024,
|
||||
64 * 1024,
|
||||
),
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MAX_CONNECTIONS',
|
||||
32,
|
||||
1,
|
||||
128,
|
||||
),
|
||||
maxConcurrentRequests: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
|
||||
8,
|
||||
1,
|
||||
32,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_REQUEST_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
30_000,
|
||||
),
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_DRAIN_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
30_000,
|
||||
),
|
||||
rateWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
peerRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PEER_REQUEST_LIMIT',
|
||||
30,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
globalRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
|
||||
120,
|
||||
1,
|
||||
10_000,
|
||||
),
|
||||
maxRateLimitPeers: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MAX_RATE_LIMIT_PEERS',
|
||||
256,
|
||||
1,
|
||||
4_096,
|
||||
),
|
||||
});
|
||||
if (http.globalRequestLimit < http.peerRequestLimit) {
|
||||
throw configFailure(
|
||||
'global request limit cannot be below the peer request limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
host,
|
||||
port: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PORT',
|
||||
8_446,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
certificateFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_TLS_CERT_FILE',
|
||||
),
|
||||
privateKeyFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_TLS_KEY_FILE',
|
||||
),
|
||||
clientCertificateAuthorityFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_CLIENT_CA_FILE',
|
||||
),
|
||||
clientCertificateRevocationListFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_CLIENT_CRL_FILE',
|
||||
),
|
||||
identityKeysetFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_KEYSET_FILE',
|
||||
),
|
||||
testConnection: Object.freeze({
|
||||
allowlistFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_FILE',
|
||||
),
|
||||
planLifetimeMs: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS,
|
||||
),
|
||||
quotaWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_QUOTA_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
quotaLimit: integerValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_QUOTA_LIMIT',
|
||||
5,
|
||||
1,
|
||||
32,
|
||||
),
|
||||
}),
|
||||
http,
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function readTlsFile(filePath: string, privateMaterial: boolean): Buffer {
|
||||
return readManagementTlsFile(filePath, privateMaterial, configFailure);
|
||||
}
|
||||
|
||||
function readTestAllowlist(
|
||||
filePath: string,
|
||||
): Readonly<ModelProviderCredentialTestAllowlist> {
|
||||
const bytes = readManagementTlsFile(filePath, false, configFailure);
|
||||
try {
|
||||
if (bytes.length > 64 * 1_024) {
|
||||
throw configFailure(
|
||||
'model provider credential test allowlist is too large',
|
||||
);
|
||||
}
|
||||
return normalizeModelProviderCredentialTestAllowlist(
|
||||
JSON.parse(
|
||||
bytes.toString('utf8'),
|
||||
) as ModelProviderCredentialTestAllowlist,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterModelProviderCredentialManagementProcessConfigError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configFailure('model provider credential test allowlist is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export async function startClusterModelProviderCredentialManagementProcess(
|
||||
options: StartClusterModelProviderCredentialManagementProcessOptions,
|
||||
): Promise<Readonly<ClusterModelProviderCredentialManagementProcessRuntime>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'environment',
|
||||
'openDatabase',
|
||||
'identities',
|
||||
'assertReady',
|
||||
'startHttp',
|
||||
'now',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
!options.environment ||
|
||||
typeof options.environment !== 'object' ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.identities !== undefined &&
|
||||
(typeof options.identities.reload !== 'function' ||
|
||||
typeof options.identities.bind !== 'function')) ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.startHttp !== undefined &&
|
||||
typeof options.startHttp !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw configFailure('options are invalid');
|
||||
}
|
||||
const config = loadClusterModelProviderCredentialManagementProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
let http:
|
||||
| Readonly<ClusterModelProviderCredentialManagementHttpApplication>
|
||||
| undefined;
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let unavailableError: unknown;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics do not own availability or cleanup.
|
||||
}
|
||||
};
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'ai-credential-manager',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError(error) {
|
||||
const firstAvailabilityError = unavailableError === undefined;
|
||||
unavailableError ??= error;
|
||||
http?.withdraw(error);
|
||||
if (firstAvailabilityError) report(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
database = await openDatabase();
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresModelProviderCredentialManagerReady
|
||||
)(database.pool);
|
||||
if (unavailableError !== undefined) throw unavailableError;
|
||||
const identities =
|
||||
options.identities ??
|
||||
createClusterModelProviderCredentialIdentityKeysetFile({
|
||||
filePath: config.identityKeysetFile,
|
||||
now,
|
||||
ledger:
|
||||
new PostgresModelProviderCredentialManagementIdentityLedgerRepository(
|
||||
database.pool,
|
||||
),
|
||||
});
|
||||
const identity = await identities.reload();
|
||||
const testAllowlist = readTestAllowlist(
|
||||
config.testConnection.allowlistFile,
|
||||
);
|
||||
const service = createClusterModelProviderCredentialManagementService({
|
||||
policy: new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(database.pool),
|
||||
),
|
||||
credentials: new PostgresModelProviderCredentialRepository(database.pool),
|
||||
audit: new PostgresModelProviderCredentialManagementAuditQueryRepository(
|
||||
database.pool,
|
||||
),
|
||||
testPlans: new PostgresModelProviderCredentialTestPlanRepository(
|
||||
database.pool,
|
||||
{
|
||||
quotaWindowMs: config.testConnection.quotaWindowMs,
|
||||
quotaLimit: config.testConnection.quotaLimit,
|
||||
},
|
||||
),
|
||||
testAllowlist,
|
||||
testPlanLifetimeMs: config.testConnection.planLifetimeMs,
|
||||
now,
|
||||
});
|
||||
const transport = createClusterModelProviderCredentialManagementTransport({
|
||||
service,
|
||||
now,
|
||||
});
|
||||
const privateKey = readTlsFile(config.privateKeyFile, true);
|
||||
try {
|
||||
const certificate = readTlsFile(config.certificateFile, false);
|
||||
const clientCertificateAuthority = readTlsFile(
|
||||
config.clientCertificateAuthorityFile,
|
||||
false,
|
||||
);
|
||||
const clientCertificateRevocationList = readTlsFile(
|
||||
config.clientCertificateRevocationListFile,
|
||||
false,
|
||||
);
|
||||
validateClusterManagementClientTrust(
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
now(),
|
||||
configFailure,
|
||||
);
|
||||
http = await (
|
||||
options.startHttp ?? startClusterModelProviderCredentialManagementHttp
|
||||
)({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: {
|
||||
privateKey,
|
||||
certificate,
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
},
|
||||
transport,
|
||||
identities,
|
||||
limits: config.http,
|
||||
now,
|
||||
onError: report,
|
||||
});
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
if (unavailableError !== undefined) http.withdraw(unavailableError);
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: http.address,
|
||||
database: evidence,
|
||||
identity,
|
||||
availabilityStatus: () => http?.availabilityStatus() ?? 'stopped',
|
||||
close(): Promise<void> {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = (async () => {
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
await http?.close();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await http?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { ModelProviderCredentialTestPlan } from '@qinglong/ai/model-provider-credential-test-connection';
|
||||
|
||||
import type {
|
||||
BindModelProviderCredentialRequest,
|
||||
ClusterModelProviderCredentialManagementService,
|
||||
ListModelProviderCredentialManagementAuditRequest,
|
||||
PlanModelProviderCredentialTestRequest,
|
||||
RevokeModelProviderCredentialRequest,
|
||||
} from './modelProviderCredentialManagement';
|
||||
|
||||
const STRONG_CLUSTER_ASSURANCES = new Set(['multi_factor', 'hardware']);
|
||||
const MAX_PRINCIPAL_AGE_MS = 5 * 60 * 1_000;
|
||||
|
||||
interface BindTransportRequest
|
||||
extends Omit<BindModelProviderCredentialRequest, 'principal'> {}
|
||||
|
||||
interface RevokeTransportRequest
|
||||
extends Omit<RevokeModelProviderCredentialRequest, 'principal'> {}
|
||||
|
||||
interface AuditTransportRequest
|
||||
extends Omit<
|
||||
ListModelProviderCredentialManagementAuditRequest,
|
||||
'principal'
|
||||
> {}
|
||||
|
||||
interface TestPlanTransportRequest
|
||||
extends Omit<PlanModelProviderCredentialTestRequest, 'principal'> {}
|
||||
|
||||
export type ClusterModelProviderCredentialManagementCommand =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.bind';
|
||||
request: BindTransportRequest;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.revoke';
|
||||
request: RevokeTransportRequest;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.audit.list';
|
||||
request: AuditTransportRequest;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.test.plan';
|
||||
request: TestPlanTransportRequest;
|
||||
}>;
|
||||
|
||||
export interface ClusterModelProviderCredentialManagementAuthentication {
|
||||
authenticate(): Promise<Readonly<SecurityPrincipal> | null>;
|
||||
}
|
||||
|
||||
export interface ClusterModelProviderCredentialManagementTransport {
|
||||
execute(
|
||||
command: unknown,
|
||||
authentication: ClusterModelProviderCredentialManagementAuthentication,
|
||||
): Promise<Readonly<ClusterModelProviderCredentialManagementTransportResult>>;
|
||||
}
|
||||
|
||||
export type ClusterModelProviderCredentialManagementTransportResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.bind' | 'provider-credential.revoke';
|
||||
status: 'created' | 'existing';
|
||||
credential: Readonly<{
|
||||
projectId: string;
|
||||
provider: string;
|
||||
generation: number;
|
||||
action: 'bind' | 'revoke';
|
||||
activeBindingRevision: string | null;
|
||||
activeBindingDigest: string | null;
|
||||
transitionDigest: string;
|
||||
changedAtMs: number;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.audit.list';
|
||||
audit: Readonly<{
|
||||
projectId: string;
|
||||
records: readonly Readonly<{
|
||||
eventId: string;
|
||||
requestId: string;
|
||||
operation: 'provider-credential.bind' | 'provider-credential.revoke';
|
||||
actor: Readonly<{ type: 'user'; id: string }>;
|
||||
fence: Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number;
|
||||
}>;
|
||||
occurredAtMs: number;
|
||||
}>[];
|
||||
nextCursor: Readonly<{
|
||||
occurredAtMs: number;
|
||||
eventId: string;
|
||||
}> | null;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'provider-credential.test.plan';
|
||||
status: 'created' | 'existing';
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>;
|
||||
}>;
|
||||
|
||||
export class ClusterModelProviderCredentialManagementTransportConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'CLUSTER_MODEL_PROVIDER_CREDENTIAL_TRANSPORT_CONFIGURATION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Cluster model provider credential transport configuration is invalid',
|
||||
);
|
||||
this.name =
|
||||
'ClusterModelProviderCredentialManagementTransportConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementTransportRequestError extends TypeError {
|
||||
readonly code = 'CLUSTER_MODEL_PROVIDER_CREDENTIAL_TRANSPORT_REQUEST_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster model provider credential transport request is invalid');
|
||||
this.name = 'ClusterModelProviderCredentialManagementTransportRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementTransportAuthenticationError extends Error {
|
||||
readonly code =
|
||||
'CLUSTER_MODEL_PROVIDER_CREDENTIAL_TRANSPORT_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Cluster model provider credential transport requires a recent strong User',
|
||||
);
|
||||
this.name =
|
||||
'ClusterModelProviderCredentialManagementTransportAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterModelProviderCredentialManagementTransportUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_MODEL_PROVIDER_CREDENTIAL_TRANSPORT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster model provider credential transport is unavailable');
|
||||
this.name =
|
||||
'ClusterModelProviderCredentialManagementTransportUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<ClusterModelProviderCredentialManagementCommand> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['operation', 'request', 'schemaVersion']) ||
|
||||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
|
||||
((value as { operation?: unknown }).operation !==
|
||||
'provider-credential.bind' &&
|
||||
(value as { operation?: unknown }).operation !==
|
||||
'provider-credential.revoke' &&
|
||||
(value as { operation?: unknown }).operation !==
|
||||
'provider-credential.audit.list' &&
|
||||
(value as { operation?: unknown }).operation !==
|
||||
'provider-credential.test.plan') ||
|
||||
!(value as { request?: unknown }).request ||
|
||||
typeof (value as { request: unknown }).request !== 'object' ||
|
||||
Array.isArray((value as { request: unknown }).request)
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportRequestError();
|
||||
}
|
||||
const command = value as ClusterModelProviderCredentialManagementCommand;
|
||||
const common = [
|
||||
'expectedGeneration',
|
||||
'mutationId',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
];
|
||||
if (command.operation === 'provider-credential.audit.list') {
|
||||
const auditKeys = ['limit', 'projectId', 'queryId', 'requestId'];
|
||||
if ('before' in command.request) auditKeys.push('before');
|
||||
if (!exactKeys(command.request, auditKeys)) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportRequestError();
|
||||
}
|
||||
return command;
|
||||
}
|
||||
if (command.operation === 'provider-credential.test.plan') {
|
||||
if (
|
||||
!exactKeys(command.request, [
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'testId',
|
||||
])
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportRequestError();
|
||||
}
|
||||
return command;
|
||||
}
|
||||
if (
|
||||
!exactKeys(
|
||||
command.request,
|
||||
command.operation === 'provider-credential.bind'
|
||||
? [...common, 'revision', 'secretRef']
|
||||
: common,
|
||||
)
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportRequestError();
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
export function normalizeClusterModelProviderCredentialManagementCommand(
|
||||
value: unknown,
|
||||
): Readonly<ClusterModelProviderCredentialManagementCommand> {
|
||||
return normalizeCommand(value);
|
||||
}
|
||||
|
||||
function summary(
|
||||
transition: Readonly<{
|
||||
projectId: string;
|
||||
provider: string;
|
||||
generation: number;
|
||||
action: 'bind' | 'revoke';
|
||||
activeBindingRevision: string | null;
|
||||
activeBindingDigest: string | null;
|
||||
transitionDigest: string;
|
||||
changedAtMs: number;
|
||||
}>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
projectId: transition.projectId,
|
||||
provider: transition.provider,
|
||||
generation: transition.generation,
|
||||
action: transition.action,
|
||||
activeBindingRevision: transition.activeBindingRevision,
|
||||
activeBindingDigest: transition.activeBindingDigest,
|
||||
transitionDigest: transition.transitionDigest,
|
||||
changedAtMs: transition.changedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterModelProviderCredentialManagementTransport(
|
||||
options: Readonly<{
|
||||
service: ClusterModelProviderCredentialManagementService;
|
||||
now?: () => number;
|
||||
}>,
|
||||
): Readonly<ClusterModelProviderCredentialManagementTransport> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'service' && key !== 'now') ||
|
||||
!options.service ||
|
||||
typeof options.service.bind !== 'function' ||
|
||||
typeof options.service.revoke !== 'function' ||
|
||||
typeof options.service.listAudit !== 'function' ||
|
||||
typeof options.service.planTestConnection !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportConfigurationError();
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
return Object.freeze({
|
||||
async execute(
|
||||
commandValue: unknown,
|
||||
authentication: ClusterModelProviderCredentialManagementAuthentication,
|
||||
) {
|
||||
const command = normalizeCommand(commandValue);
|
||||
if (
|
||||
!authentication ||
|
||||
typeof authentication !== 'object' ||
|
||||
Array.isArray(authentication) ||
|
||||
!exactKeys(authentication, ['authenticate']) ||
|
||||
typeof authentication.authenticate !== 'function'
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportConfigurationError();
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportUnavailableError();
|
||||
}
|
||||
let candidate: Readonly<SecurityPrincipal> | null;
|
||||
try {
|
||||
candidate = await authentication.authenticate();
|
||||
} catch {
|
||||
throw new ClusterModelProviderCredentialManagementTransportUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(
|
||||
candidate as SecurityPrincipal,
|
||||
observedAtMs,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterModelProviderCredentialManagementTransportAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_CLUSTER_ASSURANCES.has(principal.assurance) ||
|
||||
observedAtMs - principal.authenticatedAtMs > MAX_PRINCIPAL_AGE_MS
|
||||
) {
|
||||
throw new ClusterModelProviderCredentialManagementTransportAuthenticationError();
|
||||
}
|
||||
if (command.operation === 'provider-credential.audit.list') {
|
||||
const audit = await options.service.listAudit({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
audit,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'provider-credential.test.plan') {
|
||||
const result = await options.service.planTestConnection({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
plan: result.plan,
|
||||
});
|
||||
}
|
||||
const result =
|
||||
command.operation === 'provider-credential.bind'
|
||||
? await options.service.bind({ ...command.request, principal })
|
||||
: await options.service.revoke({ ...command.request, principal });
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
credential: summary(result.transition),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
/** One-shot model-provider credential connectivity executor. */
|
||||
import {
|
||||
createModelProviderCredentialTestResult,
|
||||
type ModelProviderCredentialTestAllowlist,
|
||||
type ModelProviderCredentialTestExecution,
|
||||
type ModelProviderCredentialTestPlan,
|
||||
type ModelProviderCredentialTestResult,
|
||||
} from '@qinglong/ai/model-provider-credential-test-connection';
|
||||
import {
|
||||
ModelProviderCredentialTestExecutionUnavailableError,
|
||||
type BeginModelProviderCredentialTestExecutionInput,
|
||||
type ModelProviderCredentialTestExecutionRepository,
|
||||
} from '@qinglong/ai/postgres-model-provider-credential-test-connection';
|
||||
import { OpenAiCompatibleProvider } from '@qinglong/ai/openai-compatible';
|
||||
import {
|
||||
BoundModelProviderCredentialProvider,
|
||||
type ModelProviderCredentialAuditSink,
|
||||
type ModelProviderCredentialBindingSource,
|
||||
type ModelProviderSecretMaterialProvider,
|
||||
} from '@qinglong/ai/provider-credential';
|
||||
|
||||
export interface ExecuteModelProviderCredentialTestInput
|
||||
extends BeginModelProviderCredentialTestExecutionInput {}
|
||||
|
||||
interface ModelProviderCredentialTestExecutorEvidence {
|
||||
readonly plan: Readonly<ModelProviderCredentialTestPlan>;
|
||||
readonly execution: Readonly<ModelProviderCredentialTestExecution>;
|
||||
}
|
||||
|
||||
export type ExecuteModelProviderCredentialTestResult =
|
||||
| Readonly<
|
||||
ModelProviderCredentialTestExecutorEvidence & {
|
||||
status: 'completed' | 'existing';
|
||||
result: Readonly<ModelProviderCredentialTestResult>;
|
||||
}
|
||||
>
|
||||
| Readonly<
|
||||
ModelProviderCredentialTestExecutorEvidence & {
|
||||
status: 'outcome_unknown';
|
||||
result: null;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ModelProviderCredentialTestExecutor {
|
||||
execute(
|
||||
input: Readonly<ExecuteModelProviderCredentialTestInput>,
|
||||
): Promise<Readonly<ExecuteModelProviderCredentialTestResult>>;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTestExecutorOptions {
|
||||
readonly repository: ModelProviderCredentialTestExecutionRepository;
|
||||
readonly credentials: ModelProviderCredentialBindingSource &
|
||||
ModelProviderCredentialAuditSink;
|
||||
readonly secrets: ModelProviderSecretMaterialProvider;
|
||||
readonly fetch?: typeof globalThis.fetch;
|
||||
readonly now?: () => number;
|
||||
readonly monotonicNow?: () => number;
|
||||
readonly transportReady?: (
|
||||
baseUrl: string,
|
||||
signal: AbortSignal,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestExecutorConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTOR_CONFIGURATION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Model provider credential test executor configuration is invalid');
|
||||
this.name = 'ModelProviderCredentialTestExecutorConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestExecutorUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTOR_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Model provider credential test executor is unavailable', options);
|
||||
this.name = 'ModelProviderCredentialTestExecutorUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exact(value: unknown, keys: readonly string[]): boolean {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return (
|
||||
actual.length === expected.length &&
|
||||
actual.every((key, index) => key === expected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function currentTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new ModelProviderCredentialTestExecutorUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: number, monotonicNow: () => number): number {
|
||||
const value = monotonicNow() - startedAt;
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new ModelProviderCredentialTestExecutorUnavailableError();
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
async function completeExactly(
|
||||
repository: ModelProviderCredentialTestExecutionRepository,
|
||||
result: Readonly<ModelProviderCredentialTestResult>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await repository.complete(result);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof ModelProviderCredentialTestExecutionUnavailableError)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await repository.complete(result);
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTestExecutor(
|
||||
options: ModelProviderCredentialTestExecutorOptions,
|
||||
): Readonly<ModelProviderCredentialTestExecutor> {
|
||||
const expectedKeys = ['credentials', 'repository', 'secrets'];
|
||||
if (options?.fetch !== undefined) expectedKeys.push('fetch');
|
||||
if (options?.monotonicNow !== undefined) expectedKeys.push('monotonicNow');
|
||||
if (options?.now !== undefined) expectedKeys.push('now');
|
||||
if (options?.transportReady !== undefined)
|
||||
expectedKeys.push('transportReady');
|
||||
if (
|
||||
!exact(options, expectedKeys) ||
|
||||
typeof options.repository?.beginExecution !== 'function' ||
|
||||
typeof options.repository?.complete !== 'function' ||
|
||||
typeof options.credentials?.resolveModelProviderCredentialBinding !==
|
||||
'function' ||
|
||||
typeof options.credentials?.record !== 'function' ||
|
||||
typeof options.secrets?.resolveProjectSecretMaterial !== 'function' ||
|
||||
(options.fetch !== undefined && typeof options.fetch !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.monotonicNow !== undefined &&
|
||||
typeof options.monotonicNow !== 'function') ||
|
||||
(options.transportReady !== undefined &&
|
||||
typeof options.transportReady !== 'function')
|
||||
) {
|
||||
throw new ModelProviderCredentialTestExecutorConfigurationError();
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const monotonicNow = options.monotonicNow ?? (() => performance.now());
|
||||
const credentials = new BoundModelProviderCredentialProvider({
|
||||
bindings: options.credentials,
|
||||
secrets: options.secrets,
|
||||
audit: options.credentials,
|
||||
now,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
async execute(input: Readonly<ExecuteModelProviderCredentialTestInput>) {
|
||||
if (!exact(input, ['allowlist', 'executionId', 'testId'])) {
|
||||
throw new ModelProviderCredentialTestExecutorConfigurationError();
|
||||
}
|
||||
const begun = await options.repository.beginExecution(input);
|
||||
if (begun.status === 'existing') {
|
||||
return begun.result === null
|
||||
? Object.freeze({
|
||||
status: 'outcome_unknown' as const,
|
||||
plan: begun.plan,
|
||||
execution: begun.execution,
|
||||
result: null,
|
||||
})
|
||||
: Object.freeze({
|
||||
status: 'existing' as const,
|
||||
plan: begun.plan,
|
||||
execution: begun.execution,
|
||||
result: begun.result,
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = monotonicNow();
|
||||
if (!Number.isFinite(startedAt) || startedAt < 0) {
|
||||
throw new ModelProviderCredentialTestExecutorUnavailableError();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
begun.plan.endpoint.deadlineMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
let outcome: 'reachable' | 'unreachable' = 'unreachable';
|
||||
let modelCount: number | null = null;
|
||||
try {
|
||||
await options.transportReady?.(
|
||||
begun.plan.endpoint.baseUrl,
|
||||
controller.signal,
|
||||
);
|
||||
const provider = new OpenAiCompatibleProvider({
|
||||
type: begun.plan.provider,
|
||||
baseUrl: begun.plan.endpoint.baseUrl,
|
||||
credentials,
|
||||
maxResponseBytes: begun.plan.endpoint.maxResponseBytes,
|
||||
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
||||
});
|
||||
const models = await provider.listModels({
|
||||
projectId: begun.plan.projectId,
|
||||
requestId: begun.execution.executionId,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
models.length <= begun.plan.endpoint.maxModels &&
|
||||
elapsedMs(startedAt, monotonicNow) <= begun.plan.endpoint.deadlineMs
|
||||
) {
|
||||
outcome = 'reachable';
|
||||
modelCount = models.length;
|
||||
}
|
||||
} catch {
|
||||
outcome = 'unreachable';
|
||||
modelCount = null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const durationMs = Math.min(
|
||||
begun.plan.endpoint.deadlineMs,
|
||||
elapsedMs(startedAt, monotonicNow),
|
||||
);
|
||||
const result = createModelProviderCredentialTestResult({
|
||||
executionId: begun.execution.executionId,
|
||||
testId: begun.plan.testId,
|
||||
planDigest: begun.plan.planDigest,
|
||||
outcome,
|
||||
modelCount,
|
||||
durationMs,
|
||||
completedAtMs: currentTime(now),
|
||||
});
|
||||
try {
|
||||
await completeExactly(options.repository, result);
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestExecutorUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'completed' as const,
|
||||
plan: begun.plan,
|
||||
execution: begun.execution,
|
||||
result,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { runModelProviderCredentialTestExecutorProcess } from './modelProviderCredentialTestExecutorProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-provider-credential-test-execute';
|
||||
const TERMINATION_MESSAGE_FILE = '/dev/termination-log';
|
||||
|
||||
function writeFact(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
stream: NodeJS.WriteStream,
|
||||
): void {
|
||||
const serialized = JSON.stringify(value);
|
||||
stream.write(`${serialized}\n`);
|
||||
if (
|
||||
process.env.QL3_MODEL_PROVIDER_CREDENTIAL_TEST_WRITE_TERMINATION_MESSAGE ===
|
||||
'true'
|
||||
) {
|
||||
writeFileSync(TERMINATION_MESSAGE_FILE, serialized, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-test-executor',
|
||||
event: 'execution_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTOR_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runModelProviderCredentialTestExecutorProcess({
|
||||
environment: process.env,
|
||||
});
|
||||
writeFact(
|
||||
result.status === 'disabled'
|
||||
? {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-test-executor',
|
||||
event: 'execution_disabled',
|
||||
}
|
||||
: {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-model-provider-credential-test-executor',
|
||||
event:
|
||||
result.test.status === 'outcome_unknown'
|
||||
? 'execution_outcome_unknown'
|
||||
: 'execution_completed',
|
||||
testId: result.test.plan.testId,
|
||||
executionId: result.test.execution.executionId,
|
||||
status: result.test.status,
|
||||
...(result.test.result === null
|
||||
? {}
|
||||
: {
|
||||
outcome: result.test.result.outcome,
|
||||
modelCount: result.test.result.modelCount,
|
||||
durationMs: result.test.result.durationMs,
|
||||
...(result.transportFailureCode === undefined
|
||||
? {}
|
||||
: {
|
||||
transportFailureCode: result.transportFailureCode,
|
||||
transportRequestDigest: result.transportRequestDigest,
|
||||
...(result.transportAddressSha256 === undefined
|
||||
? {}
|
||||
: {
|
||||
transportAddressSha256:
|
||||
result.transportAddressSha256,
|
||||
transportPort: result.transportPort,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
process.stdout,
|
||||
);
|
||||
} catch (error) {
|
||||
writeFact(failureFact(error), process.stderr);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+694
@@ -0,0 +1,694 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createConnection } from 'node:net';
|
||||
import { normalize, parse } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import {
|
||||
normalizeModelProviderCredentialTestAllowlist,
|
||||
type ModelProviderCredentialTestAllowlist,
|
||||
} from '@qinglong/ai/model-provider-credential-test-connection';
|
||||
import {
|
||||
PostgresModelProviderCredentialTestExecutionRepository,
|
||||
assertPostgresModelProviderCredentialTesterReady,
|
||||
type PostgresModelProviderCredentialTesterReadinessReport,
|
||||
} from '@qinglong/ai/postgres-model-provider-credential-test-connection';
|
||||
import { PostgresModelProviderCredentialReader } from '@qinglong/ai/postgres-model-provider-credential-storage';
|
||||
import {
|
||||
createProjectedModelProviderSecretMaterialProvider,
|
||||
type ProjectedModelProviderSecretMaterialProvider,
|
||||
} from '@qinglong/ai/projected-model-provider-secret-material';
|
||||
import type { OpenPostgresDatabase } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/ai-credential-tester';
|
||||
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
readManagementTlsFile,
|
||||
} from '../management-support/managementProcessSupport';
|
||||
import {
|
||||
createModelProviderCredentialTestExecutor,
|
||||
type ExecuteModelProviderCredentialTestResult,
|
||||
type ModelProviderCredentialTestExecutor,
|
||||
} from './modelProviderCredentialTestExecutor';
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
const SAFE_TRANSPORT_FAILURE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const MAX_COMMAND_BYTES = 4 * 1_024;
|
||||
const MAX_ALLOWLIST_BYTES = 64 * 1_024;
|
||||
|
||||
export interface ModelProviderCredentialTestExecutorCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly executionId: string;
|
||||
readonly testId: string;
|
||||
}
|
||||
|
||||
export type ModelProviderCredentialTestExecutorProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ModelProviderCredentialTestExecutorProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
commandFile: string;
|
||||
allowlistFile: string;
|
||||
secretRootDirectory: string;
|
||||
networkPolicyDenyCanary?: Readonly<{
|
||||
host: string;
|
||||
port: number;
|
||||
}>;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ModelProviderCredentialTestExecutorProcessResult =
|
||||
| Readonly<{ status: 'disabled' }>
|
||||
| Readonly<{
|
||||
status: 'completed';
|
||||
database: PostgresModelProviderCredentialTesterReadinessReport;
|
||||
test: Readonly<ExecuteModelProviderCredentialTestResult>;
|
||||
readonly transportFailureCode?: string;
|
||||
readonly transportRequestDigest?: string;
|
||||
readonly transportAddressSha256?: string;
|
||||
readonly transportPort?: number;
|
||||
}>;
|
||||
|
||||
export interface RunModelProviderCredentialTestExecutorProcessOptions {
|
||||
readonly environment: ModelProviderCredentialTestExecutorProcessEnvironment;
|
||||
readonly command?: Readonly<ModelProviderCredentialTestExecutorCommand>;
|
||||
readonly allowlist?: Readonly<ModelProviderCredentialTestAllowlist>;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly assertReady?: typeof assertPostgresModelProviderCredentialTesterReady;
|
||||
readonly secrets?: Readonly<ProjectedModelProviderSecretMaterialProvider>;
|
||||
readonly executor?: Readonly<ModelProviderCredentialTestExecutor>;
|
||||
readonly fetch?: typeof globalThis.fetch;
|
||||
readonly now?: () => number;
|
||||
readonly monotonicNow?: () => number;
|
||||
readonly transportReady?: (
|
||||
baseUrl: string,
|
||||
signal: AbortSignal,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestExecutorProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTOR_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Model provider credential test executor config is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ModelProviderCredentialTestExecutorProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function configFailure(
|
||||
message: string,
|
||||
): ModelProviderCredentialTestExecutorProcessConfigError {
|
||||
return new ModelProviderCredentialTestExecutorProcessConfigError(message);
|
||||
}
|
||||
|
||||
function transportFailureCode(error: unknown): string {
|
||||
let candidate: unknown = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
if (!candidate || typeof candidate !== 'object') break;
|
||||
const value = candidate as {
|
||||
readonly cause?: unknown;
|
||||
readonly code?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof value.code === 'string' &&
|
||||
SAFE_TRANSPORT_FAILURE_CODE.test(value.code)
|
||||
) {
|
||||
return value.code;
|
||||
}
|
||||
const message =
|
||||
typeof value.message === 'string' ? value.message.toLowerCase() : '';
|
||||
if (
|
||||
message.includes('certificate') ||
|
||||
message.includes('self signed') ||
|
||||
message.includes('unable to verify')
|
||||
) {
|
||||
return 'TLS_VALIDATION_FAILED';
|
||||
}
|
||||
if (message.includes('getaddrinfo')) return 'DNS_LOOKUP_FAILED';
|
||||
if (message.includes('bad port')) return 'PORT_REJECTED';
|
||||
candidate = value.cause;
|
||||
}
|
||||
return 'TRANSPORT_FAILED';
|
||||
}
|
||||
|
||||
function transportRequestDigest(
|
||||
input: Parameters<typeof globalThis.fetch>[0],
|
||||
init?: Parameters<typeof globalThis.fetch>[1],
|
||||
): string {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request?.url ?? String(input);
|
||||
const method = init?.method ?? request?.method ?? 'GET';
|
||||
return `sha256:${createHash('sha256')
|
||||
.update(method.toUpperCase(), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(url, 'utf8')
|
||||
.digest('hex')}`;
|
||||
}
|
||||
|
||||
function transportFailureEndpoint(
|
||||
error: unknown,
|
||||
): Readonly<{ addressSha256: string; port: number }> | undefined {
|
||||
let candidate: unknown = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
if (!candidate || typeof candidate !== 'object') break;
|
||||
const value = candidate as {
|
||||
readonly address?: unknown;
|
||||
readonly cause?: unknown;
|
||||
readonly port?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof value.address === 'string' &&
|
||||
value.address.length >= 1 &&
|
||||
value.address.length <= 128 &&
|
||||
Number.isInteger(value.port) &&
|
||||
(value.port as number) >= 1 &&
|
||||
(value.port as number) <= 65_535
|
||||
) {
|
||||
return Object.freeze({
|
||||
addressSha256: `sha256:${createHash('sha256')
|
||||
.update(value.address, 'utf8')
|
||||
.digest('hex')}`,
|
||||
port: value.port as number,
|
||||
});
|
||||
}
|
||||
candidate = value.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function connectOnce(
|
||||
host: string,
|
||||
port: number,
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const socket = createConnection({ host, port });
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const onAbort = (): void =>
|
||||
finish(
|
||||
signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error('transport readiness aborted'),
|
||||
);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once('connect', () => finish());
|
||||
socket.once('timeout', () => finish(new Error('transport timeout')));
|
||||
socket.once('error', (error) => finish(error));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTransportReady(
|
||||
baseUrl: string,
|
||||
signal: AbortSignal,
|
||||
denyCanary?: Readonly<{ host: string; port: number }>,
|
||||
): Promise<void> {
|
||||
const endpoint = new URL(baseUrl);
|
||||
const hostname = endpoint.hostname.replace(/^\[|\]$/g, '');
|
||||
const port = endpoint.port
|
||||
? Number(endpoint.port)
|
||||
: endpoint.protocol === 'https:'
|
||||
? 443
|
||||
: 80;
|
||||
while (true) {
|
||||
if (signal.aborted) throw signal.reason;
|
||||
try {
|
||||
await connectOnce(hostname, port, signal, 500);
|
||||
if (denyCanary !== undefined) {
|
||||
let denied = false;
|
||||
try {
|
||||
await connectOnce(denyCanary.host, denyCanary.port, signal, 150);
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw signal.reason ?? error;
|
||||
denied = true;
|
||||
}
|
||||
if (!denied) {
|
||||
await delay(50, undefined, { signal });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw signal.reason ?? error;
|
||||
await delay(50, undefined, { signal });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ModelProviderCredentialTestExecutorProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ModelProviderCredentialTestExecutorProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ModelProviderCredentialTestExecutorProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function absolutePath(
|
||||
environment: ModelProviderCredentialTestExecutorProcessEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
const value = absoluteManagementEnvironmentFile(
|
||||
environment,
|
||||
name,
|
||||
configFailure,
|
||||
);
|
||||
if (normalize(value) !== value || parse(value).root === value) {
|
||||
throw configFailure(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ModelProviderCredentialTestExecutorProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_AI_CREDENTIAL_TESTER_URL',
|
||||
host: 'QL3_POSTGRES_AI_CREDENTIAL_TESTER_HOST',
|
||||
port: 'QL3_POSTGRES_AI_CREDENTIAL_TESTER_PORT',
|
||||
database: 'QL3_POSTGRES_AI_CREDENTIAL_TESTER_DATABASE',
|
||||
user: 'QL3_POSTGRES_AI_CREDENTIAL_TESTER_USER',
|
||||
password: 'QL3_POSTGRES_AI_CREDENTIAL_TESTER_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw configFailure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL AI credential tester connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_AI_CREDENTIAL_TESTER_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw configFailure('PostgreSQL TLS mode must be verify-full or disable');
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_TESTER_ALLOW_INSECURE',
|
||||
)
|
||||
) {
|
||||
throw configFailure('disabling PostgreSQL TLS requires explicit opt-in');
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_TESTER_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw configFailure(
|
||||
'PostgreSQL TLS servername must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_TESTER_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw configFailure('PostgreSQL CA file cannot be used with disabled TLS');
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw configFailure('PostgreSQL CA file is invalid');
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_TESTER_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-ai-credential-tester';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw configFailure('PostgreSQL application name is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
}),
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_TESTER_POOL_MAX',
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
),
|
||||
idleTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_TESTER_IDLE_TIMEOUT_MS',
|
||||
1_000,
|
||||
100,
|
||||
10_000,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_AI_CREDENTIAL_TESTER_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function loadNetworkPolicyDenyCanary(
|
||||
environment: ModelProviderCredentialTestExecutorProcessEnvironment,
|
||||
): Readonly<{ host: string; port: number }> | undefined {
|
||||
const host = boundedValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_DENY_CANARY_HOST',
|
||||
253,
|
||||
);
|
||||
const portValue = boundedValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_DENY_CANARY_PORT',
|
||||
5,
|
||||
);
|
||||
if (host === undefined && portValue === undefined) return undefined;
|
||||
if (
|
||||
!isPostgresTlsDnsServername(host) ||
|
||||
portValue === undefined ||
|
||||
!/^[1-9][0-9]{0,4}$/.test(portValue)
|
||||
) {
|
||||
throw configFailure('network policy deny canary is invalid');
|
||||
}
|
||||
const port = Number(portValue);
|
||||
if (port > 65_535) {
|
||||
throw configFailure('network policy deny canary is invalid');
|
||||
}
|
||||
return Object.freeze({ host, port });
|
||||
}
|
||||
|
||||
export function loadModelProviderCredentialTestExecutorProcessConfig(
|
||||
environment: ModelProviderCredentialTestExecutorProcessEnvironment,
|
||||
): Readonly<ModelProviderCredentialTestExecutorProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw configFailure('environment is invalid');
|
||||
}
|
||||
if (
|
||||
!booleanValue(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTOR_ENABLED',
|
||||
)
|
||||
) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw configFailure('QL3_PROFILE must be cluster-admin');
|
||||
}
|
||||
const networkPolicyDenyCanary = loadNetworkPolicyDenyCanary(environment);
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
commandFile: absolutePath(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_COMMAND_FILE',
|
||||
),
|
||||
allowlistFile: absolutePath(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_FILE',
|
||||
),
|
||||
secretRootDirectory: absolutePath(
|
||||
environment,
|
||||
'QL3_MODEL_PROVIDER_CREDENTIAL_TEST_SECRET_ROOT',
|
||||
),
|
||||
...(networkPolicyDenyCanary === undefined
|
||||
? {}
|
||||
: { networkPolicyDenyCanary }),
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function exact(value: unknown, keys: readonly string[]): boolean {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return (
|
||||
actual.length === expected.length &&
|
||||
actual.every((key, index) => key === expected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<ModelProviderCredentialTestExecutorCommand> {
|
||||
if (
|
||||
!exact(value, ['executionId', 'schemaVersion', 'testId']) ||
|
||||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
|
||||
typeof (value as { executionId?: unknown }).executionId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test((value as { executionId: string }).executionId) ||
|
||||
typeof (value as { testId?: unknown }).testId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test((value as { testId: string }).testId)
|
||||
) {
|
||||
throw configFailure('command is invalid');
|
||||
}
|
||||
const command = value as ModelProviderCredentialTestExecutorCommand;
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
executionId: command.executionId,
|
||||
testId: command.testId,
|
||||
});
|
||||
}
|
||||
|
||||
function readJson(filePath: string, maximumBytes: number): unknown {
|
||||
const bytes = readManagementTlsFile(filePath, false, configFailure);
|
||||
try {
|
||||
if (bytes.length > maximumBytes)
|
||||
throw configFailure('authority file is too large');
|
||||
return JSON.parse(new TextDecoder('utf8', { fatal: true }).decode(bytes));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ModelProviderCredentialTestExecutorProcessConfigError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configFailure('authority file is invalid');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function readCommand(
|
||||
filePath: string,
|
||||
): Readonly<ModelProviderCredentialTestExecutorCommand> {
|
||||
return normalizeCommand(readJson(filePath, MAX_COMMAND_BYTES));
|
||||
}
|
||||
|
||||
function readAllowlist(
|
||||
filePath: string,
|
||||
): Readonly<ModelProviderCredentialTestAllowlist> {
|
||||
try {
|
||||
return normalizeModelProviderCredentialTestAllowlist(
|
||||
readJson(
|
||||
filePath,
|
||||
MAX_ALLOWLIST_BYTES,
|
||||
) as ModelProviderCredentialTestAllowlist,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ModelProviderCredentialTestExecutorProcessConfigError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configFailure('allowlist is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runModelProviderCredentialTestExecutorProcess(
|
||||
options: RunModelProviderCredentialTestExecutorProcessOptions,
|
||||
): Promise<Readonly<ModelProviderCredentialTestExecutorProcessResult>> {
|
||||
const expectedKeys = ['environment'];
|
||||
for (const key of [
|
||||
'allowlist',
|
||||
'assertReady',
|
||||
'command',
|
||||
'executor',
|
||||
'fetch',
|
||||
'monotonicNow',
|
||||
'now',
|
||||
'openDatabase',
|
||||
'secrets',
|
||||
'transportReady',
|
||||
]) {
|
||||
if (
|
||||
(options as unknown as Record<string, unknown> | undefined)?.[key] !==
|
||||
undefined
|
||||
) {
|
||||
expectedKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (!exact(options, expectedKeys)) throw configFailure('options are invalid');
|
||||
const config = loadModelProviderCredentialTestExecutorProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) return Object.freeze({ status: 'disabled' as const });
|
||||
const command = options.command
|
||||
? normalizeCommand(options.command)
|
||||
: readCommand(config.commandFile);
|
||||
const allowlist = options.allowlist
|
||||
? normalizeModelProviderCredentialTestAllowlist(options.allowlist)
|
||||
: readAllowlist(config.allowlistFile);
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'ai-credential-tester',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError() {
|
||||
// In-flight queries own one-shot availability; there is no listener to withdraw.
|
||||
},
|
||||
});
|
||||
const database = await openDatabase();
|
||||
try {
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresModelProviderCredentialTesterReady
|
||||
)(database.pool);
|
||||
const secrets =
|
||||
options.secrets ??
|
||||
(await createProjectedModelProviderSecretMaterialProvider({
|
||||
rootDirectory: config.secretRootDirectory,
|
||||
}));
|
||||
let observedTransportFailureCode: string | undefined;
|
||||
let observedTransportRequestDigest: string | undefined;
|
||||
let observedTransportEndpoint:
|
||||
| Readonly<{ addressSha256: string; port: number }>
|
||||
| undefined;
|
||||
const configuredFetch = options.fetch ?? globalThis.fetch;
|
||||
const observedFetch: typeof globalThis.fetch = async (input, init) => {
|
||||
const requestDigest = transportRequestDigest(input, init);
|
||||
try {
|
||||
return await configuredFetch(input, init);
|
||||
} catch (error) {
|
||||
observedTransportFailureCode = transportFailureCode(error);
|
||||
observedTransportRequestDigest = requestDigest;
|
||||
observedTransportEndpoint = transportFailureEndpoint(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const executor =
|
||||
options.executor ??
|
||||
createModelProviderCredentialTestExecutor({
|
||||
repository: new PostgresModelProviderCredentialTestExecutionRepository(
|
||||
database.pool,
|
||||
),
|
||||
credentials: new PostgresModelProviderCredentialReader(database.pool),
|
||||
secrets,
|
||||
fetch: observedFetch,
|
||||
transportReady:
|
||||
options.transportReady ??
|
||||
((baseUrl, signal) =>
|
||||
waitForTransportReady(
|
||||
baseUrl,
|
||||
signal,
|
||||
config.networkPolicyDenyCanary,
|
||||
)),
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.monotonicNow === undefined
|
||||
? {}
|
||||
: { monotonicNow: options.monotonicNow }),
|
||||
});
|
||||
const result = await executor.execute({
|
||||
executionId: command.executionId,
|
||||
testId: command.testId,
|
||||
allowlist,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'completed' as const,
|
||||
database: evidence,
|
||||
test: result,
|
||||
...(observedTransportFailureCode === undefined
|
||||
? {}
|
||||
: {
|
||||
transportFailureCode: observedTransportFailureCode,
|
||||
transportRequestDigest: observedTransportRequestDigest!,
|
||||
...(observedTransportEndpoint === undefined
|
||||
? {}
|
||||
: {
|
||||
transportAddressSha256:
|
||||
observedTransportEndpoint.addressSha256,
|
||||
transportPort: observedTransportEndpoint.port,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { migratePostgresModelInvocationFeature } from '@qinglong/ai/model-invocation-migration';
|
||||
import { runPostgresMigrationProcess } from '@qinglong/cluster-postgres/migration-process';
|
||||
|
||||
const USAGE = 'Usage: ql3-ai-feature-migrate';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-ai-feature-migration',
|
||||
event: 'migration_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_AI_FEATURE_MIGRATION_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runPostgresMigrationProcess({
|
||||
environment: process.env,
|
||||
migrate: ({ pool }) => migratePostgresModelInvocationFeature(pool),
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-ai-feature-migration',
|
||||
event: 'migration_completed',
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,83 @@
|
||||
// Cluster Plugin Package executor boundary; keep approved-action dispatch explicit.
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
ApprovedActionDispatcher,
|
||||
type ApprovedActionDispatcherOptions,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import { PluginPackageApprovedActionHandler } from '@qinglong/runtime-core/plugin-package-approved-action';
|
||||
import { PostgresApprovedActionExecutionRepository } from '@qinglong/cluster-postgres/approved-action-execution';
|
||||
import { PostgresPluginPackageInstallRepository } from '@qinglong/cluster-postgres/plugin-package-install';
|
||||
import { PostgresPluginPackageInstallProposalRepository } from '@qinglong/cluster-postgres/plugin-package-proposal';
|
||||
import {
|
||||
PostgresPluginPackagePublisherRevocationProposalRepository,
|
||||
PostgresPluginPackagePublisherTrustTransitionProposalRepository,
|
||||
PostgresPluginPackagePublisherTrustTransitionRepository,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
ClusterPluginPackagePublisherRevocationApprovedActionHandler,
|
||||
type ClusterPluginPackagePublisherRevocationExecutionPort,
|
||||
} from '../publisher/pluginPackagePublisherRevocationApprovedAction';
|
||||
import {
|
||||
ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler,
|
||||
type ClusterPluginPackagePublisherTrustTransitionExecutionPort,
|
||||
} from '../publisher/pluginPackagePublisherTrustTransitionApprovedAction';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMIT = 16;
|
||||
|
||||
export interface ClusterPluginPackageApprovedActionDispatcherOptions
|
||||
extends Omit<ApprovedActionDispatcherOptions, 'defaultBatchSize'> {
|
||||
readonly pool: PostgresPool;
|
||||
readonly defaultBatchSize?: number;
|
||||
readonly publisherRevocations?: ClusterPluginPackagePublisherRevocationExecutionPort;
|
||||
readonly publisherTrustTransitions?: ClusterPluginPackagePublisherTrustTransitionExecutionPort;
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageApprovedActionDispatcher(
|
||||
options: ClusterPluginPackageApprovedActionDispatcherOptions,
|
||||
): ApprovedActionDispatcher {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('cluster Package Approved Action options are invalid');
|
||||
}
|
||||
const {
|
||||
pool,
|
||||
defaultBatchSize,
|
||||
publisherRevocations,
|
||||
publisherTrustTransitions,
|
||||
...dispatcherOptions
|
||||
} = options;
|
||||
const executions = new PostgresApprovedActionExecutionRepository(pool);
|
||||
const handler = new PluginPackageApprovedActionHandler(
|
||||
new PostgresPluginPackageInstallProposalRepository(pool),
|
||||
new PostgresPluginPackageInstallRepository(pool),
|
||||
);
|
||||
const handlers = [
|
||||
handler,
|
||||
...(['overlap_add', 'safe_retire'] as const).map(
|
||||
(mode) =>
|
||||
new ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler(
|
||||
mode,
|
||||
new PostgresPluginPackagePublisherTrustTransitionProposalRepository(
|
||||
pool,
|
||||
),
|
||||
publisherTrustTransitions ??
|
||||
new PostgresPluginPackagePublisherTrustTransitionRepository(pool),
|
||||
),
|
||||
),
|
||||
...(publisherRevocations
|
||||
? [
|
||||
new ClusterPluginPackagePublisherRevocationApprovedActionHandler(
|
||||
new PostgresPluginPackagePublisherRevocationProposalRepository(
|
||||
pool,
|
||||
),
|
||||
publisherRevocations,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return new ApprovedActionDispatcher(executions, handlers, {
|
||||
...dispatcherOptions,
|
||||
defaultBatchSize:
|
||||
defaultBatchSize ?? CLUSTER_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMIT,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Cluster Plugin Package executor boundary; keep the operational CLI explicit.
|
||||
|
||||
import { runClusterPluginPackageExecutorProcess } from './pluginPackageExecutorProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-plugin-package-execute';
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PLUGIN_PACKAGE_EXECUTOR_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runClusterPluginPackageExecutorProcess({
|
||||
environment: process.env,
|
||||
});
|
||||
if (result.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-executor',
|
||||
event: 'executor_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-executor',
|
||||
event: 'executor_completed',
|
||||
databaseContractVersion: result.database.contractVersion,
|
||||
databaseMigrationCount: result.database.migrationIds.length,
|
||||
batches: result.batches,
|
||||
});
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-executor',
|
||||
event: 'executor_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string'
|
||||
? candidate.name.slice(0, 128)
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string'
|
||||
? { code: candidate.code.slice(0, 128) }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
// Cluster Plugin Package executor boundary; keep process composition explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type {
|
||||
ApprovedActionDispatchBatchSummary,
|
||||
ApprovedActionDispatcher,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
createClusterPluginPackageApprovedActionDispatcher,
|
||||
type ClusterPluginPackageApprovedActionDispatcherOptions,
|
||||
} from './pluginPackageApprovedAction';
|
||||
import {
|
||||
consumeClusterPluginPackagePublisherRevocationApprovals,
|
||||
type ClusterPluginPackagePublisherRevocationApprovalSummary,
|
||||
type ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions,
|
||||
} from '../publisher/pluginPackagePublisherRevocationApprovalConsumer';
|
||||
import {
|
||||
consumeClusterPluginPackagePublisherTrustTransitionApprovals,
|
||||
type ClusterPluginPackagePublisherTrustTransitionApprovalSummary,
|
||||
type ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions,
|
||||
} from '../publisher/pluginPackagePublisherTrustTransitionApprovalConsumer';
|
||||
import {
|
||||
runClusterPluginPackagePublisherRevocation,
|
||||
} from '../publisher/pluginPackagePublisherRevocation';
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
owner: string;
|
||||
approvalBatchSize: number;
|
||||
dispatchBatchSize: number;
|
||||
maxBatches: number;
|
||||
leaseDurationMs: number;
|
||||
revocationPageSize: number;
|
||||
revocationMaxPages: number;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export interface ClusterPluginPackageExecutorBatchResult {
|
||||
readonly approvals: Readonly<ClusterPluginPackagePublisherRevocationApprovalSummary>;
|
||||
readonly trustTransitionApprovals: Readonly<ClusterPluginPackagePublisherTrustTransitionApprovalSummary>;
|
||||
readonly dispatch: Readonly<ApprovedActionDispatchBatchSummary>;
|
||||
}
|
||||
|
||||
export type ClusterPluginPackageExecutorProcessResult =
|
||||
| Readonly<{ status: 'disabled' }>
|
||||
| Readonly<{
|
||||
status: 'completed';
|
||||
database: PostgresSchemaReadinessReport;
|
||||
batches: readonly Readonly<ClusterPluginPackageExecutorBatchResult>[];
|
||||
}>;
|
||||
|
||||
export interface RunClusterPluginPackageExecutorProcessOptions {
|
||||
readonly environment: ClusterPluginPackageExecutorProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly consumeApprovals?: (
|
||||
options: ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions,
|
||||
) => Promise<
|
||||
Readonly<ClusterPluginPackagePublisherRevocationApprovalSummary>
|
||||
>;
|
||||
readonly consumeTrustTransitionApprovals?: (
|
||||
options: ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions,
|
||||
) => Promise<
|
||||
Readonly<ClusterPluginPackagePublisherTrustTransitionApprovalSummary>
|
||||
>;
|
||||
readonly createDispatcher?: (
|
||||
options: ClusterPluginPackageApprovedActionDispatcherOptions,
|
||||
) => ApprovedActionDispatcher;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageExecutorProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_EXECUTOR_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Plugin Package executor process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageExecutorProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_OWNER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function enabledValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): boolean {
|
||||
const value = environment.QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED;
|
||||
if (value === undefined || value === '' || value === 'false') return false;
|
||||
if (value === 'true') return true;
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED must be true or false',
|
||||
);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return undefined;
|
||||
if (value.length > maximumLength || /[\0\r\n]/.test(value)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
name: string,
|
||||
defaultValue: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} must be an integer`,
|
||||
);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function databaseConfig(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL',
|
||||
host: 'QL3_POSTGRES_PACKAGE_EXECUTOR_HOST',
|
||||
port: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT',
|
||||
database: 'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE',
|
||||
user: 'QL3_POSTGRES_PACKAGE_EXECUTOR_USER',
|
||||
password: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Package executor connection is invalid',
|
||||
);
|
||||
}
|
||||
const tlsMode = environment.QL3_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
let tls: PostgresConnectionOptions['tls'];
|
||||
if (tlsMode === 'disable') {
|
||||
if (environment.QL3_POSTGRES_ALLOW_INSECURE !== 'true') {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'disabling PostgreSQL TLS requires QL3_POSTGRES_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
tls = Object.freeze({ mode: 'disable' });
|
||||
} else if (tlsMode === 'verify-full') {
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (!isPostgresTlsDnsServername(servername)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_SERVERNAME must be an explicit DNS name for verify-full',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_CA_FILE',
|
||||
4096,
|
||||
);
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE must contain a bounded trusted CA bundle',
|
||||
);
|
||||
}
|
||||
}
|
||||
tls = Object.freeze({
|
||||
mode: 'verify-full',
|
||||
servername,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
});
|
||||
} else {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(environment, 'QL3_POSTGRES_APPLICATION_NAME', 63) ??
|
||||
'qinglong3-plugin-package-executor';
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(applicationName)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_POSTGRES_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({ ...connection, tls }),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_MAX_CONNECTIONS',
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
idleTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_IDLE_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
300_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageExecutorProcessConfig(
|
||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||
): ClusterPluginPackageExecutorProcessConfig {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'environment is required',
|
||||
);
|
||||
}
|
||||
if (!enabledValue(environment)) return Object.freeze({ enabled: false });
|
||||
const owner =
|
||||
boundedValue(environment, 'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER', 128) ??
|
||||
'cluster_package_executor_1';
|
||||
if (!SAFE_OWNER.test(owner)) {
|
||||
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
owner,
|
||||
approvalBatchSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_APPROVAL_BATCH_SIZE',
|
||||
8,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
dispatchBatchSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_BATCH_SIZE',
|
||||
8,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
maxBatches: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_MAX_BATCHES',
|
||||
4,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
leaseDurationMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS',
|
||||
600_000,
|
||||
1,
|
||||
600_000,
|
||||
),
|
||||
revocationPageSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE',
|
||||
16,
|
||||
1,
|
||||
128,
|
||||
),
|
||||
revocationMaxPages: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES',
|
||||
16,
|
||||
1,
|
||||
64,
|
||||
),
|
||||
database: databaseConfig(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function borrowedDatabase(
|
||||
database: PostgresDatabaseResource,
|
||||
): OpenPostgresDatabase {
|
||||
return async () =>
|
||||
Object.freeze({
|
||||
pool: database.pool,
|
||||
close: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function isIdleBatch(
|
||||
batch: Readonly<ClusterPluginPackageExecutorBatchResult>,
|
||||
): boolean {
|
||||
return (
|
||||
batch.approvals.scanned === 0 &&
|
||||
batch.trustTransitionApprovals.scanned === 0 &&
|
||||
batch.dispatch.scanned === 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageExecutorProcess(
|
||||
options: RunClusterPluginPackageExecutorProcessOptions,
|
||||
): Promise<ClusterPluginPackageExecutorProcessResult> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!options.environment ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.consumeApprovals !== undefined &&
|
||||
typeof options.consumeApprovals !== 'function') ||
|
||||
(options.consumeTrustTransitionApprovals !== undefined &&
|
||||
typeof options.consumeTrustTransitionApprovals !== 'function') ||
|
||||
(options.createDispatcher !== undefined &&
|
||||
typeof options.createDispatcher !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Plugin Package executor process options are invalid');
|
||||
}
|
||||
const config = loadClusterPluginPackageExecutorProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) return Object.freeze({ status: 'disabled' });
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'package-executor',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError: () => undefined,
|
||||
});
|
||||
const database = await openDatabase();
|
||||
let failure: unknown;
|
||||
try {
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const dispatcherFactory =
|
||||
options.createDispatcher ??
|
||||
createClusterPluginPackageApprovedActionDispatcher;
|
||||
const consumeApprovals =
|
||||
options.consumeApprovals ??
|
||||
consumeClusterPluginPackagePublisherRevocationApprovals;
|
||||
const consumeTrustTransitionApprovals =
|
||||
options.consumeTrustTransitionApprovals ??
|
||||
consumeClusterPluginPackagePublisherTrustTransitionApprovals;
|
||||
const dispatcher = dispatcherFactory({
|
||||
pool: database.pool,
|
||||
owner: config.owner,
|
||||
leaseDurationMs: config.leaseDurationMs,
|
||||
defaultBatchSize: config.dispatchBatchSize,
|
||||
...(options.now ? { clock: options.now } : {}),
|
||||
publisherRevocations: {
|
||||
async run(receipt) {
|
||||
const result = await runClusterPluginPackagePublisherRevocation({
|
||||
openDatabase: borrowedDatabase(database),
|
||||
receipt,
|
||||
// Durable proposal, dispatch, Project Policy fence and trust-head
|
||||
// generation are revalidated in the same SERIALIZABLE mutation.
|
||||
confirmAuthorization: () => undefined,
|
||||
pageSize: config.revocationPageSize,
|
||||
maxPages: config.revocationMaxPages,
|
||||
});
|
||||
return Object.freeze({
|
||||
safeToAdmit: result.safeToAdmit,
|
||||
receiptDigest: result.receiptDigest,
|
||||
impactDigest: result.impactDigest,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const batches: Readonly<ClusterPluginPackageExecutorBatchResult>[] = [];
|
||||
for (let index = 0; index < config.maxBatches; index += 1) {
|
||||
const approvals = await consumeApprovals({
|
||||
pool: database.pool,
|
||||
limit: config.approvalBatchSize,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
const trustTransitionApprovals =
|
||||
await consumeTrustTransitionApprovals({
|
||||
pool: database.pool,
|
||||
limit: config.approvalBatchSize,
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
const dispatch = await dispatcher.dispatchBatch({
|
||||
limit: config.dispatchBatchSize,
|
||||
});
|
||||
const batch = Object.freeze({
|
||||
approvals,
|
||||
trustTransitionApprovals,
|
||||
dispatch,
|
||||
});
|
||||
batches.push(batch);
|
||||
if (isIdleBatch(batch)) break;
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'completed',
|
||||
database: evidence,
|
||||
batches: Object.freeze([...batches]),
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Plugin Package executor process failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep execution authority explicit.
|
||||
import {
|
||||
PostgresPluginPackageLifecyclePlanRepository,
|
||||
PostgresPluginPackageLifecycleRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovedActionBinding,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
createPluginPackageLifecycleEvent,
|
||||
pluginPackageLifecycleActionDigest,
|
||||
PluginPackageLifecycleConflictError,
|
||||
type PluginPackageLifecycleAction,
|
||||
type PluginPackageLifecycleReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS,
|
||||
PluginPackageLifecyclePlanConflictError,
|
||||
createPluginPackageLifecyclePlan,
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
type PluginPackageLifecyclePlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CLUSTER_LIFECYCLE_CONSUMER = Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'cluster_plugin_package_lifecycle_executor',
|
||||
}),
|
||||
authenticationId: 'cluster_plugin_package_lifecycle_executor_v1',
|
||||
});
|
||||
|
||||
export interface RunClusterPluginPackageLifecyclePlanOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly actionRef: string;
|
||||
readonly action: PluginPackageLifecycleAction;
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly requestedBy: SecuritySubject;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
readonly lifetimeMs?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecyclePlanRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageLifecycleExecutionOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecycleExecutionRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly receipt: Readonly<PluginPackageLifecycleReceipt>;
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new TypeError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function databaseNowMs(pool: PostgresPool): Promise<number> {
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS "nowMs"`,
|
||||
);
|
||||
const value = result.rows[0]?.nowMs;
|
||||
const parsed =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
!Number.isSafeInteger(parsed) ||
|
||||
parsed < 0
|
||||
) {
|
||||
throw new Error('PostgreSQL lifecycle clock is unavailable');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function binding(
|
||||
plan: Readonly<PluginPackageLifecyclePlan>,
|
||||
): Readonly<ApprovedActionBinding> {
|
||||
return Object.freeze({
|
||||
permission: 'package.manage',
|
||||
actionType: `plugin_package.lifecycle.${plan.impact.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(plan.impact),
|
||||
previewDigest: plan.impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
approvalRequestId: string,
|
||||
projectId: string,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: approvalRequestId,
|
||||
operationId: 'approval.consume',
|
||||
projectId,
|
||||
subject: CLUSTER_LIFECYCLE_CONSUMER.subject,
|
||||
authenticationId: CLUSTER_LIFECYCLE_CONSUMER.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['package_lifecycle_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeDatabase(
|
||||
database: PostgresDatabaseResource | undefined,
|
||||
failure: unknown,
|
||||
): Promise<void> {
|
||||
if (!database) {
|
||||
if (failure !== undefined) throw failure;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Cluster Plugin Package lifecycle failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageLifecyclePlan(
|
||||
options: RunClusterPluginPackageLifecyclePlanOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageLifecyclePlanRun>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function' ||
|
||||
typeof options.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(options.projectId) ||
|
||||
typeof options.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(options.packageName)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle plan options are invalid',
|
||||
);
|
||||
}
|
||||
const requestedActionRef = actionRef(options.actionRef);
|
||||
const lifetimeMs =
|
||||
options.lifetimeMs ?? MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(lifetimeMs) ||
|
||||
lifetimeMs < 1_000 ||
|
||||
lifetimeMs > MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle plan lifetime is invalid',
|
||||
);
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let failure: unknown;
|
||||
let result: Readonly<ClusterPluginPackageLifecyclePlanRun> | undefined;
|
||||
try {
|
||||
await options.confirmAuthorization();
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const lifecycles = new PostgresPluginPackageLifecycleRepository(
|
||||
database.pool,
|
||||
);
|
||||
const plans = new PostgresPluginPackageLifecyclePlanRepository(
|
||||
database.pool,
|
||||
);
|
||||
const existingValue = await plans.findByActionRef(requestedActionRef);
|
||||
if (existingValue) {
|
||||
const existing = normalizePluginPackageLifecyclePlan(existingValue);
|
||||
if (
|
||||
existing.impact.action !== options.action ||
|
||||
existing.impact.target.projectId !== options.projectId ||
|
||||
existing.impact.target.packageName !== options.packageName ||
|
||||
!same(existing.requestedBy, options.requestedBy) ||
|
||||
existing.expiresAtMs - existing.plannedAtMs !== lifetimeMs
|
||||
) {
|
||||
throw new PluginPackageLifecyclePlanConflictError(
|
||||
'actionRef is bound to another lifecycle request',
|
||||
);
|
||||
}
|
||||
await options.confirmAuthorization();
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: 'existing' as const,
|
||||
plan: existing,
|
||||
});
|
||||
} else {
|
||||
const impact = await lifecycles.plan(
|
||||
options.action,
|
||||
options.projectId,
|
||||
options.packageName,
|
||||
);
|
||||
const plannedAtMs = await databaseNowMs(database.pool);
|
||||
const plan = createPluginPackageLifecyclePlan({
|
||||
actionRef: requestedActionRef,
|
||||
impact,
|
||||
requestedBy: options.requestedBy,
|
||||
plannedAtMs,
|
||||
expiresAtMs: plannedAtMs + lifetimeMs,
|
||||
});
|
||||
await options.confirmAuthorization();
|
||||
const created = await plans.create(plan);
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: created.status,
|
||||
plan: created.plan,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
await closeDatabase(database, failure);
|
||||
if (!result) {
|
||||
throw new Error('Cluster Plugin Package lifecycle plan produced no result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageLifecycleExecution(
|
||||
options: RunClusterPluginPackageLifecycleExecutionOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageLifecycleExecutionRun>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle execution options are invalid',
|
||||
);
|
||||
}
|
||||
const requestedActionRef = actionRef(options.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
options.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const consumptionId = identifier(options.consumptionId, 'consumptionId');
|
||||
const dispatchId = identifier(options.dispatchId, 'dispatchId');
|
||||
const auditEventId = identifier(options.auditEventId, 'auditEventId');
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let failure: unknown;
|
||||
let result: Readonly<ClusterPluginPackageLifecycleExecutionRun> | undefined;
|
||||
try {
|
||||
await options.confirmAuthorization();
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const plans = new PostgresPluginPackageLifecyclePlanRepository(
|
||||
database.pool,
|
||||
);
|
||||
const planValue = await plans.findByActionRef(requestedActionRef);
|
||||
if (!planValue) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'durable lifecycle plan is absent',
|
||||
);
|
||||
}
|
||||
const plan = normalizePluginPackageLifecyclePlan(planValue);
|
||||
const approvals = new PostgresApprovalRequestRepository(database.pool);
|
||||
let approvalValue = await approvals.findById(approvalRequestId);
|
||||
if (!approvalValue) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval is absent',
|
||||
);
|
||||
}
|
||||
let approval = normalizeApprovalRequestRecord(approvalValue);
|
||||
const approvedAction = binding(plan);
|
||||
if (
|
||||
approval.projectId !== plan.impact.target.projectId ||
|
||||
approval.decisionMode !== 'separation_of_duty' ||
|
||||
!same(approval.action, approvedAction) ||
|
||||
!same(approval.requestedBy, plan.requestedBy)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval does not match durable plan',
|
||||
);
|
||||
}
|
||||
let dispatch: Readonly<ApprovedActionDispatchRecord> | null = null;
|
||||
if (approval.version === 2 && approval.state === 'approved') {
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(database.pool),
|
||||
);
|
||||
const decision = await policy.decide({
|
||||
subject: plan.requestedBy,
|
||||
projectId: plan.impact.target.projectId,
|
||||
permission: 'package.manage',
|
||||
});
|
||||
if (
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval') ||
|
||||
decision.fence === null
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle requester is no longer authorized',
|
||||
);
|
||||
}
|
||||
const consumedAtMs = await databaseNowMs(database.pool);
|
||||
const consumed = await approvals.consume({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: approvedAction,
|
||||
requestedBy: plan.requestedBy,
|
||||
consumedBy: CLUSTER_LIFECYCLE_CONSUMER.subject,
|
||||
consumedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: audit(
|
||||
auditEventId,
|
||||
approvalRequestId,
|
||||
plan.impact.target.projectId,
|
||||
decision.fence,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
approval = consumed.request;
|
||||
dispatch = consumed.dispatch;
|
||||
} else if (approval.version === 3 && approval.state === 'consumed') {
|
||||
dispatch = await approvals.findDispatchById(dispatchId);
|
||||
}
|
||||
if (
|
||||
approval.version !== 3 ||
|
||||
approval.state !== 'consumed' ||
|
||||
approval.consumptionId !== consumptionId ||
|
||||
approval.dispatchId !== dispatchId ||
|
||||
!dispatch ||
|
||||
!same(dispatch.action, approvedAction) ||
|
||||
!same(dispatch.requestedBy, plan.requestedBy) ||
|
||||
!same(dispatch.approvedBy, approval.decidedBy) ||
|
||||
!same(dispatch.consumedBy, CLUSTER_LIFECYCLE_CONSUMER.subject)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle dispatch does not match durable approval',
|
||||
);
|
||||
}
|
||||
const lifecycles = new PostgresPluginPackageLifecycleRepository(
|
||||
database.pool,
|
||||
);
|
||||
const event = createPluginPackageLifecycleEvent({
|
||||
dispatchId: dispatch.id,
|
||||
impact: plan.impact,
|
||||
requestedBy: dispatch.requestedBy,
|
||||
approvedBy: dispatch.approvedBy,
|
||||
authorizationMode: 'separation_of_duty',
|
||||
occurredAtMs: dispatch.createdAtMs,
|
||||
});
|
||||
const existingReceipt = await lifecycles.findByEventDigest(
|
||||
event.eventDigest,
|
||||
);
|
||||
if (existingReceipt) {
|
||||
await options.confirmAuthorization();
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: 'existing' as const,
|
||||
receipt: existingReceipt,
|
||||
});
|
||||
} else {
|
||||
const currentImpact = await lifecycles.plan(
|
||||
plan.impact.action,
|
||||
plan.impact.target.projectId,
|
||||
plan.impact.target.packageName,
|
||||
);
|
||||
if (!same(currentImpact, plan.impact)) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'approved lifecycle impact is stale',
|
||||
);
|
||||
}
|
||||
const transitioned = await lifecycles.transition(
|
||||
event,
|
||||
options.confirmAuthorization,
|
||||
);
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: transitioned.status,
|
||||
receipt: transitioned.receipt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
await closeDatabase(database, failure);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
'Cluster Plugin Package lifecycle execution produced no result',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep approval management authority explicit.
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresPluginPackageLifecyclePlanReader } from '@qinglong/cluster-postgres/package-manager';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
type CreateApprovalRequestResult,
|
||||
type DecideApprovalRequestResult,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import { pluginPackageLifecycleActionDigest } from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
type PluginPackageLifecyclePlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
|
||||
import {
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementConflictError,
|
||||
PluginPackageManagementRequestError,
|
||||
PluginPackageManagementUnavailableError,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const DEFAULT_APPROVAL_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
|
||||
export interface ClusterPluginPackageLifecycleManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
readonly approvalLifetimeMs?: number;
|
||||
}
|
||||
|
||||
export interface ProposeClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ProposeClusterPluginPackageLifecycleResult {
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan>;
|
||||
readonly approvalStatus: CreateApprovalRequestResult['status'];
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord>;
|
||||
}
|
||||
|
||||
export interface DecideClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly decisionId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterPluginPackageLifecycleResult {
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan> | null;
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord> | null;
|
||||
readonly stale: boolean;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecycleManagementService {
|
||||
propose(
|
||||
request: ProposeClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<ProposeClusterPluginPackageLifecycleResult>>;
|
||||
decide(
|
||||
request: DecideClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<DecideApprovalRequestResult>>;
|
||||
inspectAuthorized(
|
||||
request: InspectClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<InspectClusterPluginPackageLifecycleResult>>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new PluginPackageManagementRequestError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new PluginPackageManagementRequestError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function action(plan: Readonly<PluginPackageLifecyclePlan>) {
|
||||
return Object.freeze({
|
||||
permission: 'package.manage' as const,
|
||||
actionType: `plugin_package.lifecycle.${plan.impact.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(plan.impact),
|
||||
previewDigest: plan.impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
requestId: string,
|
||||
operationId: 'approval.request' | 'approval.decide',
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
authenticationId: string,
|
||||
outcome: 'allowed' | 'approval_required',
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: Object.freeze(['package_lifecycle_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageLifecycleManagementService(
|
||||
options: ClusterPluginPackageLifecycleManagementOptions,
|
||||
): Readonly<ClusterPluginPackageLifecycleManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'pool' &&
|
||||
key !== 'now' &&
|
||||
key !== 'approvalLifetimeMs',
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle management options are invalid',
|
||||
);
|
||||
}
|
||||
const approvalLifetimeMs =
|
||||
options.approvalLifetimeMs ?? DEFAULT_APPROVAL_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(approvalLifetimeMs) ||
|
||||
approvalLifetimeMs < 1_000 ||
|
||||
approvalLifetimeMs > DEFAULT_APPROVAL_LIFETIME_MS
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle approval lifetime is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const plans = new PostgresPluginPackageLifecyclePlanReader(options.pool);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
permission: 'package.manage' | 'approval.decide',
|
||||
observedAtMs: number,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
}>
|
||||
> => {
|
||||
let principal;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
(principal.assurance !== 'multi_factor' &&
|
||||
principal.assurance !== 'hardware')
|
||||
) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(principal, projectId, permission);
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (decision.effect !== 'allow' || decision.fence === null) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
return Object.freeze({ principal, fence: decision.fence });
|
||||
};
|
||||
|
||||
const loadPlan = async (
|
||||
requestedActionRef: string,
|
||||
): Promise<Readonly<PluginPackageLifecyclePlan>> => {
|
||||
let plan;
|
||||
try {
|
||||
plan = await plans.findByActionRef(actionRef(requestedActionRef));
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!plan) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan does not exist',
|
||||
);
|
||||
}
|
||||
return normalizePluginPackageLifecyclePlan(plan);
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async propose(request: ProposeClusterPluginPackageLifecycleRequest) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalAuditEventId',
|
||||
'approvalRequestId',
|
||||
'principal',
|
||||
]
|
||||
.sort()
|
||||
.join('\0')
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle proposal request is invalid',
|
||||
);
|
||||
}
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const approvalAuditEventId = identifier(
|
||||
request.approvalAuditEventId,
|
||||
'approvalAuditEventId',
|
||||
);
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
const observedAtMs = currentTime(now);
|
||||
if (observedAtMs > plan.expiresAtMs) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan expired',
|
||||
);
|
||||
}
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
plan.impact.target.projectId,
|
||||
'package.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
if (!sameSubject(plan.requestedBy, authorization.principal.subject)) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
const binding = action(plan);
|
||||
const existing = await approvals.findById(approvalRequestId);
|
||||
if (existing) {
|
||||
const normalized = normalizeApprovalRequestRecord(existing);
|
||||
if (
|
||||
normalized.projectId !== plan.impact.target.projectId ||
|
||||
normalized.decisionMode !== 'separation_of_duty' ||
|
||||
!sameSubject(normalized.requestedBy, plan.requestedBy) ||
|
||||
JSON.stringify(normalized.action) !== JSON.stringify(binding)
|
||||
) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request is bound to another lifecycle plan',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: 'existing' as const,
|
||||
approvalRequest: normalized,
|
||||
});
|
||||
}
|
||||
const expiresAtMs = Math.min(
|
||||
observedAtMs + approvalLifetimeMs,
|
||||
plan.expiresAtMs,
|
||||
);
|
||||
if (expiresAtMs <= observedAtMs) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan has no approval lifetime',
|
||||
);
|
||||
}
|
||||
const result = await approvals.create({
|
||||
request: createApprovalRequest({
|
||||
id: approvalRequestId,
|
||||
projectId: plan.impact.target.projectId,
|
||||
action: binding,
|
||||
risk: 'high',
|
||||
decisionMode: 'separation_of_duty',
|
||||
requestedBy: authorization.principal.subject,
|
||||
requestedAtMs: observedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: authorization.fence,
|
||||
}),
|
||||
audit: audit(
|
||||
approvalAuditEventId,
|
||||
approvalRequestId,
|
||||
'approval.request',
|
||||
plan.impact.target.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'approval_required',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: result.status,
|
||||
approvalRequest: result.request,
|
||||
});
|
||||
},
|
||||
|
||||
async decide(request: DecideClusterPluginPackageLifecycleRequest) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'auditEventId',
|
||||
'decision',
|
||||
'decisionId',
|
||||
'expectedVersion',
|
||||
'principal',
|
||||
'reasonCode',
|
||||
]
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
(request.decision !== 'approved' &&
|
||||
request.decision !== 'rejected') ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode) ||
|
||||
!Number.isSafeInteger(request.expectedVersion) ||
|
||||
request.expectedVersion < 1
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle decision request is invalid',
|
||||
);
|
||||
}
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const decisionId = identifier(request.decisionId, 'decisionId');
|
||||
const auditEventId = identifier(request.auditEventId, 'auditEventId');
|
||||
const current = await approvals.findById(approvalRequestId);
|
||||
if (!current) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request does not exist',
|
||||
);
|
||||
}
|
||||
const approval = normalizeApprovalRequestRecord(current);
|
||||
if (
|
||||
approval.action.actionRef !== plan.actionRef ||
|
||||
JSON.stringify(approval.action) !== JSON.stringify(action(plan))
|
||||
) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request does not match lifecycle plan',
|
||||
);
|
||||
}
|
||||
const observedAtMs = currentTime(now);
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
approval.projectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
if (
|
||||
approval.decisionId === decisionId &&
|
||||
approval.decision === request.decision &&
|
||||
approval.decisionReasonCode === request.reasonCode &&
|
||||
approval.decidedBy &&
|
||||
sameSubject(approval.decidedBy, authorization.principal.subject)
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
request: approval,
|
||||
});
|
||||
}
|
||||
return approvals.decide({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: request.expectedVersion,
|
||||
decisionId,
|
||||
decision: request.decision,
|
||||
reasonCode: request.reasonCode,
|
||||
principal: authorization.principal,
|
||||
decidedAtMs: observedAtMs,
|
||||
authorizationFence: authorization.fence,
|
||||
audit: audit(
|
||||
auditEventId,
|
||||
approvalRequestId,
|
||||
'approval.decide',
|
||||
approval.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'allowed',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
async inspectAuthorized(
|
||||
request: InspectClusterPluginPackageLifecycleRequest,
|
||||
) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'inspectionId',
|
||||
'principal',
|
||||
]
|
||||
.sort()
|
||||
.join('\0')
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle inspection request is invalid',
|
||||
);
|
||||
}
|
||||
identifier(request.inspectionId, 'inspectionId');
|
||||
const requestedActionRef = actionRef(request.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const [planValue, approvalValue] = await Promise.all([
|
||||
plans.findByActionRef(requestedActionRef),
|
||||
approvals.findById(approvalRequestId),
|
||||
]);
|
||||
if (!planValue && !approvalValue) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle state does not exist',
|
||||
);
|
||||
}
|
||||
const plan = planValue
|
||||
? normalizePluginPackageLifecyclePlan(planValue)
|
||||
: null;
|
||||
const approval = approvalValue
|
||||
? normalizeApprovalRequestRecord(approvalValue)
|
||||
: null;
|
||||
const projectId = plan?.impact.target.projectId ?? approval?.projectId;
|
||||
if (!projectId) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
const observedAtMs = currentTime(now);
|
||||
try {
|
||||
await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'package.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PluginPackageManagementAuthorizationError)) {
|
||||
throw error;
|
||||
}
|
||||
await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalRequest: approval,
|
||||
stale:
|
||||
plan === null ||
|
||||
approval === null ||
|
||||
approval.action.actionRef !== plan.actionRef ||
|
||||
JSON.stringify(approval.action) !== JSON.stringify(action(plan)) ||
|
||||
observedAtMs > plan.expiresAtMs,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep quarantine authority explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageQuarantineError,
|
||||
normalizePluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineRepository,
|
||||
type PluginPackageWithdrawalReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-quarantine';
|
||||
import {
|
||||
PostgresPluginPackageQuarantineRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT = 128;
|
||||
|
||||
export interface ClusterPluginPackageQuarantineService {
|
||||
quarantine(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>,
|
||||
): Promise<
|
||||
readonly Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
eventDigest: string;
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>[]
|
||||
>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageQuarantineOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly events: readonly Readonly<PluginPackageQuarantineEvent>[];
|
||||
readonly confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageQuarantineRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly results: readonly Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
eventDigest: string;
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>[];
|
||||
}
|
||||
|
||||
function targetKey(event: Readonly<PluginPackageQuarantineEvent>): string {
|
||||
return [
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
event.target.installationId,
|
||||
event.target.lockDigest,
|
||||
].join('\0');
|
||||
}
|
||||
|
||||
function normalizedBatch(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
): readonly Readonly<PluginPackageQuarantineEvent>[] {
|
||||
if (
|
||||
!Array.isArray(events) ||
|
||||
events.length < 1 ||
|
||||
events.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT ||
|
||||
Object.keys(events).some((key, index) => key !== String(index))
|
||||
) {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
`events must contain 1-${CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT} dense items`,
|
||||
);
|
||||
}
|
||||
const normalized = events.map(normalizePluginPackageQuarantineEvent);
|
||||
const eventDigests = new Set<string>();
|
||||
const targets = new Set<string>();
|
||||
for (const event of normalized) {
|
||||
const target = targetKey(event);
|
||||
if (eventDigests.has(event.eventDigest) || targets.has(target)) {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'batch event digests and targets must be unique',
|
||||
);
|
||||
}
|
||||
eventDigests.add(event.eventDigest);
|
||||
targets.add(target);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageQuarantineService(
|
||||
repository: PluginPackageQuarantineRepository,
|
||||
): Readonly<ClusterPluginPackageQuarantineService> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.findTargetsByLockDigest !== 'function' ||
|
||||
typeof repository.findByEventDigest !== 'function' ||
|
||||
typeof repository.quarantine !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine repository is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
async quarantine(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>,
|
||||
) {
|
||||
const batch = normalizedBatch(events);
|
||||
if (typeof confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
const results = [];
|
||||
for (const event of batch) {
|
||||
const result = await repository.quarantine(event, () =>
|
||||
confirmAuthorization(event),
|
||||
);
|
||||
results.push(
|
||||
Object.freeze({
|
||||
status: result.status,
|
||||
eventDigest: event.eventDigest,
|
||||
receipt: result.receipt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(results);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageQuarantine(
|
||||
options: RunClusterPluginPackageQuarantineOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageQuarantineRun>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine options are invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
!['openDatabase', 'events', 'confirmAuthorization'].includes(key),
|
||||
) ||
|
||||
typeof options.openDatabase !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine options shape is invalid',
|
||||
);
|
||||
}
|
||||
const events = normalizedBatch(options.events);
|
||||
if (typeof options.confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const results =
|
||||
await createClusterPluginPackageQuarantineService(
|
||||
new PostgresPluginPackageQuarantineRepository(database.pool),
|
||||
).quarantine(events, options.confirmAuthorization);
|
||||
return Object.freeze({ database: evidence, results });
|
||||
} finally {
|
||||
await database?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/** Plugin Package management service boundary. */
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresPluginPackageInstallInventoryReader } from '@qinglong/cluster-postgres/package-manager';
|
||||
import { PostgresPluginPackageInstallProposalRepository } from '@qinglong/cluster-postgres/plugin-package-proposal';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementConflictError,
|
||||
PluginPackageManagementQuotaExceededError,
|
||||
PluginPackageManagementRequestError,
|
||||
PluginPackageManagementUnavailableError,
|
||||
createPluginPackageManagementService,
|
||||
type InspectPluginPackageInstallResult,
|
||||
type PluginPackageManagementQuotaPort,
|
||||
type PluginPackageManagementService as RuntimePluginPackageManagementService,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_INVENTORY_PAGE_SIZE,
|
||||
normalizePluginPackageInstallInventoryCursor,
|
||||
type PluginPackageInstallInventoryItem,
|
||||
type PluginPackageInstallInventoryPage,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE =
|
||||
'separation_of_duty' as const;
|
||||
|
||||
type ClusterPluginPackageManagementMutationService = Pick<
|
||||
RuntimePluginPackageManagementService,
|
||||
'propose' | 'decide' | 'inspect'
|
||||
>;
|
||||
|
||||
export interface InspectAuthorizedClusterPluginPackageRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedClusterPluginPackageInstallationRequest {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedClusterPluginPackageInstallationsRequest {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ packageName: string }>;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export type ClusterPluginPackageManagementService =
|
||||
ClusterPluginPackageManagementMutationService &
|
||||
Readonly<{
|
||||
inspectAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageRequest,
|
||||
): Promise<Readonly<InspectPluginPackageInstallResult>>;
|
||||
inspectInstallationAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageInstallationRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryItem> | null>;
|
||||
listInstallationsAuthorized(
|
||||
request: ListAuthorizedClusterPluginPackageInstallationsRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryPage>>;
|
||||
}>;
|
||||
|
||||
export interface ClusterPluginPackageManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly approvalLifetimeMs?: number;
|
||||
readonly now?: () => number;
|
||||
readonly quota?: PluginPackageManagementQuotaPort;
|
||||
}
|
||||
|
||||
const INSPECTION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
export function createClusterPluginPackageManagementService(
|
||||
options: ClusterPluginPackageManagementOptions,
|
||||
): Readonly<ClusterPluginPackageManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'pool' &&
|
||||
key !== 'approvalLifetimeMs' &&
|
||||
key !== 'now' &&
|
||||
key !== 'quota',
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'cluster Plugin Package management options are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
const installations = new PostgresPluginPackageInstallInventoryReader(
|
||||
options.pool,
|
||||
);
|
||||
const service = createPluginPackageManagementService(
|
||||
policy,
|
||||
new PostgresPluginPackageInstallProposalRepository(options.pool),
|
||||
new PostgresApprovalRequestRepository(options.pool),
|
||||
Object.freeze({
|
||||
async dispatchBatch(): Promise<never> {
|
||||
throw new Error(
|
||||
'cluster Plugin Package management cannot execute approved actions',
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
decisionMode: CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE,
|
||||
consumer: Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'cluster_package_management_unreachable_consumer',
|
||||
}),
|
||||
authenticationId: 'cluster-package-management-unreachable-consumer',
|
||||
}),
|
||||
...(options.approvalLifetimeMs === undefined
|
||||
? {}
|
||||
: { approvalLifetimeMs: options.approvalLifetimeMs }),
|
||||
now,
|
||||
...(options.quota === undefined ? {} : { quota: options.quota }),
|
||||
},
|
||||
);
|
||||
|
||||
const allowed = (
|
||||
decision: Readonly<SecurityPolicyDecision>,
|
||||
allowApproval: boolean,
|
||||
): boolean =>
|
||||
decision.fence !== null &&
|
||||
(decision.effect === 'allow' ||
|
||||
(allowApproval && decision.effect === 'require_approval'));
|
||||
|
||||
const authorizeInstallationInventory = async (
|
||||
projectId: string,
|
||||
inspectionId: string,
|
||||
principalValue: SecurityPrincipal,
|
||||
): Promise<Readonly<SecurityPrincipal>> => {
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = await policy.authorize(principal, projectId, 'package.manage');
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!allowed(decision, true)) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
if (options.quota) {
|
||||
try {
|
||||
await options.quota.consume({
|
||||
projectId,
|
||||
subject: principal.subject,
|
||||
operation: 'plugin-package.inspect',
|
||||
idempotencyKey: inspectionId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageManagementQuotaExceededError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return principal;
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
propose: service.propose,
|
||||
decide: service.decide,
|
||||
inspect: service.inspect,
|
||||
async inspectInstallationAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageInstallationRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryItem> | null> {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
['inspectionId', 'packageName', 'principal', 'projectId']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(request.projectId) ||
|
||||
typeof request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(request.packageName) ||
|
||||
typeof request.inspectionId !== 'string' ||
|
||||
!INSPECTION_ID_PATTERN.test(request.inspectionId)
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'installation inspection request is invalid',
|
||||
);
|
||||
}
|
||||
await authorizeInstallationInventory(
|
||||
request.projectId,
|
||||
request.inspectionId,
|
||||
request.principal,
|
||||
);
|
||||
try {
|
||||
return await installations.findCurrent(
|
||||
request.projectId,
|
||||
request.packageName,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
async listInstallationsAuthorized(
|
||||
request: ListAuthorizedClusterPluginPackageInstallationsRequest,
|
||||
): Promise<Readonly<PluginPackageInstallInventoryPage>> {
|
||||
const keys =
|
||||
request && typeof request === 'object' && !Array.isArray(request)
|
||||
? Object.keys(request)
|
||||
: [];
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!keys.includes('projectId') ||
|
||||
!keys.includes('limit') ||
|
||||
!keys.includes('inspectionId') ||
|
||||
!keys.includes('principal') ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
![
|
||||
'after',
|
||||
'inspectionId',
|
||||
'limit',
|
||||
'principal',
|
||||
'projectId',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(request.projectId) ||
|
||||
!Number.isSafeInteger(request.limit) ||
|
||||
request.limit < 1 ||
|
||||
request.limit > MAX_PLUGIN_PACKAGE_INSTALL_INVENTORY_PAGE_SIZE ||
|
||||
typeof request.inspectionId !== 'string' ||
|
||||
!INSPECTION_ID_PATTERN.test(request.inspectionId)
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'installation list request is invalid',
|
||||
);
|
||||
}
|
||||
let after: Readonly<{ packageName: string }> | undefined;
|
||||
try {
|
||||
after =
|
||||
request.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageInstallInventoryCursor(request.after);
|
||||
} catch {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'installation list cursor is invalid',
|
||||
);
|
||||
}
|
||||
await authorizeInstallationInventory(
|
||||
request.projectId,
|
||||
request.inspectionId,
|
||||
request.principal,
|
||||
);
|
||||
try {
|
||||
return await installations.listCurrentPage({
|
||||
projectId: request.projectId,
|
||||
limit: request.limit,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
async inspectAuthorized(
|
||||
request: InspectAuthorizedClusterPluginPackageRequest,
|
||||
): Promise<Readonly<InspectPluginPackageInstallResult>> {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).length !== 4 ||
|
||||
Object.keys(request).some(
|
||||
(key) =>
|
||||
![
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'inspectionId',
|
||||
'principal',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof request.inspectionId !== 'string' ||
|
||||
!INSPECTION_ID_PATTERN.test(request.inspectionId)
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'inspection request is invalid',
|
||||
);
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(request.principal, observedAtMs);
|
||||
} catch {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
const current = await service.inspect(
|
||||
request.actionRef,
|
||||
request.approvalRequestId,
|
||||
);
|
||||
const projectId =
|
||||
current.proposal?.projectId ?? current.approvalRequest?.projectId;
|
||||
if (!projectId) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package management state does not exist',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(current.proposal &&
|
||||
current.approvalRequest &&
|
||||
(current.proposal.projectId !== current.approvalRequest.projectId ||
|
||||
current.approvalRequest.action.actionRef !==
|
||||
current.proposal.actionRef ||
|
||||
current.approvalRequest.action.actionDigest !==
|
||||
current.proposal.actionDigest ||
|
||||
current.approvalRequest.action.previewDigest !==
|
||||
current.proposal.previewDigest)) ||
|
||||
(current.proposal &&
|
||||
current.proposal.actionRef !== request.actionRef) ||
|
||||
(current.approvalRequest &&
|
||||
current.approvalRequest.id !== request.approvalRequestId)
|
||||
) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
let packageDecision: Readonly<SecurityPolicyDecision>;
|
||||
let approvalDecision: Readonly<SecurityPolicyDecision> | undefined;
|
||||
try {
|
||||
packageDecision = await policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
'package.manage',
|
||||
);
|
||||
if (!allowed(packageDecision, true)) {
|
||||
approvalDecision = await policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
'approval.decide',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!allowed(packageDecision, true) &&
|
||||
(!approvalDecision || !allowed(approvalDecision, false))
|
||||
) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
if (options.quota) {
|
||||
try {
|
||||
await options.quota.consume({
|
||||
projectId,
|
||||
subject: principal.subject,
|
||||
operation: 'plugin-package.inspect',
|
||||
idempotencyKey: request.inspectionId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageManagementQuotaExceededError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return current;
|
||||
},
|
||||
});
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/** One-shot Plugin Package management process CLI boundary. */
|
||||
|
||||
import {
|
||||
startClusterPluginPackageManagementProcess,
|
||||
type ClusterPluginPackageManagementProcessRuntime,
|
||||
} from './pluginPackageManagementProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-plugin-package-manage';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
|
||||
let runtime: Readonly<ClusterPluginPackageManagementProcessRuntime>;
|
||||
try {
|
||||
runtime = await startClusterPluginPackageManagementProcess({
|
||||
environment: process.env,
|
||||
onError() {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_unavailable',
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (runtime.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_started',
|
||||
address: runtime.address,
|
||||
identityGeneration: runtime.identity.generation,
|
||||
databaseContractVersion: runtime.database.contractVersion,
|
||||
databaseMigrationCount: runtime.database.migrationIds.length,
|
||||
});
|
||||
|
||||
let stopping: Promise<void> | undefined;
|
||||
const stop = (): Promise<void> => {
|
||||
stopping ??= runtime.close().then(() => {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management',
|
||||
event: 'management_stopped',
|
||||
});
|
||||
});
|
||||
return stopping;
|
||||
};
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void stop().then(
|
||||
() => {
|
||||
process.exitCode = 0;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
/** One-shot Plugin Package management client CLI boundary. */
|
||||
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRemoteError,
|
||||
executeClusterPluginPackageManagementClient,
|
||||
} from '../../management-support/pluginPackageManagementClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-plugin-package-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
|
||||
|
||||
function parseArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 3) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(config|command|assertion)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' &&
|
||||
candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = parseArguments(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management-client',
|
||||
event: 'usage_invalid',
|
||||
code: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_USAGE_INVALID',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
await executeClusterPluginPackageManagementClient(paths);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-management-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+796
@@ -0,0 +1,796 @@
|
||||
/** Explicit Kubernetes PortForward client boundary for Plugin Package management. */
|
||||
import {
|
||||
createPrivateKey,
|
||||
X509Certificate,
|
||||
} from 'node:crypto';
|
||||
import { Duplex, PassThrough, Writable } from 'node:stream';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
import {
|
||||
ClusterPluginPackageManagementClientConfigurationError,
|
||||
ClusterPluginPackageManagementClientRemoteError,
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
executeClusterPluginPackageManagementClient,
|
||||
readCanonicalFile,
|
||||
type ClusterPluginPackageManagementClientPaths,
|
||||
type ClusterPluginPackageManagementClientRawConnection,
|
||||
type ClusterPluginPackageManagementClientResult,
|
||||
} from '../../management-support/pluginPackageManagementClient';
|
||||
|
||||
const MAX_KUBERNETES_CONFIG_BYTES = 16 * 1024;
|
||||
const MAX_KUBECONFIG_BYTES = 256 * 1024;
|
||||
const MAX_KUBERNETES_CA_BYTES = 256 * 1024;
|
||||
const MAX_KUBERNETES_CLIENT_MATERIAL_BYTES = 256 * 1024;
|
||||
const MAX_KUBERNETES_TOKEN_BYTES = 16 * 1024;
|
||||
const MANAGEMENT_NAME = 'ql3-plugin-package-management';
|
||||
const MANAGEMENT_PORT = 8443;
|
||||
const MANAGEMENT_LABEL_SELECTOR =
|
||||
'app.kubernetes.io/name=ql3-plugin-package-management,' +
|
||||
'app.kubernetes.io/component=plugin-package-management';
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const DNS_LABEL_PATTERN =
|
||||
/^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const CONTEXT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,255}$/;
|
||||
const POD_NAME_PATTERN =
|
||||
/^ql3-plugin-package-management-[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?(?:-[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)?$/;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~+/-]{0,16383}$/;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type KubernetesModule = typeof import('@kubernetes/client-node', {
|
||||
with: { 'resolution-mode': 'import' }
|
||||
});
|
||||
type KubernetesConfig = InstanceType<KubernetesModule['KubeConfig']>;
|
||||
|
||||
interface ReviewedKubernetesClientConfig {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kubeconfigFile: string;
|
||||
readonly context: string;
|
||||
readonly namespace: string;
|
||||
readonly apiTimeoutMs: number;
|
||||
}
|
||||
|
||||
interface KubernetesPod {
|
||||
readonly metadata?: {
|
||||
readonly name?: string;
|
||||
readonly namespace?: string;
|
||||
readonly uid?: string;
|
||||
readonly deletionTimestamp?: unknown;
|
||||
readonly labels?: Readonly<Record<string, string>>;
|
||||
};
|
||||
readonly spec?: {
|
||||
readonly serviceAccountName?: string;
|
||||
readonly automountServiceAccountToken?: boolean;
|
||||
readonly containers?: readonly Readonly<{ readonly name?: string }>[];
|
||||
};
|
||||
readonly status?: {
|
||||
readonly phase?: string;
|
||||
readonly conditions?: readonly Readonly<{
|
||||
readonly type?: string;
|
||||
readonly status?: string;
|
||||
}>[];
|
||||
readonly containerStatuses?: readonly Readonly<{
|
||||
readonly name?: string;
|
||||
readonly ready?: boolean;
|
||||
}>[];
|
||||
};
|
||||
}
|
||||
|
||||
interface KubernetesPodList {
|
||||
readonly metadata?: {
|
||||
readonly continue?: string;
|
||||
};
|
||||
readonly items?: readonly KubernetesPod[];
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesPodApi {
|
||||
listNamespacedPod(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
labelSelector: string;
|
||||
limit: number;
|
||||
timeoutSeconds: number;
|
||||
watch: false;
|
||||
}>,
|
||||
): Promise<KubernetesPodList>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesRuntime {
|
||||
readonly pods: ClusterPluginPackageManagementKubernetesPodApi;
|
||||
openPortForward(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
podName: string;
|
||||
port: 8443;
|
||||
}>,
|
||||
): Promise<ClusterPluginPackageManagementClientRawConnection>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementPortForwardWebSocket {
|
||||
addEventListener(
|
||||
type: 'close' | 'error',
|
||||
listener: () => void,
|
||||
): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementPortForwardApi {
|
||||
portForward(
|
||||
namespace: string,
|
||||
podName: string,
|
||||
targetPorts: number[],
|
||||
output: Writable,
|
||||
error: Writable,
|
||||
input: PassThrough,
|
||||
retryCount: 0,
|
||||
): Promise<
|
||||
| ClusterPluginPackageManagementPortForwardWebSocket
|
||||
| (() => ClusterPluginPackageManagementPortForwardWebSocket | null)
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesClientPaths
|
||||
extends ClusterPluginPackageManagementClientPaths {
|
||||
readonly kubernetesFile: string;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesClientOptions {
|
||||
readonly createRuntime?: (
|
||||
kubeConfig: KubernetesConfig,
|
||||
kubernetes: KubernetesModule,
|
||||
) => ClusterPluginPackageManagementKubernetesRuntime;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementKubernetesClientConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_CONFIG_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Kubernetes Plugin Package management client configuration is invalid');
|
||||
this.name =
|
||||
'ClusterPluginPackageManagementKubernetesClientConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementKubernetesClientTunnelError extends Error {
|
||||
readonly code =
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_TUNNEL_FAILED';
|
||||
|
||||
constructor(readonly cause?: unknown) {
|
||||
super('Kubernetes Plugin Package management tunnel failed');
|
||||
this.name = 'ClusterPluginPackageManagementKubernetesClientTunnelError';
|
||||
}
|
||||
}
|
||||
|
||||
function configurationFailure(): ClusterPluginPackageManagementKubernetesClientConfigurationError {
|
||||
return new ClusterPluginPackageManagementKubernetesClientConfigurationError();
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
): asserts value is JsonObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Buffer): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
try {
|
||||
return JSON.parse(decodeUtf8(bytes));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function readPrivateFile(filePath: string, maximumBytes: number): Buffer {
|
||||
try {
|
||||
return readCanonicalFile(filePath, maximumBytes, 'private');
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(
|
||||
value: unknown,
|
||||
): Readonly<ReviewedKubernetesClientConfig> {
|
||||
exactObject(value, [
|
||||
'schemaVersion',
|
||||
'kubeconfigFile',
|
||||
'context',
|
||||
'namespace',
|
||||
'apiTimeoutMs',
|
||||
]);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
typeof value.kubeconfigFile !== 'string' ||
|
||||
typeof value.context !== 'string' ||
|
||||
!CONTEXT_PATTERN.test(value.context) ||
|
||||
typeof value.namespace !== 'string' ||
|
||||
!DNS_LABEL_PATTERN.test(value.namespace) ||
|
||||
!Number.isSafeInteger(value.apiTimeoutMs) ||
|
||||
(value.apiTimeoutMs as number) < 1_000 ||
|
||||
(value.apiTimeoutMs as number) > 30_000
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kubeconfigFile: value.kubeconfigFile,
|
||||
context: value.context,
|
||||
namespace: value.namespace,
|
||||
apiTimeoutMs: value.apiTimeoutMs as number,
|
||||
});
|
||||
}
|
||||
|
||||
function decodeCanonicalBase64(
|
||||
value: unknown,
|
||||
maximumBytes: number,
|
||||
): Buffer {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 4 ||
|
||||
value.length > maximumBytes * 2 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64');
|
||||
if (
|
||||
bytes.length < 1 ||
|
||||
bytes.length > maximumBytes ||
|
||||
bytes.toString('base64') !== value
|
||||
) {
|
||||
bytes.fill(0);
|
||||
throw configurationFailure();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function validateRawKubeconfig(
|
||||
value: unknown,
|
||||
config: Readonly<ReviewedKubernetesClientConfig>,
|
||||
): void {
|
||||
exactObject(value, [
|
||||
'apiVersion',
|
||||
'kind',
|
||||
'clusters',
|
||||
'users',
|
||||
'contexts',
|
||||
'current-context',
|
||||
]);
|
||||
if (
|
||||
value.apiVersion !== 'v1' ||
|
||||
value.kind !== 'Config' ||
|
||||
value['current-context'] !== config.context ||
|
||||
!Array.isArray(value.clusters) ||
|
||||
value.clusters.length !== 1 ||
|
||||
!Array.isArray(value.users) ||
|
||||
value.users.length !== 1 ||
|
||||
!Array.isArray(value.contexts) ||
|
||||
value.contexts.length !== 1
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const clusterEntry = value.clusters[0];
|
||||
const userEntry = value.users[0];
|
||||
const contextEntry = value.contexts[0];
|
||||
exactObject(clusterEntry, ['name', 'cluster']);
|
||||
const rawCluster = clusterEntry.cluster;
|
||||
exactObject(rawCluster, [
|
||||
'server',
|
||||
'certificate-authority-data',
|
||||
]);
|
||||
exactObject(userEntry, ['name', 'user']);
|
||||
const rawUser = userEntry.user;
|
||||
if (!rawUser || typeof rawUser !== 'object' || Array.isArray(rawUser)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
exactObject(contextEntry, ['name', 'context']);
|
||||
const rawContext = contextEntry.context;
|
||||
exactObject(rawContext, [
|
||||
'cluster',
|
||||
'user',
|
||||
'namespace',
|
||||
]);
|
||||
if (
|
||||
typeof clusterEntry.name !== 'string' ||
|
||||
!CONTEXT_PATTERN.test(clusterEntry.name) ||
|
||||
typeof userEntry.name !== 'string' ||
|
||||
!CONTEXT_PATTERN.test(userEntry.name) ||
|
||||
contextEntry.name !== config.context ||
|
||||
rawContext.cluster !== clusterEntry.name ||
|
||||
rawContext.user !== userEntry.name ||
|
||||
rawContext.namespace !== config.namespace
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const userKeys = Object.keys(rawUser).sort();
|
||||
if (
|
||||
JSON.stringify(userKeys) !== JSON.stringify(['token']) &&
|
||||
JSON.stringify(userKeys) !==
|
||||
JSON.stringify(
|
||||
['client-certificate-data', 'client-key-data'].sort(),
|
||||
)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function validateKubeConfig(
|
||||
kubeConfig: KubernetesConfig,
|
||||
config: Readonly<ReviewedKubernetesClientConfig>,
|
||||
): void {
|
||||
kubeConfig.setCurrentContext(config.context);
|
||||
if (kubeConfig.getCurrentContext() !== config.context) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const context = kubeConfig.getContextObject(config.context);
|
||||
const cluster = kubeConfig.getCurrentCluster();
|
||||
const user = kubeConfig.getCurrentUser();
|
||||
if (
|
||||
!context ||
|
||||
context.namespace !== config.namespace ||
|
||||
!cluster ||
|
||||
!user
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
let server: URL;
|
||||
try {
|
||||
server = new URL(cluster.server);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (
|
||||
server.protocol !== 'https:' ||
|
||||
server.username !== '' ||
|
||||
server.password !== '' ||
|
||||
(server.pathname !== '' && server.pathname !== '/') ||
|
||||
server.search !== '' ||
|
||||
server.hash !== '' ||
|
||||
server.hostname.length < 1 ||
|
||||
cluster.skipTLSVerify !== false ||
|
||||
cluster.proxyUrl != null ||
|
||||
cluster.caFile != null ||
|
||||
typeof cluster.caData !== 'string' ||
|
||||
(cluster.tlsServerName != null &&
|
||||
cluster.tlsServerName !== server.hostname)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const ca = decodeCanonicalBase64(
|
||||
cluster.caData,
|
||||
MAX_KUBERNETES_CA_BYTES,
|
||||
);
|
||||
try {
|
||||
new X509Certificate(ca);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
} finally {
|
||||
ca.fill(0);
|
||||
}
|
||||
|
||||
if (
|
||||
user.exec != null ||
|
||||
user.authProvider != null ||
|
||||
user.certFile != null ||
|
||||
user.keyFile != null ||
|
||||
user.username != null ||
|
||||
user.password != null ||
|
||||
user.impersonateUser != null
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const hasToken = user.token != null;
|
||||
const hasCertificate =
|
||||
user.certData != null || user.keyData != null;
|
||||
if (
|
||||
hasToken === hasCertificate ||
|
||||
(hasToken &&
|
||||
(typeof user.token !== 'string' ||
|
||||
Buffer.byteLength(user.token, 'utf8') >
|
||||
MAX_KUBERNETES_TOKEN_BYTES ||
|
||||
CONTROL_PATTERN.test(user.token) ||
|
||||
!TOKEN_PATTERN.test(user.token)))
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (hasCertificate) {
|
||||
const certificate = decodeCanonicalBase64(
|
||||
user.certData,
|
||||
MAX_KUBERNETES_CLIENT_MATERIAL_BYTES,
|
||||
);
|
||||
const privateKey = decodeCanonicalBase64(
|
||||
user.keyData,
|
||||
MAX_KUBERNETES_CLIENT_MATERIAL_BYTES,
|
||||
);
|
||||
try {
|
||||
const parsedCertificate = new X509Certificate(certificate);
|
||||
const parsedPrivateKey = createPrivateKey(privateKey);
|
||||
if (!parsedCertificate.checkPrivateKey(parsedPrivateKey)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
} finally {
|
||||
certificate.fill(0);
|
||||
privateKey.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isReviewedPod(
|
||||
value: KubernetesPod,
|
||||
namespace: string,
|
||||
): value is KubernetesPod & {
|
||||
readonly metadata: {
|
||||
readonly name: string;
|
||||
readonly namespace: string;
|
||||
readonly uid: string;
|
||||
};
|
||||
} {
|
||||
const labels = value.metadata?.labels;
|
||||
return (
|
||||
typeof value.metadata?.name === 'string' &&
|
||||
POD_NAME_PATTERN.test(value.metadata.name) &&
|
||||
value.metadata.namespace === namespace &&
|
||||
typeof value.metadata.uid === 'string' &&
|
||||
value.metadata.uid.length >= 8 &&
|
||||
value.metadata.uid.length <= 128 &&
|
||||
!CONTROL_PATTERN.test(value.metadata.uid) &&
|
||||
value.metadata.deletionTimestamp === undefined &&
|
||||
labels?.['app.kubernetes.io/name'] === MANAGEMENT_NAME &&
|
||||
labels?.['app.kubernetes.io/component'] ===
|
||||
'plugin-package-management' &&
|
||||
value.spec?.serviceAccountName === MANAGEMENT_NAME &&
|
||||
value.spec?.automountServiceAccountToken === false &&
|
||||
value.spec?.containers?.some(({ name }) => name === 'management') ===
|
||||
true &&
|
||||
value.status?.phase === 'Running' &&
|
||||
value.status.conditions?.some(
|
||||
({ type, status }) => type === 'Ready' && status === 'True',
|
||||
) === true &&
|
||||
value.status.containerStatuses?.some(
|
||||
({ name, ready }) => name === 'management' && ready === true,
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
function selectManagementPod(
|
||||
value: KubernetesPodList,
|
||||
namespace: string,
|
||||
): string {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!Array.isArray(value.items) ||
|
||||
value.items.length < 1 ||
|
||||
value.items.length > 3 ||
|
||||
(value.metadata?.continue !== undefined &&
|
||||
value.metadata.continue !== '')
|
||||
) {
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError();
|
||||
}
|
||||
const current = value.items.filter(
|
||||
({ metadata }) => metadata?.deletionTimestamp === undefined,
|
||||
);
|
||||
if (
|
||||
current.length < 1 ||
|
||||
current.length > 2 ||
|
||||
!current.every((pod) => isReviewedPod(pod, namespace))
|
||||
) {
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError();
|
||||
}
|
||||
return current
|
||||
.map(({ metadata }) => metadata!.name!)
|
||||
.sort()[0]!;
|
||||
}
|
||||
|
||||
function deadline<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
disposeLate?: (value: T) => void | Promise<void>,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}, timeoutMs);
|
||||
operation.then(
|
||||
(value) => {
|
||||
if (settled) {
|
||||
void Promise.resolve(disposeLate?.(value)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError
|
||||
? error
|
||||
: new ClusterPluginPackageManagementKubernetesClientTunnelError(
|
||||
error,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function openClusterPluginPackageManagementPortForward(
|
||||
forward: ClusterPluginPackageManagementPortForwardApi,
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
podName: string;
|
||||
port: 8443;
|
||||
}>,
|
||||
): Promise<ClusterPluginPackageManagementClientRawConnection> {
|
||||
const incoming = new PassThrough();
|
||||
const outgoing = new PassThrough();
|
||||
let connection: Duplex | undefined;
|
||||
let pendingError = false;
|
||||
const errors = new Writable({
|
||||
write(chunk: Buffer | string, _encoding, callback) {
|
||||
const bytes = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk);
|
||||
const failed = bytes.length > 0;
|
||||
bytes.fill(0);
|
||||
if (failed) {
|
||||
pendingError = true;
|
||||
connection?.destroy(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const handle = await forward.portForward(
|
||||
request.namespace,
|
||||
request.podName,
|
||||
[request.port],
|
||||
incoming,
|
||||
errors,
|
||||
outgoing,
|
||||
0,
|
||||
);
|
||||
const webSocket =
|
||||
typeof handle === 'function' ? handle() : handle;
|
||||
if (!webSocket) {
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError();
|
||||
}
|
||||
const nodeStreamPair = {
|
||||
readable: incoming,
|
||||
writable: outgoing,
|
||||
};
|
||||
// Node supports a { readable, writable } pair of Node streams here, while
|
||||
// @types/node@24.13.3 currently models only the equivalent Web Streams pair.
|
||||
connection = Duplex.from(
|
||||
nodeStreamPair as unknown as Parameters<typeof Duplex.from>[0],
|
||||
);
|
||||
if (pendingError) {
|
||||
connection.destroy(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}
|
||||
let closed = false;
|
||||
const tunnelFailure = () => {
|
||||
if (!closed) {
|
||||
connection?.destroy(
|
||||
new ClusterPluginPackageManagementKubernetesClientTunnelError(),
|
||||
);
|
||||
}
|
||||
};
|
||||
webSocket.addEventListener('close', tunnelFailure);
|
||||
webSocket.addEventListener('error', tunnelFailure);
|
||||
return Object.freeze({
|
||||
stream: connection,
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
connection?.end();
|
||||
incoming.end();
|
||||
outgoing.end();
|
||||
errors.end();
|
||||
webSocket.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function productionRuntime(
|
||||
kubeConfig: KubernetesConfig,
|
||||
kubernetes: KubernetesModule,
|
||||
): ClusterPluginPackageManagementKubernetesRuntime {
|
||||
const pods = kubeConfig.makeApiClient(
|
||||
kubernetes.CoreV1Api,
|
||||
) as unknown as ClusterPluginPackageManagementKubernetesPodApi;
|
||||
const forward = new kubernetes.PortForward(
|
||||
kubeConfig,
|
||||
true,
|
||||
) as unknown as ClusterPluginPackageManagementPortForwardApi;
|
||||
const runtime: ClusterPluginPackageManagementKubernetesRuntime = {
|
||||
pods,
|
||||
openPortForward: (request) =>
|
||||
openClusterPluginPackageManagementPortForward(
|
||||
forward,
|
||||
request,
|
||||
),
|
||||
};
|
||||
return Object.freeze(runtime);
|
||||
}
|
||||
|
||||
export async function executeClusterPluginPackageManagementKubernetesClient(
|
||||
paths: ClusterPluginPackageManagementKubernetesClientPaths,
|
||||
options: ClusterPluginPackageManagementKubernetesClientOptions = {},
|
||||
): Promise<Readonly<ClusterPluginPackageManagementClientResult>> {
|
||||
exactObject(paths, [
|
||||
'configFile',
|
||||
'commandFile',
|
||||
'assertionFile',
|
||||
'kubernetesFile',
|
||||
]);
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'createRuntime') ||
|
||||
(options.createRuntime !== undefined &&
|
||||
typeof options.createRuntime !== 'function')
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
let kubernetesConfigBytes: Buffer | undefined;
|
||||
let kubeconfigBytes: Buffer | undefined;
|
||||
try {
|
||||
kubernetesConfigBytes = readPrivateFile(
|
||||
paths.kubernetesFile,
|
||||
MAX_KUBERNETES_CONFIG_BYTES,
|
||||
);
|
||||
const config = normalizeConfig(parseJson(kubernetesConfigBytes));
|
||||
kubeconfigBytes = readPrivateFile(
|
||||
config.kubeconfigFile,
|
||||
MAX_KUBECONFIG_BYTES,
|
||||
);
|
||||
const kubernetes = await import('@kubernetes/client-node');
|
||||
const kubeConfig = new kubernetes.KubeConfig();
|
||||
try {
|
||||
const kubeconfigText = decodeUtf8(kubeconfigBytes);
|
||||
validateRawKubeconfig(parseJson(kubeconfigBytes), config);
|
||||
kubeConfig.loadFromString(kubeconfigText);
|
||||
validateKubeConfig(kubeConfig, config);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
const runtime = (options.createRuntime ?? productionRuntime)(
|
||||
kubeConfig,
|
||||
kubernetes,
|
||||
);
|
||||
if (
|
||||
!runtime ||
|
||||
typeof runtime !== 'object' ||
|
||||
typeof runtime.pods?.listNamespacedPod !== 'function' ||
|
||||
typeof runtime.openPortForward !== 'function'
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
const expectedHostname =
|
||||
`${MANAGEMENT_NAME}.${config.namespace}.svc`;
|
||||
return await executeClusterPluginPackageManagementClient(
|
||||
{
|
||||
configFile: paths.configFile,
|
||||
commandFile: paths.commandFile,
|
||||
assertionFile: paths.assertionFile,
|
||||
},
|
||||
{
|
||||
async connect(target) {
|
||||
if (
|
||||
target.hostname !== expectedHostname ||
|
||||
target.port !== MANAGEMENT_PORT
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const list = await deadline(
|
||||
runtime.pods.listNamespacedPod({
|
||||
namespace: config.namespace,
|
||||
labelSelector: MANAGEMENT_LABEL_SELECTOR,
|
||||
limit: 3,
|
||||
timeoutSeconds: Math.ceil(config.apiTimeoutMs / 1_000),
|
||||
watch: false,
|
||||
}),
|
||||
config.apiTimeoutMs,
|
||||
);
|
||||
const podName = selectManagementPod(
|
||||
list,
|
||||
config.namespace,
|
||||
);
|
||||
return await deadline(
|
||||
runtime.openPortForward({
|
||||
namespace: config.namespace,
|
||||
podName,
|
||||
port: MANAGEMENT_PORT,
|
||||
}),
|
||||
config.apiTimeoutMs,
|
||||
async (connection) => {
|
||||
await connection.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError &&
|
||||
error.cause instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError
|
||||
) {
|
||||
throw error.cause;
|
||||
}
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError ||
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError ||
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementClientConfigurationError ||
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError ||
|
||||
error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError(
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
kubernetesConfigBytes?.fill(0);
|
||||
kubeconfigBytes?.fill(0);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
/** One-shot Kubernetes-tunneled Plugin Package management client CLI boundary. */
|
||||
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRemoteError,
|
||||
} from '../../management-support/pluginPackageManagementClient';
|
||||
import {
|
||||
executeClusterPluginPackageManagementKubernetesClient,
|
||||
} from './pluginPackageManagementKubernetesClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-plugin-package-client-kubernetes ' +
|
||||
'--config=/absolute/client.json --command=/absolute/command.json ' +
|
||||
'--assertion=/absolute/assertion.jwt ' +
|
||||
'--kubernetes=/absolute/kubernetes.json';
|
||||
|
||||
function parseArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
kubernetesFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 4) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match =
|
||||
/^--(config|command|assertion|kubernetes)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion') ||
|
||||
!values.has('kubernetes')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
kubernetesFile: values.get('kubernetes')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component:
|
||||
'qinglong3-plugin-package-management-kubernetes-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' &&
|
||||
candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = parseArguments(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component:
|
||||
'qinglong3-plugin-package-management-kubernetes-client',
|
||||
event: 'usage_invalid',
|
||||
code:
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_KUBERNETES_CLIENT_USAGE_INVALID',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
await executeClusterPluginPackageManagementKubernetesClient(paths);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component:
|
||||
'qinglong3-plugin-package-management-kubernetes-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
/** Optional bounded Plugin Package management process composition boundary. */
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type {
|
||||
ObservePluginPackagePublisherTrustSnapshotInput,
|
||||
ObservePluginPackagePublisherTrustSnapshotResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust';
|
||||
import {
|
||||
assertPostgresPackageManagerSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresPluginPackageIdentityKeysetLedgerRepository,
|
||||
PostgresPluginPackageManagementQuotaRepository,
|
||||
PostgresPluginPackagePublisherTrustAuthorityRepository,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-manager';
|
||||
|
||||
import {
|
||||
createClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetSnapshot,
|
||||
} from '../../management-support/pluginPackageIdentityKeyset';
|
||||
import { createClusterPluginPackageManagementService } from './pluginPackageManagement';
|
||||
import { createClusterPluginPackageLifecycleManagementService } from '../lifecycle/pluginPackageLifecycleManagement';
|
||||
import {
|
||||
loadClusterPluginPackagePublisherTrustFileEvidence,
|
||||
type ClusterPluginPackagePublisherTrustFileEvidence,
|
||||
} from '../recovery/pluginPackageRecoveryProcess';
|
||||
import { createClusterPluginPackagePublisherTrustManagementService } from '../publisher/pluginPackagePublisherTrustManagement';
|
||||
import {
|
||||
startClusterPluginPackageManagementHttp,
|
||||
type ClusterPluginPackageManagementHttpApplication,
|
||||
type StartClusterPluginPackageManagementHttpOptions,
|
||||
} from '../../management-support/pluginPackageManagementHttp';
|
||||
import { createClusterPluginPackageManagementTransport } from './pluginPackageManagementTransport';
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
readManagementTlsFile,
|
||||
} from '../../management-support/managementProcessSupport';
|
||||
|
||||
const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.:-]{0,254}$/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
|
||||
export type ClusterPluginPackageManagementProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterPluginPackageManagementProcessConfig =
|
||||
| Readonly<{
|
||||
enabled: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
host: string;
|
||||
port: number;
|
||||
certificateFile: string;
|
||||
privateKeyFile: string;
|
||||
identityKeysetFile: string;
|
||||
publisherTrust: Readonly<{
|
||||
file: string;
|
||||
authorityProjectId: string;
|
||||
authorityId: string;
|
||||
observerId: string;
|
||||
}>;
|
||||
approvalLifetimeMs: number;
|
||||
quota: Readonly<{
|
||||
windowMs: number;
|
||||
proposeLimit: number;
|
||||
decideLimit: number;
|
||||
inspectLimit: number;
|
||||
}>;
|
||||
http: Readonly<{
|
||||
maxBodyBytes: number;
|
||||
maxConnections: number;
|
||||
maxConcurrentRequests: number;
|
||||
requestTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
rateWindowMs: number;
|
||||
peerRequestLimit: number;
|
||||
globalRequestLimit: number;
|
||||
maxRateLimitPeers: number;
|
||||
}>;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterPluginPackageManagementProcessRuntime =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
close(): Promise<void>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
address: Readonly<{ host: string; port: number }>;
|
||||
database: PostgresSchemaReadinessReport;
|
||||
identity: ClusterPluginPackageIdentityKeysetSnapshot;
|
||||
publisherTrust: Readonly<{
|
||||
generation: number;
|
||||
baseSnapshotDigest: string;
|
||||
effectiveTrustDigest: string;
|
||||
}>;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export interface StartClusterPluginPackageManagementProcessOptions {
|
||||
readonly environment: ClusterPluginPackageManagementProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly identities?: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly publisherTrustEvidence?: ClusterPluginPackagePublisherTrustFileEvidence;
|
||||
readonly observePublisherTrust?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
input: ObservePluginPackagePublisherTrustSnapshotInput,
|
||||
) => Promise<Readonly<ObservePluginPackagePublisherTrustSnapshotResult>>;
|
||||
readonly assertReady?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
) => Promise<PostgresSchemaReadinessReport>;
|
||||
readonly startHttp?: (
|
||||
options: StartClusterPluginPackageManagementHttpOptions,
|
||||
) => Promise<Readonly<ClusterPluginPackageManagementHttpApplication>>;
|
||||
readonly now?: () => number;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_MANAGEMENT_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Plugin Package management process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageManagementProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function configFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageManagementProcessConfigError {
|
||||
return new ClusterPluginPackageManagementProcessConfigError(message);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
configFailure,
|
||||
required,
|
||||
);
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function absoluteFile(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
return absoluteManagementEnvironmentFile(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_PACKAGE_MANAGER_URL',
|
||||
host: 'QL3_POSTGRES_PACKAGE_MANAGER_HOST',
|
||||
port: 'QL3_POSTGRES_PACKAGE_MANAGER_PORT',
|
||||
database: 'QL3_POSTGRES_PACKAGE_MANAGER_DATABASE',
|
||||
user: 'QL3_POSTGRES_PACKAGE_MANAGER_USER',
|
||||
password: 'QL3_POSTGRES_PACKAGE_MANAGER_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw configFailure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Package manager connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_PACKAGE_MANAGER_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(environment, 'QL3_POSTGRES_PACKAGE_MANAGER_ALLOW_INSECURE')
|
||||
) {
|
||||
throw configFailure(
|
||||
'disabling Package manager PostgreSQL TLS requires QL3_POSTGRES_PACKAGE_MANAGER_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_TLS_CA_FILE is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-plugin-package-manager';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? { mode: 'disable' as const }
|
||||
: {
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
},
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_MAX_CONNECTIONS',
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_PACKAGE_MANAGER_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageManagementProcessConfig(
|
||||
environment: ClusterPluginPackageManagementProcessEnvironment,
|
||||
): Readonly<ClusterPluginPackageManagementProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw configFailure('environment is invalid');
|
||||
}
|
||||
if (!booleanValue(environment, 'QL3_PLUGIN_PACKAGE_MANAGEMENT_ENABLED')) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw configFailure(
|
||||
'QL3_PROFILE must be cluster-admin when management is enabled',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
boundedValue(environment, 'QL3_PLUGIN_PACKAGE_MANAGEMENT_HOST', 255) ??
|
||||
'0.0.0.0';
|
||||
if (!SAFE_HOST.test(host)) {
|
||||
throw configFailure('QL3_PLUGIN_PACKAGE_MANAGEMENT_HOST is invalid');
|
||||
}
|
||||
const http = Object.freeze({
|
||||
maxBodyBytes: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_BODY_BYTES',
|
||||
64 * 1024,
|
||||
1_024,
|
||||
256 * 1024,
|
||||
),
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_CONNECTIONS',
|
||||
64,
|
||||
1,
|
||||
512,
|
||||
),
|
||||
maxConcurrentRequests: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
|
||||
32,
|
||||
1,
|
||||
256,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_REQUEST_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_DRAIN_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
rateWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
peerRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_PEER_REQUEST_LIMIT',
|
||||
60,
|
||||
1,
|
||||
10_000,
|
||||
),
|
||||
globalRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
|
||||
600,
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
maxRateLimitPeers: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_MAX_RATE_LIMIT_PEERS',
|
||||
1_024,
|
||||
1,
|
||||
16_384,
|
||||
),
|
||||
});
|
||||
if (http.globalRequestLimit < http.peerRequestLimit) {
|
||||
throw configFailure(
|
||||
'global request limit cannot be below the peer request limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
host,
|
||||
port: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_PORT',
|
||||
8_443,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
certificateFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_TLS_CERT_FILE',
|
||||
),
|
||||
privateKeyFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_TLS_KEY_FILE',
|
||||
),
|
||||
identityKeysetFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_IDENTITY_KEYSET_FILE',
|
||||
),
|
||||
publisherTrust: Object.freeze({
|
||||
file: absoluteFile(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_PUBLISHER_TRUST_FILE',
|
||||
),
|
||||
authorityProjectId: boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_AUTHORITY_PROJECT_ID',
|
||||
128,
|
||||
true,
|
||||
)!,
|
||||
authorityId:
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_AUTHORITY_ID',
|
||||
128,
|
||||
) ?? 'cluster',
|
||||
observerId:
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_OBSERVER_ID',
|
||||
128,
|
||||
) ?? 'cluster-package-manager',
|
||||
}),
|
||||
approvalLifetimeMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_APPROVAL_LIFETIME_MS',
|
||||
15 * 60_000,
|
||||
1_000,
|
||||
24 * 60 * 60_000,
|
||||
),
|
||||
quota: Object.freeze({
|
||||
windowMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_QUOTA_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
proposeLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_PROPOSE_QUOTA',
|
||||
30,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
decideLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_DECIDE_QUOTA',
|
||||
60,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
inspectLimit: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_INSPECT_QUOTA',
|
||||
600,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
}),
|
||||
http,
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function readTlsFile(filePath: string, privateMaterial: boolean): Buffer {
|
||||
return readManagementTlsFile(filePath, privateMaterial, configFailure);
|
||||
}
|
||||
|
||||
export async function startClusterPluginPackageManagementProcess(
|
||||
options: StartClusterPluginPackageManagementProcessOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageManagementProcessRuntime>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'environment',
|
||||
'openDatabase',
|
||||
'identities',
|
||||
'publisherTrustEvidence',
|
||||
'observePublisherTrust',
|
||||
'assertReady',
|
||||
'startHttp',
|
||||
'now',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
!options.environment ||
|
||||
typeof options.environment !== 'object' ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.identities !== undefined &&
|
||||
(typeof options.identities.reload !== 'function' ||
|
||||
typeof options.identities.bind !== 'function')) ||
|
||||
(options.publisherTrustEvidence !== undefined &&
|
||||
(!options.publisherTrustEvidence ||
|
||||
typeof options.publisherTrustEvidence !== 'object' ||
|
||||
!options.publisherTrustEvidence.registry ||
|
||||
!options.publisherTrustEvidence.snapshot)) ||
|
||||
(options.observePublisherTrust !== undefined &&
|
||||
typeof options.observePublisherTrust !== 'function') ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.startHttp !== undefined &&
|
||||
typeof options.startHttp !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw configFailure('options are invalid');
|
||||
}
|
||||
const config = loadClusterPluginPackageManagementProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
let http: Readonly<ClusterPluginPackageManagementHttpApplication> | undefined;
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let unavailableError: unknown;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics do not own availability or cleanup.
|
||||
}
|
||||
};
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'package-manager',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError(error) {
|
||||
const firstAvailabilityError = unavailableError === undefined;
|
||||
unavailableError ??= error;
|
||||
http?.withdraw(error);
|
||||
if (firstAvailabilityError) report(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
database = await openDatabase();
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresPackageManagerSchemaReady
|
||||
)(database.pool);
|
||||
if (unavailableError !== undefined) throw unavailableError;
|
||||
const identities =
|
||||
options.identities ??
|
||||
createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath: config.identityKeysetFile,
|
||||
now,
|
||||
ledger: new PostgresPluginPackageIdentityKeysetLedgerRepository(
|
||||
database.pool,
|
||||
),
|
||||
});
|
||||
const identity = await identities.reload();
|
||||
const publisherTrustEvidence =
|
||||
options.publisherTrustEvidence ??
|
||||
loadClusterPluginPackagePublisherTrustFileEvidence(
|
||||
config.publisherTrust.file,
|
||||
);
|
||||
const publisherTrustObservation = await (
|
||||
options.observePublisherTrust ??
|
||||
(async (pool, input) =>
|
||||
new PostgresPluginPackagePublisherTrustAuthorityRepository(
|
||||
pool,
|
||||
).observeSnapshot(input))
|
||||
)(database.pool, {
|
||||
authorityId: config.publisherTrust.authorityId,
|
||||
snapshot: publisherTrustEvidence.snapshot,
|
||||
observedBy: config.publisherTrust.observerId,
|
||||
observedAtMs: now(),
|
||||
});
|
||||
const quota = new PostgresPluginPackageManagementQuotaRepository(
|
||||
database.pool,
|
||||
{
|
||||
windowMs: config.quota.windowMs,
|
||||
limits: {
|
||||
'plugin-package.propose': config.quota.proposeLimit,
|
||||
'plugin-package.decide': config.quota.decideLimit,
|
||||
'plugin-package.inspect': config.quota.inspectLimit,
|
||||
},
|
||||
},
|
||||
);
|
||||
const service = createClusterPluginPackageManagementService({
|
||||
pool: database.pool,
|
||||
approvalLifetimeMs: config.approvalLifetimeMs,
|
||||
now,
|
||||
quota,
|
||||
});
|
||||
const lifecycle =
|
||||
createClusterPluginPackageLifecycleManagementService({
|
||||
pool: database.pool,
|
||||
approvalLifetimeMs: config.approvalLifetimeMs,
|
||||
now,
|
||||
});
|
||||
const publisherTrust =
|
||||
createClusterPluginPackagePublisherTrustManagementService({
|
||||
pool: database.pool,
|
||||
authorityProjectId: config.publisherTrust.authorityProjectId,
|
||||
trustAuthorityId: config.publisherTrust.authorityId,
|
||||
materialSnapshot: publisherTrustEvidence.snapshot,
|
||||
approvalLifetimeMs: config.approvalLifetimeMs,
|
||||
now,
|
||||
quota,
|
||||
});
|
||||
const transport = createClusterPluginPackageManagementTransport({
|
||||
service,
|
||||
lifecycle,
|
||||
publisherTrust,
|
||||
now,
|
||||
});
|
||||
const privateKey = readTlsFile(config.privateKeyFile, true);
|
||||
try {
|
||||
const certificate = readTlsFile(config.certificateFile, false);
|
||||
http = await (
|
||||
options.startHttp ?? startClusterPluginPackageManagementHttp
|
||||
)({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: { privateKey, certificate },
|
||||
transport,
|
||||
identities,
|
||||
limits: config.http,
|
||||
now,
|
||||
onError: report,
|
||||
});
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
if (unavailableError !== undefined) {
|
||||
http.withdraw(unavailableError);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: http.address,
|
||||
database: evidence,
|
||||
identity,
|
||||
publisherTrust: Object.freeze({
|
||||
generation: publisherTrustObservation.head.generation,
|
||||
baseSnapshotDigest:
|
||||
publisherTrustObservation.head.baseSnapshotDigest,
|
||||
effectiveTrustDigest:
|
||||
publisherTrustObservation.head.effectiveTrustDigest,
|
||||
}),
|
||||
availabilityStatus: () => http?.availabilityStatus() ?? 'stopped',
|
||||
close(): Promise<void> {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = (async () => {
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
await http?.close();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await http?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+1150
File diff suppressed because it is too large
Load Diff
+234
@@ -0,0 +1,234 @@
|
||||
// Cluster Plugin Package publisher boundary; keep provenance recovery authority explicit.
|
||||
import {
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
type PluginPackageInstallCommit,
|
||||
type PluginPackageInstallCreate,
|
||||
type PluginPackageInstallRecord,
|
||||
type PluginPackageInstallRecoveryCursor,
|
||||
type PluginPackageInstallRecoveryPage,
|
||||
type PluginPackageInstallRepository,
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
normalizePluginPackageStageEvidence,
|
||||
type PluginPackageStageEvidence,
|
||||
} from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import {
|
||||
createPluginPackagePublisherProvenance,
|
||||
type PluginPackagePublisherProvenance,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-provenance';
|
||||
import {
|
||||
PostgresPluginPackageInstallRepository,
|
||||
} from '@qinglong/cluster-postgres/plugin-package-install';
|
||||
import {
|
||||
POSTGRES_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGE_LIMIT,
|
||||
PostgresPluginPackagePublisherProvenanceRepository,
|
||||
type PluginPackagePublisherProvenanceRecoveryCursor,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import type { ClusterPluginPackageStageAuthority } from '../recovery/pluginPackageOciStage';
|
||||
|
||||
export const MAX_CLUSTER_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGES = 64;
|
||||
|
||||
export interface ClusterPluginPackagePublisherProvenanceRecoveryResult {
|
||||
readonly pages: number;
|
||||
readonly scanned: number;
|
||||
readonly created: number;
|
||||
readonly existing: number;
|
||||
readonly remaining: boolean;
|
||||
readonly safeToAdmit: boolean;
|
||||
}
|
||||
|
||||
function stageEvidence(
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
): Readonly<PluginPackageStageEvidence> {
|
||||
if (record.stageReceipt === null) {
|
||||
throw new TypeError(
|
||||
'Plugin Package install lacks durable stage evidence for provenance',
|
||||
);
|
||||
}
|
||||
return normalizePluginPackageStageEvidence({
|
||||
stageRef: record.stageReceipt.stageRef,
|
||||
artifactDigest: record.stageReceipt.artifactDigest,
|
||||
manifestDigest: record.stageReceipt.manifestDigest,
|
||||
contentDigest: record.stageReceipt.contentDigest,
|
||||
evidenceDigest: record.stageReceipt.evidenceDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function provenanceFor(
|
||||
authority: ClusterPluginPackageStageAuthority,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
): Promise<Readonly<PluginPackagePublisherProvenance>> {
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
const stage = stageEvidence(record);
|
||||
const signature = await authority.publisherEvidence(lock, stage);
|
||||
return createPluginPackagePublisherProvenance({
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
installationId: record.installationId,
|
||||
lockDigest: record.lockDigest,
|
||||
artifactDigest: stage.artifactDigest,
|
||||
manifestDigest: stage.manifestDigest,
|
||||
contentDigest: stage.contentDigest,
|
||||
stageEvidenceDigest: stage.evidenceDigest,
|
||||
signature,
|
||||
});
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageProvenanceInstallRepository
|
||||
implements PluginPackageInstallRepository
|
||||
{
|
||||
constructor(
|
||||
private readonly installs: PostgresPluginPackageInstallRepository,
|
||||
private readonly provenance: PostgresPluginPackagePublisherProvenanceRepository,
|
||||
private readonly authority: ClusterPluginPackageStageAuthority,
|
||||
private readonly trustAuthorityId: string,
|
||||
) {
|
||||
if (
|
||||
!(installs instanceof PostgresPluginPackageInstallRepository) ||
|
||||
!(provenance instanceof
|
||||
PostgresPluginPackagePublisherProvenanceRepository) ||
|
||||
!authority ||
|
||||
typeof authority.publisherEvidence !== 'function' ||
|
||||
typeof trustAuthorityId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(trustAuthorityId)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package provenance install repository is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
find(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<Readonly<PluginPackageInstallRecord> | null> {
|
||||
return this.installs.find(projectId, packageName);
|
||||
}
|
||||
|
||||
findLock(
|
||||
lockDigest: string,
|
||||
): Promise<Readonly<PluginPackageLock> | null> {
|
||||
return this.installs.findLock(lockDigest);
|
||||
}
|
||||
|
||||
create(command: Readonly<PluginPackageInstallCreate>): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
record: Readonly<PluginPackageInstallRecord>;
|
||||
}>
|
||||
> {
|
||||
return this.installs.create(command);
|
||||
}
|
||||
|
||||
async commit(command: Readonly<PluginPackageInstallCommit>): Promise<
|
||||
Readonly<{
|
||||
status: 'committed' | 'existing';
|
||||
record: Readonly<PluginPackageInstallRecord>;
|
||||
}>
|
||||
> {
|
||||
if (command.record.state !== 'staged') {
|
||||
return this.installs.commit(command);
|
||||
}
|
||||
const lock = await this.installs.findLock(command.record.lockDigest);
|
||||
if (!lock) {
|
||||
throw new TypeError('Plugin Package stage lock is unavailable');
|
||||
}
|
||||
return this.provenance.commitStage(
|
||||
command,
|
||||
await provenanceFor(this.authority, lock, command.record),
|
||||
this.trustAuthorityId,
|
||||
);
|
||||
}
|
||||
|
||||
listRecoveryPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<PluginPackageInstallRecoveryCursor>;
|
||||
}): Promise<Readonly<PluginPackageInstallRecoveryPage>> {
|
||||
return this.installs.listRecoveryPage(options);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverClusterPluginPackagePublisherProvenance(
|
||||
installs: PostgresPluginPackageInstallRepository,
|
||||
repository: PostgresPluginPackagePublisherProvenanceRepository,
|
||||
authority: ClusterPluginPackageStageAuthority,
|
||||
options: Readonly<{
|
||||
trustAuthorityId: string;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
}>,
|
||||
): Promise<Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>> {
|
||||
const pageSize = options.pageSize ?? 16;
|
||||
const maxPages = options.maxPages ?? 16;
|
||||
if (
|
||||
!(installs instanceof PostgresPluginPackageInstallRepository) ||
|
||||
!(repository instanceof
|
||||
PostgresPluginPackagePublisherProvenanceRepository) ||
|
||||
!authority ||
|
||||
typeof authority.verify !== 'function' ||
|
||||
typeof authority.publisherEvidence !== 'function' ||
|
||||
typeof options.trustAuthorityId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(
|
||||
options.trustAuthorityId,
|
||||
) ||
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > POSTGRES_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGE_LIMIT ||
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_CLUSTER_PLUGIN_PACKAGE_PROVENANCE_RECOVERY_PAGES
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package provenance recovery configuration is invalid',
|
||||
);
|
||||
}
|
||||
let after:
|
||||
| Readonly<PluginPackagePublisherProvenanceRecoveryCursor>
|
||||
| undefined;
|
||||
const counts = {
|
||||
pages: 0,
|
||||
scanned: 0,
|
||||
created: 0,
|
||||
existing: 0,
|
||||
};
|
||||
let exhausted = false;
|
||||
while (counts.pages < maxPages) {
|
||||
const page = await repository.listMissingPage({
|
||||
limit: pageSize,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
counts.pages += 1;
|
||||
for (const record of page.records) {
|
||||
const lock = await installs.findLock(record.lockDigest);
|
||||
if (!lock || record.stageReceipt === null) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package provenance recovery source is incomplete',
|
||||
);
|
||||
}
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
await authority.verify(lock, record.stageReceipt);
|
||||
const result = await repository.recordExisting(
|
||||
record,
|
||||
await provenanceFor(authority, lock, record),
|
||||
options.trustAuthorityId,
|
||||
);
|
||||
counts.scanned += 1;
|
||||
counts[result.status] += 1;
|
||||
}
|
||||
if (!page.truncated) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
after = page.next;
|
||||
}
|
||||
const probe = await repository.listMissingPage({ limit: 1 });
|
||||
const remaining = !exhausted || probe.records.length > 0;
|
||||
return Object.freeze({
|
||||
...counts,
|
||||
remaining,
|
||||
safeToAdmit: !remaining,
|
||||
});
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// Cluster Plugin Package publisher boundary; keep revocation authority explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizePluginPackagePublisherRevocationReceipt,
|
||||
type PluginPackagePublisherRevocationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-provenance';
|
||||
import {
|
||||
createPluginPackageQuarantineEvent,
|
||||
pluginPackageQuarantineMutationId,
|
||||
} from '@qinglong/runtime-core/plugin-package-quarantine';
|
||||
import {
|
||||
PostgresPluginPackagePublisherProvenanceRepository,
|
||||
PostgresPluginPackageQuarantineRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT,
|
||||
createClusterPluginPackageQuarantineService,
|
||||
} from '../lifecycle/pluginPackageQuarantine';
|
||||
|
||||
export const MAX_CLUSTER_PLUGIN_PACKAGE_REVOCATION_PAGES = 64;
|
||||
|
||||
export interface RunClusterPluginPackagePublisherRevocationOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly receipt: Readonly<PluginPackagePublisherRevocationReceipt>;
|
||||
readonly confirmAuthorization: (
|
||||
receipt: Readonly<PluginPackagePublisherRevocationReceipt>,
|
||||
) => void | Promise<void>;
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly receiptStatus: 'created' | 'existing';
|
||||
readonly receiptDigest: string;
|
||||
readonly impactDigest: string;
|
||||
readonly impacted: number;
|
||||
readonly pages: number;
|
||||
readonly quarantined: number;
|
||||
readonly existing: number;
|
||||
readonly remaining: boolean;
|
||||
readonly safeToAdmit: boolean;
|
||||
}
|
||||
|
||||
function normalizedOptions(
|
||||
options: RunClusterPluginPackagePublisherRevocationOptions,
|
||||
): Readonly<{
|
||||
receipt: Readonly<PluginPackagePublisherRevocationReceipt>;
|
||||
pageSize: number;
|
||||
maxPages: number;
|
||||
}> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'openDatabase',
|
||||
'receipt',
|
||||
'confirmAuthorization',
|
||||
'pageSize',
|
||||
'maxPages',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package publisher revocation options are invalid',
|
||||
);
|
||||
}
|
||||
const pageSize = options.pageSize ?? 64;
|
||||
const maxPages = options.maxPages ?? 32;
|
||||
if (
|
||||
!Number.isSafeInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT ||
|
||||
!Number.isSafeInteger(maxPages) ||
|
||||
maxPages < 1 ||
|
||||
maxPages > MAX_CLUSTER_PLUGIN_PACKAGE_REVOCATION_PAGES
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package publisher revocation bounds are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
receipt: normalizePluginPackagePublisherRevocationReceipt(options.receipt),
|
||||
pageSize,
|
||||
maxPages,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived administration composition. The immutable revocation receipt
|
||||
* and its bounded impact are committed before quarantine materialization.
|
||||
* Re-running the same receipt converges on the same facts and skips targets
|
||||
* already quarantined or superseded by a newer installation head.
|
||||
*/
|
||||
export async function runClusterPluginPackagePublisherRevocation(
|
||||
options: RunClusterPluginPackagePublisherRevocationOptions,
|
||||
): Promise<Readonly<ClusterPluginPackagePublisherRevocationRun>> {
|
||||
const normalized = normalizedOptions(options);
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let result: Readonly<ClusterPluginPackagePublisherRevocationRun> | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const provenance =
|
||||
new PostgresPluginPackagePublisherProvenanceRepository(database.pool);
|
||||
const quarantine = createClusterPluginPackageQuarantineService(
|
||||
new PostgresPluginPackageQuarantineRepository(database.pool),
|
||||
);
|
||||
const impactResult = await provenance.recordRevocationImpact(
|
||||
normalized.receipt,
|
||||
() => options.confirmAuthorization(normalized.receipt),
|
||||
);
|
||||
let pages = 0;
|
||||
let quarantined = 0;
|
||||
let existing = 0;
|
||||
while (pages < normalized.maxPages) {
|
||||
const page = await provenance.listPendingQuarantineTargets(
|
||||
impactResult.impact.impactDigest,
|
||||
normalized.pageSize,
|
||||
);
|
||||
if (page.targets.length === 0) break;
|
||||
const events = page.targets.map((target) =>
|
||||
createPluginPackageQuarantineEvent({
|
||||
mutationId: pluginPackageQuarantineMutationId(
|
||||
normalized.receipt.receiptDigest,
|
||||
target,
|
||||
),
|
||||
revocationReceiptDigest: normalized.receipt.receiptDigest,
|
||||
impactDigest: impactResult.impact.impactDigest,
|
||||
target,
|
||||
proposer: normalized.receipt.proposer,
|
||||
confirmer: normalized.receipt.confirmer,
|
||||
authorizationMode: normalized.receipt.authorizationMode,
|
||||
reasonCode: normalized.receipt.reasonCode,
|
||||
occurredAtMs: normalized.receipt.revokedAtMs,
|
||||
}),
|
||||
);
|
||||
const quarantineResults = await quarantine.quarantine(
|
||||
events,
|
||||
() => options.confirmAuthorization(normalized.receipt),
|
||||
);
|
||||
pages += 1;
|
||||
for (const item of quarantineResults) {
|
||||
if (item.status === 'created') quarantined += 1;
|
||||
else existing += 1;
|
||||
}
|
||||
}
|
||||
const probe = await provenance.listPendingQuarantineTargets(
|
||||
impactResult.impact.impactDigest,
|
||||
1,
|
||||
);
|
||||
const remaining = probe.targets.length > 0;
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
receiptStatus: impactResult.status,
|
||||
receiptDigest: normalized.receipt.receiptDigest,
|
||||
impactDigest: impactResult.impact.impactDigest,
|
||||
impacted: impactResult.impact.items.length,
|
||||
pages,
|
||||
quarantined,
|
||||
existing,
|
||||
remaining,
|
||||
safeToAdmit: !remaining,
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
|
||||
if (database) {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Cluster Plugin Package publisher revocation failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
'Cluster Plugin Package publisher revocation produced no result',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
// Cluster Plugin Package publisher boundary; keep approval consumption authority explicit.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import {
|
||||
PostgresPluginPackagePublisherRevocationProposalRepository,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PUBLISHER_APPROVAL_BATCH_LIMIT = 16;
|
||||
|
||||
export interface ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationApprovalSummary {
|
||||
readonly scanned: number;
|
||||
readonly consumed: number;
|
||||
readonly existing: number;
|
||||
readonly expired: number;
|
||||
readonly blocked: number;
|
||||
}
|
||||
|
||||
function stableDigest(domain: string, value: string): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(value)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function stableId(prefix: string, domain: string, value: string): string {
|
||||
return `${prefix}-${stableDigest(domain, value)}`;
|
||||
}
|
||||
|
||||
function stableAuditEventId(requestId: string): string {
|
||||
const bytes = Buffer.from(
|
||||
stableDigest(
|
||||
'qinglong/plugin-package-publisher-revocation-consume-audit@v1',
|
||||
requestId,
|
||||
),
|
||||
'hex',
|
||||
);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = bytes.toString('hex');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
|
||||
12,
|
||||
16,
|
||||
)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
export async function consumeClusterPluginPackagePublisherRevocationApprovals(
|
||||
options: ConsumeClusterPluginPackagePublisherRevocationApprovalsOptions,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherRevocationApprovalSummary>
|
||||
> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => !['pool', 'now', 'limit'].includes(key),
|
||||
) ||
|
||||
!options.pool ||
|
||||
typeof options.pool.query !== 'function' ||
|
||||
typeof options.pool.connect !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher revocation approval consumer options are invalid',
|
||||
);
|
||||
}
|
||||
const limit =
|
||||
options.limit ?? CLUSTER_PLUGIN_PACKAGE_PUBLISHER_APPROVAL_BATCH_LIMIT;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new TypeError(
|
||||
'publisher revocation approval consumer limit is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError(
|
||||
'publisher revocation approval consumer clock is invalid',
|
||||
);
|
||||
}
|
||||
const proposals =
|
||||
new PostgresPluginPackagePublisherRevocationProposalRepository(
|
||||
options.pool,
|
||||
);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
const requests = await proposals.listApprovedRequests(limit);
|
||||
let consumed = 0;
|
||||
let existing = 0;
|
||||
let expired = 0;
|
||||
let blocked = 0;
|
||||
for (const request of requests) {
|
||||
if (observedAtMs >= request.expiresAtMs) {
|
||||
expired += 1;
|
||||
continue;
|
||||
}
|
||||
const decision = await policy.decide({
|
||||
subject: request.requestedBy,
|
||||
projectId: request.projectId,
|
||||
permission: 'package.manage',
|
||||
});
|
||||
if (
|
||||
decision.fence === null ||
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval')
|
||||
) {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const consumptionId = stableId(
|
||||
'pprc',
|
||||
'qinglong/plugin-package-publisher-revocation-consumption@v1',
|
||||
request.id,
|
||||
);
|
||||
const dispatchId = stableId(
|
||||
'pprd',
|
||||
'qinglong/plugin-package-publisher-revocation-dispatch@v1',
|
||||
request.id,
|
||||
);
|
||||
const result = await approvals.consume({
|
||||
requestId: request.id,
|
||||
expectedVersion: request.version,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: request.action,
|
||||
requestedBy: request.requestedBy,
|
||||
consumedBy: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
consumedAtMs: observedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: {
|
||||
eventId: stableAuditEventId(request.id),
|
||||
requestId: request.id,
|
||||
operationId: 'approval.consume',
|
||||
projectId: request.projectId,
|
||||
subject: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
authenticationId: 'cluster-package-executor',
|
||||
outcome: 'allowed',
|
||||
reasons: ['publisher_revocation_execution'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs: observedAtMs,
|
||||
},
|
||||
});
|
||||
if (result.status === 'consumed') consumed += 1;
|
||||
else existing += 1;
|
||||
}
|
||||
return Object.freeze({
|
||||
scanned: requests.length,
|
||||
consumed,
|
||||
existing,
|
||||
expired,
|
||||
blocked,
|
||||
});
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// Cluster Plugin Package publisher boundary; keep Approved Action authority explicit.
|
||||
import {
|
||||
type ApprovedActionHandler,
|
||||
type ApprovedActionHandlerExecutionContext,
|
||||
type ApprovedActionHandlerInspection,
|
||||
type ApprovedActionHandlerResult,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE,
|
||||
PluginPackagePublisherRevocationProposalBindingConflictError,
|
||||
normalizePluginPackagePublisherRevocationProposal,
|
||||
resolvePluginPackagePublisherRevocationProposal,
|
||||
type PluginPackagePublisherRevocationProposalRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-revocation-proposal';
|
||||
import type {
|
||||
PluginPackagePublisherRevocationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-provenance';
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationExecutionResult {
|
||||
readonly safeToAdmit: boolean;
|
||||
readonly receiptDigest: string;
|
||||
readonly impactDigest: string;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherRevocationExecutionPort {
|
||||
run(
|
||||
receipt: Readonly<PluginPackagePublisherRevocationReceipt>,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherRevocationExecutionResult>
|
||||
>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackagePublisherRevocationApprovedActionHandler
|
||||
implements ApprovedActionHandler
|
||||
{
|
||||
readonly actionType = PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE;
|
||||
|
||||
constructor(
|
||||
readonly proposals: PluginPackagePublisherRevocationProposalRepository,
|
||||
readonly revocations: ClusterPluginPackagePublisherRevocationExecutionPort,
|
||||
) {
|
||||
if (
|
||||
!proposals ||
|
||||
typeof proposals.findProposalByActionRef !== 'function' ||
|
||||
!revocations ||
|
||||
typeof revocations.run !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher revocation Approved Action authority is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
dispatch: ApprovedActionHandlerExecutionContext['dispatch'],
|
||||
): Promise<ApprovedActionHandlerInspection> {
|
||||
let proposal;
|
||||
try {
|
||||
proposal = await this.proposals.findProposalByActionRef(
|
||||
dispatch.action.actionRef,
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'retry',
|
||||
resultCode: 'publisher_revocation_proposal_unavailable',
|
||||
});
|
||||
}
|
||||
if (!proposal) {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_revocation_proposal_missing',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const normalized =
|
||||
normalizePluginPackagePublisherRevocationProposal(proposal);
|
||||
resolvePluginPackagePublisherRevocationProposal(
|
||||
normalized,
|
||||
dispatch,
|
||||
dispatch.createdAtMs,
|
||||
);
|
||||
return Object.freeze({
|
||||
status: 'ready',
|
||||
actionDigest: normalized.actionDigest,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_revocation_proposal_rejected',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
context: Readonly<ApprovedActionHandlerExecutionContext>,
|
||||
): Promise<Readonly<ApprovedActionHandlerResult>> {
|
||||
const startedAtMs = context.execution.startedAtMs;
|
||||
if (
|
||||
context.execution.status !== 'executing' ||
|
||||
startedAtMs === null ||
|
||||
context.execution.leaseOwner !== context.fence.owner ||
|
||||
context.execution.leaseToken !== context.fence.leaseToken ||
|
||||
context.execution.version !== context.fence.version
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_execution_rejected',
|
||||
});
|
||||
}
|
||||
const proposal = await this.proposals.findProposalByActionRef(
|
||||
context.dispatch.action.actionRef,
|
||||
);
|
||||
if (!proposal) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_proposal_missing',
|
||||
});
|
||||
}
|
||||
let receipt;
|
||||
try {
|
||||
receipt = resolvePluginPackagePublisherRevocationProposal(
|
||||
proposal,
|
||||
context.dispatch,
|
||||
startedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackagePublisherRevocationProposalBindingConflictError
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_proposal_rejected',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await this.revocations.run(receipt);
|
||||
if (!result.safeToAdmit) {
|
||||
return Object.freeze({
|
||||
outcome: 'indeterminate',
|
||||
resultCode: 'publisher_revocation_convergence_incomplete',
|
||||
});
|
||||
}
|
||||
if (
|
||||
result.receiptDigest !== receipt.receiptDigest ||
|
||||
!/^[0-9a-f]{64}$/.test(result.impactDigest)
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_revocation_result_rejected',
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: 'succeeded',
|
||||
resultCode: 'publisher_revocation_converged',
|
||||
resultDigest: result.impactDigest,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1078
File diff suppressed because it is too large
Load Diff
+178
@@ -0,0 +1,178 @@
|
||||
// Cluster Plugin Package publisher boundary; keep transition approval authority explicit.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import {
|
||||
PostgresPluginPackagePublisherTrustTransitionProposalRepository,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_APPROVAL_BATCH_LIMIT =
|
||||
16;
|
||||
|
||||
export interface ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustTransitionApprovalSummary {
|
||||
readonly scanned: number;
|
||||
readonly consumed: number;
|
||||
readonly existing: number;
|
||||
readonly expired: number;
|
||||
readonly blocked: number;
|
||||
}
|
||||
|
||||
function stableDigest(domain: string, value: string): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(value)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function stableId(prefix: string, domain: string, value: string): string {
|
||||
return `${prefix}-${stableDigest(domain, value)}`;
|
||||
}
|
||||
|
||||
function stableAuditEventId(requestId: string): string {
|
||||
const bytes = Buffer.from(
|
||||
stableDigest(
|
||||
'qinglong/plugin-package-publisher-trust-transition-consume-audit@v1',
|
||||
requestId,
|
||||
),
|
||||
'hex',
|
||||
);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = bytes.toString('hex');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
|
||||
12,
|
||||
16,
|
||||
)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
export async function consumeClusterPluginPackagePublisherTrustTransitionApprovals(
|
||||
options: ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherTrustTransitionApprovalSummary>
|
||||
> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => !['pool', 'now', 'limit'].includes(key),
|
||||
) ||
|
||||
!options.pool ||
|
||||
typeof options.pool.query !== 'function' ||
|
||||
typeof options.pool.connect !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition approval consumer options are invalid',
|
||||
);
|
||||
}
|
||||
const limit =
|
||||
options.limit ??
|
||||
CLUSTER_PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_APPROVAL_BATCH_LIMIT;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition approval consumer limit is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition approval consumer clock is invalid',
|
||||
);
|
||||
}
|
||||
const proposals =
|
||||
new PostgresPluginPackagePublisherTrustTransitionProposalRepository(
|
||||
options.pool,
|
||||
);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
const requests = await proposals.listApprovedRequests(limit);
|
||||
let consumed = 0;
|
||||
let existing = 0;
|
||||
let expired = 0;
|
||||
let blocked = 0;
|
||||
for (const request of requests) {
|
||||
if (observedAtMs >= request.expiresAtMs) {
|
||||
expired += 1;
|
||||
continue;
|
||||
}
|
||||
if (request.decisionMode !== 'separation_of_duty') {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const decision = await policy.decide({
|
||||
subject: request.requestedBy,
|
||||
projectId: request.projectId,
|
||||
permission: 'package.manage',
|
||||
});
|
||||
if (
|
||||
decision.fence === null ||
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval')
|
||||
) {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const consumptionId = stableId(
|
||||
'ppttc',
|
||||
'qinglong/plugin-package-publisher-trust-transition-consumption@v1',
|
||||
request.id,
|
||||
);
|
||||
const dispatchId = stableId(
|
||||
'ppttd',
|
||||
'qinglong/plugin-package-publisher-trust-transition-dispatch@v1',
|
||||
request.id,
|
||||
);
|
||||
const result = await approvals.consume({
|
||||
requestId: request.id,
|
||||
expectedVersion: request.version,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: request.action,
|
||||
requestedBy: request.requestedBy,
|
||||
consumedBy: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
consumedAtMs: observedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: {
|
||||
eventId: stableAuditEventId(request.id),
|
||||
requestId: request.id,
|
||||
operationId: 'approval.consume',
|
||||
projectId: request.projectId,
|
||||
subject: {
|
||||
type: 'system',
|
||||
id: 'cluster_package_executor',
|
||||
},
|
||||
authenticationId: 'cluster-package-executor',
|
||||
outcome: 'allowed',
|
||||
reasons: ['publisher_trust_transition_execution'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs: observedAtMs,
|
||||
},
|
||||
});
|
||||
if (result.status === 'consumed') consumed += 1;
|
||||
else existing += 1;
|
||||
}
|
||||
return Object.freeze({
|
||||
scanned: requests.length,
|
||||
consumed,
|
||||
existing,
|
||||
expired,
|
||||
blocked,
|
||||
});
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// Cluster Plugin Package publisher boundary; keep transition execution authority explicit.
|
||||
import {
|
||||
type ApprovedActionHandler,
|
||||
type ApprovedActionHandlerExecutionContext,
|
||||
type ApprovedActionHandlerInspection,
|
||||
type ApprovedActionHandlerResult,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES,
|
||||
PluginPackagePublisherTrustTransitionBindingConflictError,
|
||||
PluginPackagePublisherTrustTransitionConflictError,
|
||||
normalizePluginPackagePublisherTrustTransitionProposal,
|
||||
resolvePluginPackagePublisherTrustTransitionProposal,
|
||||
type PluginPackagePublisherTrustTransitionMode,
|
||||
type PluginPackagePublisherTrustTransitionProposalRepository,
|
||||
type PluginPackagePublisherTrustTransitionReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust-transition-proposal';
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustTransitionExecutionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly receipt: Readonly<PluginPackagePublisherTrustTransitionReceipt>;
|
||||
readonly head: Readonly<{
|
||||
generation: number;
|
||||
effectiveTrustDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustTransitionExecutionPort {
|
||||
applyApprovedTransition(
|
||||
input: Readonly<{
|
||||
dispatch: ApprovedActionHandlerExecutionContext['dispatch'];
|
||||
executedAtMs: number;
|
||||
}>,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackagePublisherTrustTransitionExecutionResult>
|
||||
>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler
|
||||
implements ApprovedActionHandler
|
||||
{
|
||||
readonly actionType:
|
||||
(typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES)[PluginPackagePublisherTrustTransitionMode];
|
||||
|
||||
constructor(
|
||||
readonly mode: PluginPackagePublisherTrustTransitionMode,
|
||||
readonly proposals: PluginPackagePublisherTrustTransitionProposalRepository,
|
||||
readonly transitions: ClusterPluginPackagePublisherTrustTransitionExecutionPort,
|
||||
) {
|
||||
this.actionType =
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES[mode];
|
||||
if (
|
||||
(mode !== 'overlap_add' && mode !== 'safe_retire') ||
|
||||
!proposals ||
|
||||
typeof proposals.findProposalByActionRef !== 'function' ||
|
||||
!transitions ||
|
||||
typeof transitions.applyApprovedTransition !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition Approved Action authority is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
dispatch: ApprovedActionHandlerExecutionContext['dispatch'],
|
||||
): Promise<ApprovedActionHandlerInspection> {
|
||||
let proposal;
|
||||
try {
|
||||
proposal = await this.proposals.findProposalByActionRef(
|
||||
dispatch.action.actionRef,
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'retry',
|
||||
resultCode: 'publisher_trust_transition_proposal_unavailable',
|
||||
});
|
||||
}
|
||||
if (!proposal) {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_trust_transition_proposal_missing',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const normalized =
|
||||
normalizePluginPackagePublisherTrustTransitionProposal(proposal);
|
||||
if (
|
||||
normalized.actionInput.mode !== this.mode ||
|
||||
normalized.actionType !== this.actionType
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionBindingConflictError();
|
||||
}
|
||||
resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
normalized,
|
||||
dispatch,
|
||||
dispatch.createdAtMs,
|
||||
this.mode === 'safe_retire' ? 0 : null,
|
||||
);
|
||||
return Object.freeze({
|
||||
status: 'ready',
|
||||
actionDigest: normalized.actionDigest,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
status: 'blocked',
|
||||
resultCode: 'publisher_trust_transition_proposal_rejected',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
context: Readonly<ApprovedActionHandlerExecutionContext>,
|
||||
): Promise<Readonly<ApprovedActionHandlerResult>> {
|
||||
const startedAtMs = context.execution.startedAtMs;
|
||||
if (
|
||||
context.execution.status !== 'executing' ||
|
||||
startedAtMs === null ||
|
||||
context.execution.leaseOwner !== context.fence.owner ||
|
||||
context.execution.leaseToken !== context.fence.leaseToken ||
|
||||
context.execution.version !== context.fence.version
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_trust_transition_execution_rejected',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await this.transitions.applyApprovedTransition({
|
||||
dispatch: context.dispatch,
|
||||
executedAtMs: startedAtMs,
|
||||
});
|
||||
if (
|
||||
result.receipt.mode !== this.mode ||
|
||||
result.receipt.mutationId !== context.dispatch.id ||
|
||||
result.head.generation !== result.receipt.currentGeneration ||
|
||||
result.head.effectiveTrustDigest !==
|
||||
result.receipt.currentTrustDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_trust_transition_result_rejected',
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: 'succeeded',
|
||||
resultCode:
|
||||
this.mode === 'overlap_add'
|
||||
? 'publisher_trust_overlap_added'
|
||||
: 'publisher_trust_key_retired',
|
||||
resultDigest: result.receipt.receiptDigest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackagePublisherTrustTransitionBindingConflictError ||
|
||||
error instanceof PluginPackagePublisherTrustTransitionConflictError
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
resultCode: 'publisher_trust_transition_conflict',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+565
@@ -0,0 +1,565 @@
|
||||
// Cluster Plugin Package recovery boundary; keep Kubernetes activation authority explicit.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
PluginPackageActivationConflictError,
|
||||
PluginPackageActivationUnavailableError,
|
||||
normalizePluginPackageActivationIntent,
|
||||
type PluginPackageActivationIntent,
|
||||
type PluginPackageActivationObservation,
|
||||
type PluginPackageActivationPublisher,
|
||||
} from '@qinglong/runtime-core/plugin-package-activation';
|
||||
import type {
|
||||
PluginPackageResourceGeneration,
|
||||
PluginPackageResourceGenerationSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import {
|
||||
createPluginPackageActivationReceipt,
|
||||
normalizePluginPackageActivationReceipt,
|
||||
type PluginPackageActivationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
|
||||
const ACTIVE_POINTER_SCHEMA =
|
||||
'qinglong/plugin-package-kubernetes-active-pointer@v2';
|
||||
const ACTIVE_POINTER_KEY = 'active.json';
|
||||
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
||||
const MANAGED_BY_VALUE = 'qinglong3';
|
||||
const ACTIVE_LABEL = 'qinglong.io/plugin-package-active';
|
||||
const TARGET_LABEL = 'qinglong.io/plugin-package-target';
|
||||
const INTENT_ANNOTATION = 'qinglong.io/plugin-package-intent';
|
||||
const FIELD_MANAGER = 'qinglong-plugin-package-activation';
|
||||
const MAX_ACTIVE_POINTER_BYTES = 512 * 1024;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const SAFE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
|
||||
const RESOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/+=-]{0,511}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const TARGET_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-kubernetes-target@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export interface ClusterPluginPackageStageEvidence {
|
||||
readonly lockDigest: string;
|
||||
readonly stageRef: string;
|
||||
readonly stageReceiptDigest: string;
|
||||
readonly stageEvidenceDigest: string;
|
||||
readonly contentDigest: string;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageStageEvidenceVerifier {
|
||||
verify(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<ClusterPluginPackageStageEvidence>>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesActivationPublisherOptions {
|
||||
/** Stable operator-reviewed identity for one Kubernetes API cluster. */
|
||||
readonly clusterIdentity: string;
|
||||
readonly namespace: string;
|
||||
/** Explicit authoritative clock called only for a new publication attempt. */
|
||||
readonly now: () => number | Promise<number>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesConfigMap {
|
||||
readonly apiVersion?: string;
|
||||
readonly kind?: string;
|
||||
readonly immutable?: boolean;
|
||||
readonly data?: Readonly<Record<string, string>>;
|
||||
readonly binaryData?: Readonly<Record<string, string>>;
|
||||
readonly metadata?: Readonly<{
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
deletionTimestamp?: Date;
|
||||
finalizers?: readonly string[];
|
||||
ownerReferences?: readonly Readonly<Record<string, unknown>>[];
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
annotations?: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ConfigMapWrite extends PluginPackageKubernetesConfigMap {
|
||||
readonly metadata: NonNullable<PluginPackageKubernetesConfigMap['metadata']>;
|
||||
readonly data: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesConfigMapApi {
|
||||
readNamespacedConfigMap(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesConfigMap>;
|
||||
createNamespacedConfigMap(
|
||||
request: Readonly<{
|
||||
namespace: string;
|
||||
body: ConfigMapWrite;
|
||||
fieldManager: string;
|
||||
fieldValidation: 'Strict';
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesConfigMap>;
|
||||
replaceNamespacedConfigMap(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
body: ConfigMapWrite;
|
||||
fieldManager: string;
|
||||
fieldValidation: 'Strict';
|
||||
}>,
|
||||
): Promise<PluginPackageKubernetesConfigMap>;
|
||||
}
|
||||
|
||||
interface ActivePointer {
|
||||
readonly schema: typeof ACTIVE_POINTER_SCHEMA;
|
||||
readonly clusterIdentityDigest: string;
|
||||
readonly intent: Readonly<PluginPackageActivationIntent>;
|
||||
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
||||
}
|
||||
|
||||
interface StoredPointer extends ActivePointer {
|
||||
readonly resourceVersion: string;
|
||||
}
|
||||
|
||||
function apiStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
if ('code' in error && typeof error.code === 'number') return error.code;
|
||||
if (
|
||||
'response' in error &&
|
||||
error.response &&
|
||||
typeof error.response === 'object' &&
|
||||
'statusCode' in error.response &&
|
||||
typeof error.response.statusCode === 'number'
|
||||
) {
|
||||
return error.response.statusCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function preserveDomainError(error: unknown): never {
|
||||
if (
|
||||
error instanceof PluginPackageActivationConflictError ||
|
||||
error instanceof PluginPackageActivationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageActivationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function boundedResourceId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !RESOURCE_ID.test(value)) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIntent(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Readonly<PluginPackageActivationIntent> {
|
||||
try {
|
||||
return normalizePluginPackageActivationIntent(value);
|
||||
} catch {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived Kubernetes ConfigMap publisher. It owns no timer, watcher,
|
||||
* database connection or cache; every replacement is resourceVersion fenced.
|
||||
*/
|
||||
export class PluginPackageKubernetesActivationPublisher
|
||||
implements
|
||||
PluginPackageActivationPublisher,
|
||||
PluginPackageResourceGenerationSource
|
||||
{
|
||||
readonly #clusterIdentityDigest: string;
|
||||
|
||||
constructor(
|
||||
private readonly api: PluginPackageKubernetesConfigMapApi,
|
||||
private readonly stageEvidence: ClusterPluginPackageStageEvidenceVerifier,
|
||||
private readonly options: PluginPackageKubernetesActivationPublisherOptions,
|
||||
) {
|
||||
if (
|
||||
!api ||
|
||||
typeof api.readNamespacedConfigMap !== 'function' ||
|
||||
typeof api.createNamespacedConfigMap !== 'function' ||
|
||||
typeof api.replaceNamespacedConfigMap !== 'function'
|
||||
) {
|
||||
throw new TypeError('Plugin Package Kubernetes ConfigMap API is invalid');
|
||||
}
|
||||
if (!stageEvidence || typeof stageEvidence.verify !== 'function') {
|
||||
throw new TypeError(
|
||||
'Plugin Package cluster stage evidence verifier is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).sort().join(',') !==
|
||||
'clusterIdentity,namespace,now' ||
|
||||
!SAFE_IDENTITY.test(options.clusterIdentity) ||
|
||||
!DNS_LABEL.test(options.namespace) ||
|
||||
typeof options.now !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Plugin Package Kubernetes activation options are invalid',
|
||||
);
|
||||
}
|
||||
this.#clusterIdentityDigest = createHash('sha256')
|
||||
.update('qinglong/plugin-package-kubernetes-cluster@v1\0', 'utf8')
|
||||
.update(options.clusterIdentity, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
#targetDigest(
|
||||
identity: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(TARGET_DIGEST_DOMAIN)
|
||||
.update(this.#clusterIdentityDigest, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(this.options.namespace, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(identity.projectId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(identity.packageName, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
#name(
|
||||
identity: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): string {
|
||||
return `ql3p-${this.#targetDigest(identity).slice(0, 52)}`;
|
||||
}
|
||||
|
||||
async #verifyStage(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<void> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await this.stageEvidence.verify(intent);
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
const evidence = dataRecord(value);
|
||||
exactKeys(evidence, [
|
||||
'lockDigest',
|
||||
'stageRef',
|
||||
'stageReceiptDigest',
|
||||
'stageEvidenceDigest',
|
||||
'contentDigest',
|
||||
]);
|
||||
if (
|
||||
evidence.lockDigest !== intent.lockDigest ||
|
||||
evidence.stageRef !== intent.stageRef ||
|
||||
evidence.stageReceiptDigest !== intent.stageReceiptDigest ||
|
||||
evidence.stageEvidenceDigest !== intent.stageEvidenceDigest ||
|
||||
evidence.contentDigest !== intent.contentDigest
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
#parsePointer(
|
||||
configMap: PluginPackageKubernetesConfigMap,
|
||||
expectedName: string,
|
||||
): Readonly<StoredPointer> {
|
||||
try {
|
||||
const metadata = configMap?.metadata;
|
||||
if (
|
||||
configMap.apiVersion !== 'v1' ||
|
||||
configMap.kind !== 'ConfigMap' ||
|
||||
configMap.immutable === true ||
|
||||
configMap.binaryData !== undefined ||
|
||||
!metadata ||
|
||||
metadata.name !== expectedName ||
|
||||
metadata.namespace !== this.options.namespace ||
|
||||
metadata.deletionTimestamp !== undefined ||
|
||||
(metadata.finalizers?.length ?? 0) !== 0 ||
|
||||
(metadata.ownerReferences?.length ?? 0) !== 0 ||
|
||||
!configMap.data
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const labels = dataRecord(metadata.labels);
|
||||
exactKeys(labels, [MANAGED_BY_LABEL, ACTIVE_LABEL, TARGET_LABEL]);
|
||||
const annotations = dataRecord(metadata.annotations);
|
||||
exactKeys(annotations, [INTENT_ANNOTATION]);
|
||||
const data = dataRecord(configMap.data);
|
||||
exactKeys(data, [ACTIVE_POINTER_KEY]);
|
||||
const serialized = data[ACTIVE_POINTER_KEY];
|
||||
if (
|
||||
labels[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE ||
|
||||
labels[ACTIVE_LABEL] !== 'v2' ||
|
||||
typeof serialized !== 'string' ||
|
||||
Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const pointer = dataRecord(JSON.parse(serialized));
|
||||
exactKeys(pointer, [
|
||||
'schema',
|
||||
'clusterIdentityDigest',
|
||||
'intent',
|
||||
'receipt',
|
||||
]);
|
||||
const intent = normalizeIntent(
|
||||
pointer.intent as PluginPackageActivationIntent,
|
||||
);
|
||||
const receipt = normalizePluginPackageActivationReceipt(pointer.receipt);
|
||||
const normalized: ActivePointer = Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
if (
|
||||
pointer.schema !== ACTIVE_POINTER_SCHEMA ||
|
||||
pointer.clusterIdentityDigest !== this.#clusterIdentityDigest ||
|
||||
this.#name(intent) !== expectedName ||
|
||||
labels[TARGET_LABEL] !==
|
||||
Buffer.from(this.#targetDigest(intent), 'hex').toString(
|
||||
'base64url',
|
||||
) ||
|
||||
annotations[INTENT_ANNOTATION] !== intent.intentDigest ||
|
||||
receipt.intentDigest !== intent.intentDigest ||
|
||||
receipt.generation !== intent.targetGeneration ||
|
||||
receipt.contentDigest !== intent.contentDigest ||
|
||||
`${JSON.stringify(normalized)}\n` !== serialized
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
boundedResourceId(metadata.uid);
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
resourceVersion: boundedResourceId(metadata.resourceVersion),
|
||||
});
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #optionalPointer(
|
||||
identity: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): Promise<Readonly<StoredPointer> | null> {
|
||||
const name = this.#name(identity);
|
||||
try {
|
||||
return this.#parsePointer(
|
||||
await this.api.readNamespacedConfigMap({
|
||||
name,
|
||||
namespace: this.options.namespace,
|
||||
}),
|
||||
name,
|
||||
);
|
||||
} catch (error) {
|
||||
if (apiStatus(error) === 404) return null;
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #observe(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationObservation>> {
|
||||
await this.#verifyStage(intent);
|
||||
const pointer = await this.#optionalPointer(intent);
|
||||
if (!pointer) {
|
||||
if (intent.previousActiveLockDigest !== null) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'not_published' });
|
||||
}
|
||||
if (same(pointer.intent, intent)) {
|
||||
return Object.freeze({ status: 'published', receipt: pointer.receipt });
|
||||
}
|
||||
if (
|
||||
pointer.intent.projectId === intent.projectId &&
|
||||
pointer.intent.packageName === intent.packageName &&
|
||||
pointer.intent.lockDigest === intent.previousActiveLockDigest
|
||||
) {
|
||||
return Object.freeze({ status: 'not_published' });
|
||||
}
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
|
||||
#body(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
receipt: Readonly<PluginPackageActivationReceipt>,
|
||||
current: Readonly<StoredPointer> | null,
|
||||
): ConfigMapWrite {
|
||||
const targetDigest = this.#targetDigest(intent);
|
||||
const pointer: Readonly<ActivePointer> = Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA,
|
||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
const serialized = `${JSON.stringify(pointer)}\n`;
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
apiVersion: 'v1',
|
||||
kind: 'ConfigMap',
|
||||
immutable: false,
|
||||
metadata: Object.freeze({
|
||||
name: this.#name(intent),
|
||||
namespace: this.options.namespace,
|
||||
...(current ? { resourceVersion: current.resourceVersion } : {}),
|
||||
labels: Object.freeze({
|
||||
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
|
||||
[ACTIVE_LABEL]: 'v2',
|
||||
[TARGET_LABEL]: Buffer.from(targetDigest, 'hex').toString(
|
||||
'base64url',
|
||||
),
|
||||
}),
|
||||
annotations: Object.freeze({
|
||||
[INTENT_ANNOTATION]: intent.intentDigest,
|
||||
}),
|
||||
}),
|
||||
data: Object.freeze({ [ACTIVE_POINTER_KEY]: serialized }),
|
||||
});
|
||||
}
|
||||
|
||||
async inspect(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationObservation>> {
|
||||
try {
|
||||
return await this.#observe(normalizeIntent(value));
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findActiveResourceGeneration(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<Readonly<PluginPackageResourceGeneration> | null> {
|
||||
if (
|
||||
typeof projectId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId) ||
|
||||
typeof packageName !== 'string' ||
|
||||
!DNS_LABEL.test(packageName)
|
||||
) {
|
||||
throw new TypeError('Plugin Package active resource identity is invalid');
|
||||
}
|
||||
try {
|
||||
return (
|
||||
(await this.#optionalPointer(Object.freeze({ projectId, packageName })))
|
||||
?.intent.resourceGeneration ?? null
|
||||
);
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationReceipt>> {
|
||||
const intent = normalizeIntent(value);
|
||||
try {
|
||||
const first = await this.#observe(intent);
|
||||
if (first.status === 'published') return first.receipt;
|
||||
const current = await this.#optionalPointer(intent);
|
||||
if (current && same(current.intent, intent)) return current.receipt;
|
||||
if (
|
||||
(!current && intent.previousActiveLockDigest !== null) ||
|
||||
(current &&
|
||||
(current.intent.projectId !== intent.projectId ||
|
||||
current.intent.packageName !== intent.packageName ||
|
||||
current.intent.lockDigest !== intent.previousActiveLockDigest))
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const activatedAtMs = await this.options.now();
|
||||
if (!Number.isSafeInteger(activatedAtMs) || activatedAtMs < 0) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
const receipt = createPluginPackageActivationReceipt({
|
||||
activationRef: `k8s-configmap:${this.#targetDigest(intent)}`,
|
||||
intentDigest: intent.intentDigest,
|
||||
generation: intent.targetGeneration,
|
||||
contentDigest: intent.contentDigest,
|
||||
activatedAtMs,
|
||||
});
|
||||
const body = this.#body(intent, receipt, current);
|
||||
try {
|
||||
if (current) {
|
||||
await this.api.replaceNamespacedConfigMap({
|
||||
name: this.#name(intent),
|
||||
namespace: this.options.namespace,
|
||||
body,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
});
|
||||
} else {
|
||||
await this.api.createNamespacedConfigMap({
|
||||
namespace: this.options.namespace,
|
||||
body,
|
||||
fieldManager: FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (apiStatus(error) !== 409) return preserveDomainError(error);
|
||||
const winner = await this.#observe(intent);
|
||||
if (winner.status === 'published') return winner.receipt;
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const final = await this.#observe(intent);
|
||||
if (final.status !== 'published') {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return final.receipt;
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
||||
// Cluster Plugin Package recovery boundary; keep recovery coordination authority explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
PluginPackageRecoveryCoordinator,
|
||||
type PluginPackageRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import {
|
||||
PluginPackageAutomationPublicationCoordinator,
|
||||
PluginPackageAutomationPublicationRecoveryCoordinator,
|
||||
type PluginPackageAutomationPublicationRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageResourceByteSource } from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
|
||||
PluginPackageTaskPublicationCoordinator,
|
||||
PluginPackageTaskPublicationRecoveryCoordinator,
|
||||
type PluginPackageTaskPublicationRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import {
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGES,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGE_SIZE,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_SOURCE_PAGE_SIZE,
|
||||
ProjectToolDefinitionSnapshotPublicationCoordinator,
|
||||
ProjectToolDefinitionSnapshotRecoveryCoordinator,
|
||||
type ProjectToolDefinitionSnapshotRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import { createBuiltInTaskSpecSemanticRegistry } from '@qinglong/runtime-core/task-spec-semantic';
|
||||
import {
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
PostgresPluginPackageAutomationPublicationRepository,
|
||||
PostgresPluginPackageMaterializedRevisionRepository,
|
||||
PostgresPluginPackagePublisherProvenanceRepository,
|
||||
PostgresPluginPackageTaskReconciliationRepository,
|
||||
PostgresProjectToolDefinitionSnapshotRepository,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresPluginPackageInstallRepository } from '@qinglong/cluster-postgres/plugin-package-install';
|
||||
|
||||
import {
|
||||
PluginPackageKubernetesActivationPublisher,
|
||||
type PluginPackageKubernetesConfigMapApi,
|
||||
} from './pluginPackageKubernetesActivation';
|
||||
import {
|
||||
ClusterPluginPackageOciResourceByteSource,
|
||||
ClusterPluginPackageOciStageAuthority,
|
||||
clusterPluginPackageActivationEvidence,
|
||||
pluginPackageStageVerificationFailure,
|
||||
type ClusterPluginPackageStageAuthority,
|
||||
} from './pluginPackageOciStage';
|
||||
import {
|
||||
ClusterPluginPackageProvenanceInstallRepository,
|
||||
recoverClusterPluginPackagePublisherProvenance,
|
||||
type ClusterPluginPackagePublisherProvenanceRecoveryResult,
|
||||
} from '../publisher/pluginPackagePublisherProvenanceRecovery';
|
||||
|
||||
export interface ClusterPluginPackageRecoveryOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly api: PluginPackageKubernetesConfigMapApi;
|
||||
readonly stageAuthority?: ClusterPluginPackageStageAuthority;
|
||||
readonly stageAuthorityFactory?: (
|
||||
pool: PostgresPool,
|
||||
) =>
|
||||
| ClusterPluginPackageStageAuthority
|
||||
| Promise<ClusterPluginPackageStageAuthority>;
|
||||
readonly resourceByteSource?: PluginPackageResourceByteSource;
|
||||
readonly trustAuthorityId: string;
|
||||
readonly clusterIdentity: string;
|
||||
readonly namespace: string;
|
||||
readonly now: () => number | Promise<number>;
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageRecoveryResult {
|
||||
readonly evidence: PostgresSchemaReadinessReport;
|
||||
readonly provenanceRecovery: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>;
|
||||
readonly recovery: Readonly<PluginPackageRecoveryCycleResult>;
|
||||
readonly taskPublicationRecovery: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>;
|
||||
readonly automationPublicationRecovery: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>;
|
||||
readonly toolSnapshotRecovery: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageRecoveryRequiredError extends Error {
|
||||
constructor(readonly recovery: Readonly<PluginPackageRecoveryCycleResult>) {
|
||||
super('Cluster has unresolved Plugin Package recovery work');
|
||||
this.name = 'ClusterPluginPackageRecoveryRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackagePublisherProvenanceRecoveryRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>,
|
||||
) {
|
||||
super('Cluster has unresolved Plugin Package publisher provenance work');
|
||||
this.name =
|
||||
'ClusterPluginPackagePublisherProvenanceRecoveryRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageTaskPublicationRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>,
|
||||
) {
|
||||
super('Cluster has unresolved Plugin Package Task publication work');
|
||||
this.name = 'ClusterPluginPackageTaskPublicationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageAutomationPublicationRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>,
|
||||
) {
|
||||
super(
|
||||
'Cluster has unresolved Plugin Package Workflow/Prompt publication work',
|
||||
);
|
||||
this.name = 'ClusterPluginPackageAutomationPublicationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageToolSnapshotRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>,
|
||||
) {
|
||||
super('Cluster has unresolved Plugin Package Tool snapshot work');
|
||||
this.name = 'ClusterPluginPackageToolSnapshotRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertOptions(options: ClusterPluginPackageRecoveryOptions): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'openDatabase',
|
||||
'api',
|
||||
'stageAuthority',
|
||||
'stageAuthorityFactory',
|
||||
'resourceByteSource',
|
||||
'trustAuthorityId',
|
||||
'clusterIdentity',
|
||||
'namespace',
|
||||
'now',
|
||||
'pageSize',
|
||||
'maxPages',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
(options.stageAuthority === undefined) ===
|
||||
(options.stageAuthorityFactory === undefined) ||
|
||||
(options.stageAuthorityFactory !== undefined &&
|
||||
typeof options.stageAuthorityFactory !== 'function') ||
|
||||
(options.resourceByteSource !== undefined &&
|
||||
(!options.resourceByteSource ||
|
||||
typeof options.resourceByteSource.open !== 'function')) ||
|
||||
typeof options.trustAuthorityId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(
|
||||
options.trustAuthorityId,
|
||||
) ||
|
||||
typeof options.now !== 'function' ||
|
||||
(options.pageSize !== undefined &&
|
||||
(!Number.isSafeInteger(options.pageSize) ||
|
||||
options.pageSize < 1 ||
|
||||
options.pageSize >
|
||||
Math.min(
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGE_SIZE,
|
||||
))) ||
|
||||
(options.maxPages !== undefined &&
|
||||
(!Number.isSafeInteger(options.maxPages) ||
|
||||
options.maxPages < 1 ||
|
||||
options.maxPages >
|
||||
Math.min(
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGES,
|
||||
)))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package recovery configuration is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStageAuthority(
|
||||
stageAuthority: ClusterPluginPackageStageAuthority,
|
||||
hasResourceByteSource: boolean,
|
||||
): void {
|
||||
if (
|
||||
!stageAuthority ||
|
||||
typeof stageAuthority.stage !== 'function' ||
|
||||
typeof stageAuthority.publisherEvidence !== 'function' ||
|
||||
typeof stageAuthority.verify !== 'function' ||
|
||||
(!hasResourceByteSource &&
|
||||
!(stageAuthority instanceof ClusterPluginPackageOciStageAuthority))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package recovery stage authority is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot admin Job composition. The database is always closed before this
|
||||
* function settles, and no repository or Kubernetes authority escapes.
|
||||
*/
|
||||
export async function recoverClusterPluginPackages(
|
||||
options: ClusterPluginPackageRecoveryOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageRecoveryResult>> {
|
||||
assertOptions(options);
|
||||
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let result: Readonly<ClusterPluginPackageRecoveryResult> | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const stageAuthority =
|
||||
options.stageAuthority ??
|
||||
(await options.stageAuthorityFactory!(database.pool));
|
||||
assertStageAuthority(
|
||||
stageAuthority,
|
||||
options.resourceByteSource !== undefined,
|
||||
);
|
||||
const installRepository = new PostgresPluginPackageInstallRepository(
|
||||
database.pool,
|
||||
);
|
||||
const provenanceRepository =
|
||||
new PostgresPluginPackagePublisherProvenanceRepository(database.pool);
|
||||
const provenanceRecovery =
|
||||
await recoverClusterPluginPackagePublisherProvenance(
|
||||
installRepository,
|
||||
provenanceRepository,
|
||||
stageAuthority,
|
||||
{
|
||||
trustAuthorityId: options.trustAuthorityId,
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
},
|
||||
);
|
||||
if (!provenanceRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackagePublisherProvenanceRecoveryRequiredError(
|
||||
provenanceRecovery,
|
||||
);
|
||||
}
|
||||
const repository = new ClusterPluginPackageProvenanceInstallRepository(
|
||||
installRepository,
|
||||
provenanceRepository,
|
||||
stageAuthority,
|
||||
options.trustAuthorityId,
|
||||
);
|
||||
const publisher = new PluginPackageKubernetesActivationPublisher(
|
||||
options.api,
|
||||
{
|
||||
async verify(intent) {
|
||||
try {
|
||||
const [record, lock] = await Promise.all([
|
||||
repository.find(intent.projectId, intent.packageName),
|
||||
repository.findLock(intent.lockDigest),
|
||||
]);
|
||||
if (
|
||||
!record ||
|
||||
!lock ||
|
||||
record.installationId !== intent.installationId ||
|
||||
record.lockDigest !== intent.lockDigest ||
|
||||
record.stageReceipt === null ||
|
||||
record.stageReceipt.stageRef !== intent.stageRef ||
|
||||
record.stageReceipt.receiptDigest !== intent.stageReceiptDigest ||
|
||||
record.stageReceipt.evidenceDigest !==
|
||||
intent.stageEvidenceDigest ||
|
||||
record.stageReceipt.contentDigest !== intent.contentDigest
|
||||
) {
|
||||
return pluginPackageStageVerificationFailure(
|
||||
new Error('durable stage identity conflict'),
|
||||
);
|
||||
}
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
await stageAuthority.verify(lock, record.stageReceipt);
|
||||
await provenanceRepository.assertInstallationNotRevoked(
|
||||
record.installationId,
|
||||
);
|
||||
return clusterPluginPackageActivationEvidence(intent);
|
||||
} catch (error) {
|
||||
return pluginPackageStageVerificationFailure(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
clusterIdentity: options.clusterIdentity,
|
||||
namespace: options.namespace,
|
||||
now: options.now,
|
||||
},
|
||||
);
|
||||
const resourceByteSource =
|
||||
options.resourceByteSource ??
|
||||
new ClusterPluginPackageOciResourceByteSource({
|
||||
authority:
|
||||
stageAuthority as ClusterPluginPackageOciStageAuthority,
|
||||
lockSource: repository,
|
||||
});
|
||||
const recovery = await new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: stageAuthority,
|
||||
publisher,
|
||||
now: options.now,
|
||||
}).recover({
|
||||
...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined ? {} : { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!recovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageRecoveryRequiredError(recovery);
|
||||
}
|
||||
const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const taskReconciliationRepository =
|
||||
new PostgresPluginPackageTaskReconciliationRepository(
|
||||
database.pool,
|
||||
taskSpecSemanticRegistry,
|
||||
);
|
||||
const materializedRepository =
|
||||
new PostgresPluginPackageMaterializedRevisionRepository(
|
||||
database.pool,
|
||||
taskSpecSemanticRegistry,
|
||||
);
|
||||
const taskPublicationRecovery =
|
||||
await new PluginPackageTaskPublicationRecoveryCoordinator({
|
||||
source: taskReconciliationRepository,
|
||||
publisher: new PluginPackageTaskPublicationCoordinator({
|
||||
generationSource: publisher,
|
||||
lockSource: repository,
|
||||
byteSource: resourceByteSource,
|
||||
materializedRepository,
|
||||
reconciliationRepository: taskReconciliationRepository,
|
||||
taskSpecSemanticRegistry,
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!taskPublicationRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageTaskPublicationRequiredError(
|
||||
taskPublicationRecovery,
|
||||
);
|
||||
}
|
||||
const automationPublicationRepository =
|
||||
new PostgresPluginPackageAutomationPublicationRepository(database.pool);
|
||||
const automationPublicationRecovery =
|
||||
await new PluginPackageAutomationPublicationRecoveryCoordinator({
|
||||
source: automationPublicationRepository,
|
||||
publisher: new PluginPackageAutomationPublicationCoordinator({
|
||||
generationSource: publisher,
|
||||
materializedRepository,
|
||||
repository: automationPublicationRepository,
|
||||
taskSpecSemanticRegistry,
|
||||
now: options.now,
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!automationPublicationRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageAutomationPublicationRequiredError(
|
||||
automationPublicationRecovery,
|
||||
);
|
||||
}
|
||||
const toolSnapshotRepository =
|
||||
new PostgresProjectToolDefinitionSnapshotRepository(database.pool);
|
||||
const toolSnapshotRecovery =
|
||||
await new ProjectToolDefinitionSnapshotRecoveryCoordinator({
|
||||
source: toolSnapshotRepository,
|
||||
publisher: new ProjectToolDefinitionSnapshotPublicationCoordinator({
|
||||
source: toolSnapshotRepository,
|
||||
materializedRepository,
|
||||
repository: toolSnapshotRepository,
|
||||
taskSpecSemanticRegistry,
|
||||
pageSize: Math.min(
|
||||
options.pageSize ?? 16,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_SOURCE_PAGE_SIZE,
|
||||
),
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pageSize }),
|
||||
...(options.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.maxPages }),
|
||||
});
|
||||
if (!toolSnapshotRecovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageToolSnapshotRequiredError(
|
||||
toolSnapshotRecovery,
|
||||
);
|
||||
}
|
||||
result = Object.freeze({
|
||||
evidence,
|
||||
provenanceRecovery,
|
||||
recovery,
|
||||
taskPublicationRecovery,
|
||||
automationPublicationRecovery,
|
||||
toolSnapshotRecovery,
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
|
||||
if (database) {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Cluster Plugin Package recovery failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
if (!result) {
|
||||
throw new Error('Cluster Plugin Package recovery produced no result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Cluster Plugin Package recovery boundary; keep the operational CLI explicit.
|
||||
|
||||
import { runClusterPluginPackageRecoveryProcess } from './pluginPackageRecoveryProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-plugin-package-recover';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-recovery',
|
||||
event: 'recovery_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PLUGIN_PACKAGE_RECOVERY_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runClusterPluginPackageRecoveryProcess({
|
||||
environment: process.env,
|
||||
emit(record) {
|
||||
process.stdout.write(`${JSON.stringify(record)}\n`);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
+885
@@ -0,0 +1,885 @@
|
||||
// Cluster Plugin Package recovery boundary; keep process composition explicit.
|
||||
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import { MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE } from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
type PluginPackagePublisherKeyDefinition,
|
||||
} from '@qinglong/runtime-core/plugin-package-bundle';
|
||||
import {
|
||||
createPluginPackagePublisherTrustSnapshot,
|
||||
createPluginPackagePublisherEffectiveTrustRegistry,
|
||||
type PluginPackagePublisherTrustSnapshot,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
type PluginPackageRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import type { PluginPackageAutomationPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageResourceByteSource } from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import type { PluginPackageTaskPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import type { ProjectToolDefinitionSnapshotRecoveryCycleResult } from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresPluginPackagePublisherTrustAuthorityRepository,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
import {
|
||||
recoverClusterPluginPackages,
|
||||
type ClusterPluginPackageRecoveryResult,
|
||||
} from './pluginPackageRecovery';
|
||||
import type { ClusterPluginPackagePublisherProvenanceRecoveryResult } from '../publisher/pluginPackagePublisherProvenanceRecovery';
|
||||
import {
|
||||
ClusterPluginPackageOciStageAuthority,
|
||||
type ClusterPluginPackageOciFetch,
|
||||
type ClusterPluginPackageRegistryCredentialProvider,
|
||||
type ClusterPluginPackageStageAuthority,
|
||||
} from './pluginPackageOciStage';
|
||||
import type { PluginPackageKubernetesConfigMapApi } from './pluginPackageKubernetesActivation';
|
||||
|
||||
export type ClusterPluginPackageRecoveryProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export interface ClusterPluginPackageRecoveryProcessConfig {
|
||||
readonly clusterIdentity: string;
|
||||
readonly namespace: string;
|
||||
readonly allowedRegistries: readonly string[];
|
||||
readonly publisherTrustFile: string;
|
||||
readonly publisherTrustAuthorityId: string;
|
||||
readonly registryCredentialFile?: string;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly pageSize: number;
|
||||
readonly maxPages: number;
|
||||
readonly database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageRecoveryProcessEvent {
|
||||
readonly schemaVersion: 1;
|
||||
readonly component: 'qinglong3-plugin-package-recovery';
|
||||
readonly event: 'recovery_started' | 'recovery_completed';
|
||||
readonly clusterIdentity: string;
|
||||
readonly provenanceRecovery?: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>;
|
||||
readonly recovery?: Readonly<PluginPackageRecoveryCycleResult>;
|
||||
readonly taskPublicationRecovery?: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>;
|
||||
readonly automationPublicationRecovery?: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>;
|
||||
readonly toolSnapshotRecovery?: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageRecoveryProcessOptions {
|
||||
readonly environment: ClusterPluginPackageRecoveryProcessEnvironment;
|
||||
readonly emit?: (
|
||||
event: ClusterPluginPackageRecoveryProcessEvent,
|
||||
) => void | Promise<void>;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly api?: PluginPackageKubernetesConfigMapApi;
|
||||
readonly stageAuthority?: ClusterPluginPackageStageAuthority;
|
||||
readonly resourceByteSource?: PluginPackageResourceByteSource;
|
||||
readonly trust?: PluginPackagePublisherTrustRegistry;
|
||||
readonly fetch?: ClusterPluginPackageOciFetch;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageRegistryCredentialFile
|
||||
extends ClusterPluginPackageRegistryCredentialProvider {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackagePublisherTrustFileEvidence {
|
||||
readonly registry: PluginPackagePublisherTrustRegistry;
|
||||
readonly snapshot: Readonly<PluginPackagePublisherTrustSnapshot>;
|
||||
readonly definitions: readonly Readonly<PluginPackagePublisherKeyDefinition>[];
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageRecoveryProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_RECOVERY_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Plugin Package recovery process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageRecoveryProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
const TRUST_SCHEMA = 'qinglong/plugin-package-publisher-trust@v1';
|
||||
const REGISTRY_CREDENTIAL_SCHEMA =
|
||||
'qinglong/plugin-package-registry-credentials@v1';
|
||||
const MAX_TRUST_FILE_BYTES = 256 * 1024;
|
||||
const MAX_REGISTRY_CREDENTIAL_FILE_BYTES = 256 * 1024;
|
||||
const MAX_REGISTRY_CREDENTIALS = 32;
|
||||
const SAFE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const REGISTRY =
|
||||
/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*)(?::([1-9][0-9]{0,4}))?$/;
|
||||
const BEARER_TOKEN = /^[A-Za-z0-9._~+/-]+={0,2}$/;
|
||||
|
||||
class LoadedClusterPluginPackageRegistryCredentialFile
|
||||
implements ClusterPluginPackageRegistryCredentialFile
|
||||
{
|
||||
readonly #authorizations: Map<string, Buffer>;
|
||||
|
||||
constructor(authorizations: Map<string, Buffer>) {
|
||||
this.#authorizations = authorizations;
|
||||
}
|
||||
|
||||
authorizationFor(registry: string): string | undefined {
|
||||
if (typeof registry !== 'string' || !REGISTRY.test(registry)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.#authorizations.get(registry)?.toString('ascii');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const authorization of this.#authorizations.values()) {
|
||||
authorization.fill(0);
|
||||
}
|
||||
this.#authorizations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') {
|
||||
if (required) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} is required`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > maximumLength || /[\0\r\n]/.test(value)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return false;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} must be true or false`,
|
||||
);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
name: string,
|
||||
defaultValue: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return defaultValue;
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} must be an integer`,
|
||||
);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${name} must be between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL',
|
||||
host: 'QL3_POSTGRES_PACKAGE_EXECUTOR_HOST',
|
||||
port: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT',
|
||||
database: 'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE',
|
||||
user: 'QL3_POSTGRES_PACKAGE_EXECUTOR_USER',
|
||||
password: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Package executor connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode = environment.QL3_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(environment, 'QL3_POSTGRES_ALLOW_INSECURE')
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'disabling PostgreSQL TLS requires QL3_POSTGRES_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_SERVERNAME must be an explicit DNS name for verify-full',
|
||||
);
|
||||
}
|
||||
const certificateAuthorityFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_TLS_CA_FILE',
|
||||
4096,
|
||||
);
|
||||
if (mode === 'disable' && certificateAuthorityFile !== undefined) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let certificateAuthority: string | undefined;
|
||||
if (certificateAuthorityFile !== undefined) {
|
||||
try {
|
||||
certificateAuthority = loadPostgresCertificateAuthorityFile(
|
||||
certificateAuthorityFile,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_TLS_CA_FILE must contain a bounded trusted CA bundle',
|
||||
);
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(environment, 'QL3_POSTGRES_APPLICATION_NAME', 63) ??
|
||||
'qinglong3-plugin-package-recovery';
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(applicationName)) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'QL3_POSTGRES_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
...(certificateAuthority === undefined
|
||||
? {}
|
||||
: { ca: certificateAuthority }),
|
||||
servername: servername!,
|
||||
}),
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: 1,
|
||||
connectionTimeoutMs: 15_000,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageRecoveryProcessConfig(
|
||||
environment: ClusterPluginPackageRecoveryProcessEnvironment,
|
||||
): Readonly<ClusterPluginPackageRecoveryProcessConfig> {
|
||||
if (
|
||||
!environment ||
|
||||
typeof environment !== 'object' ||
|
||||
Array.isArray(environment)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'environment must be an object',
|
||||
);
|
||||
}
|
||||
const clusterIdentity = boundedValue(
|
||||
environment,
|
||||
'QL3_CLUSTER_IDENTITY',
|
||||
256,
|
||||
true,
|
||||
)!;
|
||||
const namespace = boundedValue(
|
||||
environment,
|
||||
'QL3_KUBERNETES_NAMESPACE',
|
||||
63,
|
||||
true,
|
||||
)!;
|
||||
const registryValue = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_OCI_REGISTRIES',
|
||||
4096,
|
||||
true,
|
||||
)!;
|
||||
const allowedRegistries = registryValue.split(',');
|
||||
const publisherTrustFile = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_PUBLISHER_TRUST_FILE',
|
||||
4096,
|
||||
true,
|
||||
)!;
|
||||
const registryCredentialFile = boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_REGISTRY_CREDENTIAL_FILE',
|
||||
4096,
|
||||
);
|
||||
if (
|
||||
!SAFE_IDENTITY.test(clusterIdentity) ||
|
||||
!DNS_LABEL.test(namespace) ||
|
||||
allowedRegistries.length < 1 ||
|
||||
allowedRegistries.length > 32 ||
|
||||
allowedRegistries.some((registry) => !REGISTRY.test(registry)) ||
|
||||
new Set(allowedRegistries).size !== allowedRegistries.length ||
|
||||
!isAbsolute(publisherTrustFile) ||
|
||||
(registryCredentialFile !== undefined &&
|
||||
!isAbsolute(registryCredentialFile))
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'cluster, namespace, registry or publisher trust binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
clusterIdentity,
|
||||
namespace,
|
||||
allowedRegistries: Object.freeze(allowedRegistries),
|
||||
publisherTrustFile,
|
||||
publisherTrustAuthorityId:
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_TRUST_AUTHORITY_ID',
|
||||
128,
|
||||
) ?? 'cluster',
|
||||
...(registryCredentialFile === undefined ? {} : { registryCredentialFile }),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_OCI_TIMEOUT_MS',
|
||||
15_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
pageSize: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_RECOVERY_PAGE_SIZE',
|
||||
16,
|
||||
1,
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
|
||||
),
|
||||
maxPages: integerValue(
|
||||
environment,
|
||||
'QL3_PLUGIN_PACKAGE_RECOVERY_MAX_PAGES',
|
||||
16,
|
||||
1,
|
||||
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
|
||||
),
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
function readClusterPluginPackageRegistryCredentialFile(
|
||||
filePath: string,
|
||||
): Buffer {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
filePath.length < 1 ||
|
||||
filePath.length > 4096 ||
|
||||
/[\0\r\n]/.test(filePath) ||
|
||||
!isAbsolute(filePath)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file path is invalid',
|
||||
);
|
||||
}
|
||||
let descriptor: number;
|
||||
try {
|
||||
descriptor = openSync(filePath, constants.O_RDONLY);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file is unavailable',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const stat = fstatSync(descriptor);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o027) !== 0 ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_REGISTRY_CREDENTIAL_FILE_BYTES
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file is not a bounded private regular file',
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
null,
|
||||
);
|
||||
if (count === 0) break;
|
||||
offset += count;
|
||||
}
|
||||
if (offset !== stat.size) {
|
||||
bytes.fill(0);
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file changed while reading',
|
||||
);
|
||||
}
|
||||
return bytes.subarray(0, offset);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function credentialRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function scrubCredentialSource(value: unknown): void {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const credentials = (value as { credentials?: unknown }).credentials;
|
||||
if (!Array.isArray(credentials)) return;
|
||||
for (const candidate of credentials) {
|
||||
if (!candidate || typeof candidate !== 'object') continue;
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (typeof record.password === 'string') record.password = '';
|
||||
if (typeof record.token === 'string') record.token = '';
|
||||
}
|
||||
}
|
||||
|
||||
function zeroAuthorizations(authorizations: Map<string, Buffer>): void {
|
||||
for (const authorization of authorizations.values()) {
|
||||
authorization.fill(0);
|
||||
}
|
||||
authorizations.clear();
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackageRegistryCredentialFile(
|
||||
filePath: string,
|
||||
allowedRegistries: readonly string[],
|
||||
): ClusterPluginPackageRegistryCredentialFile {
|
||||
if (
|
||||
!Array.isArray(allowedRegistries) ||
|
||||
allowedRegistries.length < 1 ||
|
||||
allowedRegistries.length > MAX_REGISTRY_CREDENTIALS ||
|
||||
allowedRegistries.some(
|
||||
(registry) => typeof registry !== 'string' || !REGISTRY.test(registry),
|
||||
) ||
|
||||
new Set(allowedRegistries).size !== allowedRegistries.length
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential allowlist is invalid',
|
||||
);
|
||||
}
|
||||
const bytes = readClusterPluginPackageRegistryCredentialFile(filePath);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file is not valid JSON',
|
||||
);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
const authorizations = new Map<string, Buffer>();
|
||||
try {
|
||||
const root = credentialRecord(value, 'registry credential file');
|
||||
if (
|
||||
Object.keys(root).sort().join(',') !== 'credentials,schema' ||
|
||||
root.schema !== REGISTRY_CREDENTIAL_SCHEMA ||
|
||||
!Array.isArray(root.credentials) ||
|
||||
root.credentials.length < 1 ||
|
||||
root.credentials.length > MAX_REGISTRY_CREDENTIALS
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'registry credential file shape is invalid',
|
||||
);
|
||||
}
|
||||
const allowed = new Set(allowedRegistries);
|
||||
for (const [index, candidate] of root.credentials.entries()) {
|
||||
const entry = credentialRecord(candidate, `registry credential ${index}`);
|
||||
const registry = entry.registry;
|
||||
const scheme = entry.scheme;
|
||||
if (
|
||||
typeof registry !== 'string' ||
|
||||
!REGISTRY.test(registry) ||
|
||||
!allowed.has(registry) ||
|
||||
authorizations.has(registry)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} binding is invalid`,
|
||||
);
|
||||
}
|
||||
let authorization: Buffer;
|
||||
if (scheme === 'basic') {
|
||||
if (
|
||||
Object.keys(entry).sort().join(',') !==
|
||||
'password,registry,scheme,username' ||
|
||||
typeof entry.username !== 'string' ||
|
||||
Buffer.byteLength(entry.username, 'utf8') < 1 ||
|
||||
Buffer.byteLength(entry.username, 'utf8') > 256 ||
|
||||
/[\0-\x1f\x7f:]/.test(entry.username) ||
|
||||
typeof entry.password !== 'string' ||
|
||||
Buffer.byteLength(entry.password, 'utf8') < 1 ||
|
||||
Buffer.byteLength(entry.password, 'utf8') > 4096 ||
|
||||
/[\0-\x1f\x7f]/.test(entry.password)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} basic value is invalid`,
|
||||
);
|
||||
}
|
||||
const userPassword = Buffer.from(
|
||||
`${entry.username}:${entry.password}`,
|
||||
'utf8',
|
||||
);
|
||||
try {
|
||||
authorization = Buffer.from(
|
||||
`Basic ${userPassword.toString('base64')}`,
|
||||
'ascii',
|
||||
);
|
||||
} finally {
|
||||
userPassword.fill(0);
|
||||
}
|
||||
} else if (scheme === 'bearer') {
|
||||
if (
|
||||
Object.keys(entry).sort().join(',') !== 'registry,scheme,token' ||
|
||||
typeof entry.token !== 'string' ||
|
||||
entry.token.length < 1 ||
|
||||
entry.token.length > 8192 ||
|
||||
!BEARER_TOKEN.test(entry.token)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} bearer value is invalid`,
|
||||
);
|
||||
}
|
||||
authorization = Buffer.from(`Bearer ${entry.token}`, 'ascii');
|
||||
} else {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
`registry credential ${index} scheme is invalid`,
|
||||
);
|
||||
}
|
||||
authorizations.set(registry, authorization);
|
||||
}
|
||||
return new LoadedClusterPluginPackageRegistryCredentialFile(authorizations);
|
||||
} catch (error) {
|
||||
zeroAuthorizations(authorizations);
|
||||
throw error;
|
||||
} finally {
|
||||
scrubCredentialSource(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackagePublisherTrustFileEvidence(
|
||||
filePath: string,
|
||||
): Readonly<ClusterPluginPackagePublisherTrustFileEvidence> {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
filePath.length < 1 ||
|
||||
filePath.length > 4096 ||
|
||||
/[\0\r\n]/.test(filePath) ||
|
||||
!isAbsolute(filePath)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file path is invalid',
|
||||
);
|
||||
}
|
||||
let descriptor: number;
|
||||
try {
|
||||
descriptor = openSync(filePath, constants.O_RDONLY);
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file is unavailable',
|
||||
);
|
||||
}
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
const stat = fstatSync(descriptor);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_TRUST_FILE_BYTES
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file is not a bounded read-only regular file',
|
||||
);
|
||||
}
|
||||
bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
null,
|
||||
);
|
||||
if (count === 0) break;
|
||||
offset += count;
|
||||
}
|
||||
if (offset !== stat.size) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file changed while reading',
|
||||
);
|
||||
}
|
||||
bytes = bytes.subarray(0, offset);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file is not valid JSON',
|
||||
);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !== 'keys,schema' ||
|
||||
(value as { schema?: unknown }).schema !== TRUST_SCHEMA ||
|
||||
!Array.isArray((value as { keys?: unknown }).keys)
|
||||
) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust file shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const definitions = (
|
||||
value as { keys: PluginPackagePublisherKeyDefinition[] }
|
||||
).keys;
|
||||
const frozenDefinitions = Object.freeze(
|
||||
definitions.map((definition) => Object.freeze({ ...definition })),
|
||||
);
|
||||
return Object.freeze({
|
||||
registry: new PluginPackagePublisherTrustRegistry(frozenDefinitions),
|
||||
snapshot:
|
||||
createPluginPackagePublisherTrustSnapshot(frozenDefinitions),
|
||||
definitions: frozenDefinitions,
|
||||
});
|
||||
} catch {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust keys are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadClusterPluginPackagePublisherTrustFile(
|
||||
filePath: string,
|
||||
): PluginPackagePublisherTrustRegistry {
|
||||
return loadClusterPluginPackagePublisherTrustFileEvidence(filePath).registry;
|
||||
}
|
||||
|
||||
async function productionKubernetesApi(): Promise<PluginPackageKubernetesConfigMapApi> {
|
||||
const kubernetes = await import('@kubernetes/client-node');
|
||||
const config = new kubernetes.KubeConfig();
|
||||
config.loadFromCluster();
|
||||
return config.makeApiClient(
|
||||
kubernetes.CoreV1Api,
|
||||
) as unknown as PluginPackageKubernetesConfigMapApi;
|
||||
}
|
||||
|
||||
function processEvent(
|
||||
config: Readonly<ClusterPluginPackageRecoveryProcessConfig>,
|
||||
event: ClusterPluginPackageRecoveryProcessEvent['event'],
|
||||
provenanceRecovery?: Readonly<ClusterPluginPackagePublisherProvenanceRecoveryResult>,
|
||||
recovery?: Readonly<PluginPackageRecoveryCycleResult>,
|
||||
taskPublicationRecovery?: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>,
|
||||
automationPublicationRecovery?: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>,
|
||||
toolSnapshotRecovery?: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>,
|
||||
): Readonly<ClusterPluginPackageRecoveryProcessEvent> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-plugin-package-recovery',
|
||||
event,
|
||||
clusterIdentity: config.clusterIdentity,
|
||||
...(provenanceRecovery === undefined ? {} : { provenanceRecovery }),
|
||||
...(recovery === undefined ? {} : { recovery }),
|
||||
...(taskPublicationRecovery === undefined
|
||||
? {}
|
||||
: { taskPublicationRecovery }),
|
||||
...(automationPublicationRecovery === undefined
|
||||
? {}
|
||||
: { automationPublicationRecovery }),
|
||||
...(toolSnapshotRecovery === undefined ? {} : { toolSnapshotRecovery }),
|
||||
});
|
||||
}
|
||||
|
||||
async function emit(
|
||||
sink: RunClusterPluginPackageRecoveryProcessOptions['emit'],
|
||||
value: Readonly<ClusterPluginPackageRecoveryProcessEvent>,
|
||||
): Promise<void> {
|
||||
if (!sink) return;
|
||||
try {
|
||||
await sink(value);
|
||||
} catch {
|
||||
// Diagnostics cannot replace recovery or database close outcomes.
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs exactly one admin recovery cycle and owns no resident authority. */
|
||||
export async function runClusterPluginPackageRecoveryProcess(
|
||||
options: RunClusterPluginPackageRecoveryProcessOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageRecoveryResult>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
(options.emit !== undefined && typeof options.emit !== 'function') ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.resourceByteSource !== undefined &&
|
||||
(!options.resourceByteSource ||
|
||||
typeof options.resourceByteSource.open !== 'function')) ||
|
||||
(options.fetch !== undefined && typeof options.fetch !== 'function')
|
||||
) {
|
||||
throw new TypeError('Plugin Package recovery process options are invalid');
|
||||
}
|
||||
const config = loadClusterPluginPackageRecoveryProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
const trustEvidence =
|
||||
options.trust === undefined && options.stageAuthority === undefined
|
||||
? loadClusterPluginPackagePublisherTrustFileEvidence(
|
||||
config.publisherTrustFile,
|
||||
)
|
||||
: undefined;
|
||||
const registryCredentials =
|
||||
options.stageAuthority !== undefined ||
|
||||
config.registryCredentialFile === undefined
|
||||
? undefined
|
||||
: loadClusterPluginPackageRegistryCredentialFile(
|
||||
config.registryCredentialFile,
|
||||
config.allowedRegistries,
|
||||
);
|
||||
try {
|
||||
const api = options.api ?? (await productionKubernetesApi());
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'package-executor',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError() {
|
||||
// Awaited recovery queries and final close remain authoritative.
|
||||
},
|
||||
});
|
||||
await emit(options.emit, processEvent(config, 'recovery_started'));
|
||||
const result = await recoverClusterPluginPackages({
|
||||
openDatabase,
|
||||
api,
|
||||
...(options.stageAuthority === undefined
|
||||
? {
|
||||
stageAuthorityFactory: async (pool: PostgresPool) => {
|
||||
let effectiveTrust = options.trust;
|
||||
if (effectiveTrust === undefined && trustEvidence !== undefined) {
|
||||
const authority =
|
||||
await new PostgresPluginPackagePublisherTrustAuthorityRepository(
|
||||
pool,
|
||||
).findAuthority(config.publisherTrustAuthorityId);
|
||||
if (!authority) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'durable publisher trust authority is unavailable',
|
||||
);
|
||||
}
|
||||
effectiveTrust =
|
||||
createPluginPackagePublisherEffectiveTrustRegistry(
|
||||
trustEvidence.definitions,
|
||||
authority.effectiveSnapshot,
|
||||
);
|
||||
}
|
||||
if (effectiveTrust === undefined) {
|
||||
throw new ClusterPluginPackageRecoveryProcessConfigError(
|
||||
'publisher trust evidence is unavailable',
|
||||
);
|
||||
}
|
||||
return new ClusterPluginPackageOciStageAuthority({
|
||||
allowedRegistries: config.allowedRegistries,
|
||||
trust: effectiveTrust,
|
||||
...(registryCredentials === undefined
|
||||
? {}
|
||||
: { credentialProvider: registryCredentials }),
|
||||
...(options.fetch === undefined
|
||||
? {}
|
||||
: { fetch: options.fetch }),
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
});
|
||||
},
|
||||
}
|
||||
: { stageAuthority: options.stageAuthority }),
|
||||
...(options.resourceByteSource === undefined
|
||||
? {}
|
||||
: { resourceByteSource: options.resourceByteSource }),
|
||||
trustAuthorityId: config.publisherTrustAuthorityId,
|
||||
clusterIdentity: config.clusterIdentity,
|
||||
namespace: config.namespace,
|
||||
now: Date.now,
|
||||
pageSize: config.pageSize,
|
||||
maxPages: config.maxPages,
|
||||
});
|
||||
await emit(
|
||||
options.emit,
|
||||
processEvent(
|
||||
config,
|
||||
'recovery_completed',
|
||||
result.provenanceRecovery,
|
||||
result.recovery,
|
||||
result.taskPublicationRecovery,
|
||||
result.automationPublicationRecovery,
|
||||
result.toolSnapshotRecovery,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
registryCredentials?.dispose();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Offline Prompt Output external recovery verification CLI boundary. */
|
||||
import {
|
||||
disposeClusterPromptOutputExternalRecoveryInput,
|
||||
readClusterPromptOutputExternalRecoveryCommand,
|
||||
readClusterPromptOutputExternalRecoveryInput,
|
||||
type ClusterPromptOutputExternalRecoveryInput,
|
||||
} from './promptOutputExternalRecoveryInput';
|
||||
import { runClusterPromptOutputExternalRecoveryVerifier } from './promptOutputExternalRecoveryVerifier';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-prompt-output-key-recovery-verify run --command-file /absolute/recovery.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
!argv[2]
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PROMPT_OUTPUT_EXTERNAL_RECOVERY_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
let input: Readonly<ClusterPromptOutputExternalRecoveryInput> | undefined;
|
||||
try {
|
||||
const command = readClusterPromptOutputExternalRecoveryCommand(argv[2]);
|
||||
input = readClusterPromptOutputExternalRecoveryInput(command);
|
||||
const proof = runClusterPromptOutputExternalRecoveryVerifier(input);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-external-recovery-verifier',
|
||||
event: 'recovery_verified',
|
||||
...proof,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-external-recovery-verifier',
|
||||
event: 'recovery_rejected',
|
||||
name:
|
||||
typeof candidate.name === 'string'
|
||||
? candidate.name.slice(0, 128)
|
||||
: 'Error',
|
||||
...(typeof candidate.code === 'string'
|
||||
? { code: candidate.code.slice(0, 128) }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (input) disposeClusterPromptOutputExternalRecoveryInput(input);
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
/** Private Prompt Output external recovery input authority boundary. */
|
||||
import {
|
||||
constants,
|
||||
closeSync,
|
||||
fstatSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { PluginPackagePromptOutputArtifact } from '@qinglong/ai/plugin-package-prompt-output-artifact';
|
||||
import type {
|
||||
PluginPackagePromptOutputDurableKeyFact,
|
||||
PluginPackagePromptOutputExternalCustodyReceipt,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-external-custody';
|
||||
import {
|
||||
openPluginPackagePromptOutputExternalCustodyBundle,
|
||||
type OpenPluginPackagePromptOutputExternalCustodyBundle,
|
||||
type PluginPackagePromptOutputExternalCustodyBundle,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-external-custody-bundle';
|
||||
import type { PluginPackagePromptOutputExternalRecoveryAuthorization } from '@qinglong/ai/plugin-package-prompt-output-external-recovery-authorization';
|
||||
|
||||
const MAX_COMMAND_FILE_BYTES = 16 * 1024;
|
||||
const MAX_JSON_INPUT_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_CUSTODY_BUNDLE_BYTES = 128 * 1024;
|
||||
const MAX_PUBLIC_KEY_BYTES = 8 * 1024;
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
||||
|
||||
export interface ClusterPromptOutputExternalRecoveryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'cluster.prompt-output-key.verify-recovery';
|
||||
readonly authorizationFile: string;
|
||||
readonly custodyBundleFile: string;
|
||||
readonly recoveredMaterialFile: string;
|
||||
readonly durableKeyFactFile: string;
|
||||
readonly artifactFile: string;
|
||||
readonly custodyPublicKeyFile: string;
|
||||
readonly approverPublicKeyFiles: readonly [
|
||||
Readonly<{ userId: string; filePath: string }>,
|
||||
Readonly<{ userId: string; filePath: string }>,
|
||||
];
|
||||
}
|
||||
|
||||
export interface ClusterPromptOutputExternalRecoveryInput {
|
||||
readonly authorization: PluginPackagePromptOutputExternalRecoveryAuthorization;
|
||||
readonly receipt: PluginPackagePromptOutputExternalCustodyReceipt;
|
||||
readonly wrappedMaterial: Buffer;
|
||||
readonly material: Buffer;
|
||||
readonly durableKeyFact: Readonly<PluginPackagePromptOutputDurableKeyFact>;
|
||||
readonly artifact: PluginPackagePromptOutputArtifact;
|
||||
readonly custodyPublicKey: Buffer;
|
||||
readonly approverPublicKeys: readonly [
|
||||
Readonly<{ userId: string; publicKey: Buffer }>,
|
||||
Readonly<{ userId: string; publicKey: Buffer }>,
|
||||
];
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
keys.length === canonical.length &&
|
||||
keys.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function absolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableReadOnlyFile(
|
||||
filePathValue: string,
|
||||
options: Readonly<{
|
||||
label: string;
|
||||
minimumBytes: number;
|
||||
maximumBytes: number;
|
||||
privateFile: boolean;
|
||||
}>,
|
||||
): Buffer {
|
||||
const filePath = absolutePath(filePathValue, `${options.label} path`);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size < options.minimumBytes ||
|
||||
before.size > options.maximumBytes ||
|
||||
(before.mode & 0o222) !== 0 ||
|
||||
(before.mode & 0o111) !== 0 ||
|
||||
(before.mode & 0o440) === 0 ||
|
||||
(options.privateFile && (before.mode & 0o007) !== 0)
|
||||
) {
|
||||
throw new TypeError(`${options.label} is unavailable`);
|
||||
}
|
||||
const value = readFileSync(descriptor);
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
value.byteLength !== before.size ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs
|
||||
) {
|
||||
value.fill(0);
|
||||
throw new TypeError(`${options.label} changed during read`);
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonFile(filePath: string, label: string): unknown {
|
||||
const bytes = stableReadOnlyFile(filePath, {
|
||||
label,
|
||||
minimumBytes: 2,
|
||||
maximumBytes: MAX_JSON_INPUT_BYTES,
|
||||
privateFile: true,
|
||||
});
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8')) as unknown;
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function readClusterPromptOutputExternalRecoveryCommand(
|
||||
filePathValue: string,
|
||||
): Readonly<ClusterPromptOutputExternalRecoveryCommand> {
|
||||
const bytes = stableReadOnlyFile(filePathValue, {
|
||||
label: 'Recovery command file',
|
||||
minimumBytes: 2,
|
||||
maximumBytes: MAX_COMMAND_FILE_BYTES,
|
||||
privateFile: false,
|
||||
});
|
||||
try {
|
||||
const value = JSON.parse(bytes.toString('utf8')) as unknown;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, [
|
||||
'approverPublicKeyFiles',
|
||||
'artifactFile',
|
||||
'authorizationFile',
|
||||
'custodyBundleFile',
|
||||
'custodyPublicKeyFile',
|
||||
'durableKeyFactFile',
|
||||
'operation',
|
||||
'recoveredMaterialFile',
|
||||
'schemaVersion',
|
||||
])
|
||||
) {
|
||||
throw new TypeError('Recovery command shape is invalid');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
candidate.operation !== 'cluster.prompt-output-key.verify-recovery' ||
|
||||
!Array.isArray(candidate.approverPublicKeyFiles) ||
|
||||
candidate.approverPublicKeyFiles.length !== 2
|
||||
) {
|
||||
throw new TypeError('Recovery command value is invalid');
|
||||
}
|
||||
const approvers = candidate.approverPublicKeyFiles
|
||||
.map((entry, index) => {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry !== 'object' ||
|
||||
Array.isArray(entry) ||
|
||||
!exactKeys(entry, ['filePath', 'userId'])
|
||||
) {
|
||||
throw new TypeError(`Approver public key ${index} is invalid`);
|
||||
}
|
||||
const item = entry as Record<string, unknown>;
|
||||
if (typeof item.userId !== 'string' || !ID_PATTERN.test(item.userId)) {
|
||||
throw new TypeError(`Approver public key ${index} user is invalid`);
|
||||
}
|
||||
return Object.freeze({
|
||||
userId: item.userId,
|
||||
filePath: absolutePath(
|
||||
item.filePath,
|
||||
`Approver public key ${index} file`,
|
||||
),
|
||||
});
|
||||
})
|
||||
.sort((left, right) => left.userId.localeCompare(right.userId));
|
||||
if (approvers[0]!.userId === approvers[1]!.userId) {
|
||||
throw new TypeError('Approver public key users must be distinct');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'cluster.prompt-output-key.verify-recovery',
|
||||
authorizationFile: absolutePath(
|
||||
candidate.authorizationFile,
|
||||
'Authorization file',
|
||||
),
|
||||
custodyBundleFile: absolutePath(
|
||||
candidate.custodyBundleFile,
|
||||
'Custody bundle file',
|
||||
),
|
||||
recoveredMaterialFile: absolutePath(
|
||||
candidate.recoveredMaterialFile,
|
||||
'Recovered material file',
|
||||
),
|
||||
durableKeyFactFile: absolutePath(
|
||||
candidate.durableKeyFactFile,
|
||||
'Durable key fact file',
|
||||
),
|
||||
artifactFile: absolutePath(candidate.artifactFile, 'Artifact file'),
|
||||
custodyPublicKeyFile: absolutePath(
|
||||
candidate.custodyPublicKeyFile,
|
||||
'Custody public key file',
|
||||
),
|
||||
approverPublicKeyFiles: approvers as unknown as readonly [
|
||||
Readonly<{ userId: string; filePath: string }>,
|
||||
Readonly<{ userId: string; filePath: string }>,
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function readClusterPromptOutputExternalRecoveryInput(
|
||||
command: Readonly<ClusterPromptOutputExternalRecoveryCommand>,
|
||||
): Readonly<ClusterPromptOutputExternalRecoveryInput> {
|
||||
let custodyBundle:
|
||||
| Readonly<OpenPluginPackagePromptOutputExternalCustodyBundle>
|
||||
| undefined;
|
||||
let material: Buffer | undefined;
|
||||
let custodyPublicKey: Buffer | undefined;
|
||||
const approverPublicKeys: Buffer[] = [];
|
||||
try {
|
||||
material = stableReadOnlyFile(command.recoveredMaterialFile, {
|
||||
label: 'Recovered material',
|
||||
minimumBytes: 32,
|
||||
maximumBytes: 32,
|
||||
privateFile: true,
|
||||
});
|
||||
custodyPublicKey = stableReadOnlyFile(command.custodyPublicKeyFile, {
|
||||
label: 'Custody public key',
|
||||
minimumBytes: 32,
|
||||
maximumBytes: MAX_PUBLIC_KEY_BYTES,
|
||||
privateFile: true,
|
||||
});
|
||||
const custodyBundleBytes = stableReadOnlyFile(command.custodyBundleFile, {
|
||||
label: 'Custody bundle',
|
||||
minimumBytes: 2,
|
||||
maximumBytes: MAX_CUSTODY_BUNDLE_BYTES,
|
||||
privateFile: true,
|
||||
});
|
||||
try {
|
||||
custodyBundle = openPluginPackagePromptOutputExternalCustodyBundle(
|
||||
JSON.parse(
|
||||
custodyBundleBytes.toString('utf8'),
|
||||
) as PluginPackagePromptOutputExternalCustodyBundle,
|
||||
custodyPublicKey,
|
||||
);
|
||||
} finally {
|
||||
custodyBundleBytes.fill(0);
|
||||
}
|
||||
const approvers = command.approverPublicKeyFiles.map((entry) => {
|
||||
const publicKey = stableReadOnlyFile(entry.filePath, {
|
||||
label: `Approver ${entry.userId} public key`,
|
||||
minimumBytes: 32,
|
||||
maximumBytes: MAX_PUBLIC_KEY_BYTES,
|
||||
privateFile: true,
|
||||
});
|
||||
approverPublicKeys.push(publicKey);
|
||||
return Object.freeze({ userId: entry.userId, publicKey });
|
||||
}) as unknown as readonly [
|
||||
Readonly<{ userId: string; publicKey: Buffer }>,
|
||||
Readonly<{ userId: string; publicKey: Buffer }>,
|
||||
];
|
||||
return Object.freeze({
|
||||
authorization: jsonFile(
|
||||
command.authorizationFile,
|
||||
'Recovery authorization',
|
||||
) as PluginPackagePromptOutputExternalRecoveryAuthorization,
|
||||
receipt: custodyBundle.receipt,
|
||||
wrappedMaterial: custodyBundle.wrappedMaterial,
|
||||
material,
|
||||
durableKeyFact: jsonFile(
|
||||
command.durableKeyFactFile,
|
||||
'Durable key fact',
|
||||
) as PluginPackagePromptOutputDurableKeyFact,
|
||||
artifact: jsonFile(
|
||||
command.artifactFile,
|
||||
'Prompt output Artifact',
|
||||
) as PluginPackagePromptOutputArtifact,
|
||||
custodyPublicKey,
|
||||
approverPublicKeys: approvers,
|
||||
});
|
||||
} catch (cause) {
|
||||
custodyBundle?.wrappedMaterial.fill(0);
|
||||
material?.fill(0);
|
||||
custodyPublicKey?.fill(0);
|
||||
approverPublicKeys.forEach((publicKey) => publicKey.fill(0));
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeClusterPromptOutputExternalRecoveryInput(
|
||||
value: Readonly<ClusterPromptOutputExternalRecoveryInput>,
|
||||
): void {
|
||||
value.wrappedMaterial.fill(0);
|
||||
value.material.fill(0);
|
||||
value.custodyPublicKey.fill(0);
|
||||
value.approverPublicKeys.forEach(({ publicKey }) => publicKey.fill(0));
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/** Content-free Prompt Output external recovery verifier boundary. */
|
||||
import {
|
||||
verifyAuthorizedPluginPackagePromptOutputRecoveredMaterial,
|
||||
type PluginPackagePromptOutputAuthorizedExternalRecoveryProof,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-external-recovery-authorization';
|
||||
|
||||
import type { ClusterPromptOutputExternalRecoveryInput } from './promptOutputExternalRecoveryInput';
|
||||
|
||||
export class ClusterPromptOutputExternalRecoveryVerifierConfigError extends TypeError {
|
||||
readonly code = 'QL3_PROMPT_OUTPUT_EXTERNAL_RECOVERY_VERIFIER_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(
|
||||
`Prompt output external recovery verifier configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPromptOutputExternalRecoveryVerifierConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
export function runClusterPromptOutputExternalRecoveryVerifier(
|
||||
input: Readonly<ClusterPromptOutputExternalRecoveryInput>,
|
||||
verifiedAtMs = Date.now(),
|
||||
): Readonly<PluginPackagePromptOutputAuthorizedExternalRecoveryProof> {
|
||||
if (!Number.isSafeInteger(verifiedAtMs) || verifiedAtMs < 0) {
|
||||
throw new ClusterPromptOutputExternalRecoveryVerifierConfigError(
|
||||
'verification time is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return verifyAuthorizedPluginPackagePromptOutputRecoveredMaterial({
|
||||
authorization: input.authorization,
|
||||
trustedApprovers: input.approverPublicKeys,
|
||||
receipt: input.receipt,
|
||||
trustedCustodyPublicKey: input.custodyPublicKey,
|
||||
wrappedMaterial: input.wrappedMaterial,
|
||||
durableKeyFact: input.durableKeyFact,
|
||||
material: input.material,
|
||||
artifact: input.artifact,
|
||||
verifiedAtMs,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new ClusterPromptOutputExternalRecoveryVerifierConfigError(
|
||||
'recovery evidence is untrusted',
|
||||
cause,
|
||||
);
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** One-shot Prompt Output key retirement CLI boundary. */
|
||||
import {
|
||||
constants,
|
||||
closeSync,
|
||||
fstatSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type ClusterPromptOutputKubernetesSecretKeyringOptions } from './promptOutputKubernetesSecretKeyring';
|
||||
import { openPromptOutputKubernetesSecretAuthority } from './promptOutputKubernetesSecretAuthority';
|
||||
import { runClusterPromptOutputKeyRetirementProcess } from './promptOutputKeyRetirementProcess';
|
||||
import { loadPromptOutputPostgresMaintenanceConnection } from './promptOutputPostgresMaintenanceConnection';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-prompt-output-key-retire run --command-file /absolute/retirement.json';
|
||||
const MAX_COMMAND_FILE_BYTES = 16 * 1024;
|
||||
|
||||
interface RetirementCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'cluster.prompt-output-key.retire';
|
||||
readonly kubernetes: ClusterPromptOutputKubernetesSecretKeyringOptions;
|
||||
readonly request: Readonly<{
|
||||
keyId: string;
|
||||
retirementId: string;
|
||||
requestId: string;
|
||||
mutationId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function commandFile(filePath: string): RetirementCommand {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
!path.isAbsolute(filePath) ||
|
||||
filePath.includes('\0') ||
|
||||
Buffer.byteLength(filePath, 'utf8') > 4096
|
||||
) {
|
||||
throw new TypeError('Retirement command file path is invalid');
|
||||
}
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = fstatSync(descriptor);
|
||||
if (!stat.isFile() || stat.size < 1 || stat.size > MAX_COMMAND_FILE_BYTES) {
|
||||
throw new TypeError('Retirement command file is invalid');
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(descriptor, 'utf8')) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
!exactKeys(parsed, [
|
||||
'kubernetes',
|
||||
'operation',
|
||||
'request',
|
||||
'schemaVersion',
|
||||
])
|
||||
) {
|
||||
throw new TypeError('Retirement command shape is invalid');
|
||||
}
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
candidate.operation !== 'cluster.prompt-output-key.retire' ||
|
||||
!candidate.kubernetes ||
|
||||
typeof candidate.kubernetes !== 'object' ||
|
||||
Array.isArray(candidate.kubernetes) ||
|
||||
!candidate.request ||
|
||||
typeof candidate.request !== 'object' ||
|
||||
Array.isArray(candidate.request)
|
||||
) {
|
||||
throw new TypeError('Retirement command value is invalid');
|
||||
}
|
||||
const kubernetes = candidate.kubernetes as Record<string, unknown>;
|
||||
const request = candidate.request as Record<string, unknown>;
|
||||
if (
|
||||
!exactKeys(kubernetes, [
|
||||
'dataKey',
|
||||
'expectedSecretUid',
|
||||
'namespace',
|
||||
'secretName',
|
||||
]) ||
|
||||
!exactKeys(request, ['keyId', 'mutationId', 'requestId', 'retirementId'])
|
||||
) {
|
||||
throw new TypeError('Retirement command nested shape is invalid');
|
||||
}
|
||||
return Object.freeze(parsed as RetirementCommand);
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
!argv[2]
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PROMPT_OUTPUT_KEY_RETIREMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
let authority:
|
||||
| Awaited<ReturnType<typeof openPromptOutputKubernetesSecretAuthority>>
|
||||
| undefined;
|
||||
try {
|
||||
const command = commandFile(argv[2]);
|
||||
authority = await openPromptOutputKubernetesSecretAuthority(
|
||||
command.kubernetes,
|
||||
);
|
||||
const result = await runClusterPromptOutputKeyRetirementProcess({
|
||||
database: {
|
||||
connection: loadPromptOutputPostgresMaintenanceConnection(process.env),
|
||||
pool: {
|
||||
applicationName: 'qinglong3-prompt-output-key-retirement',
|
||||
maxConnections: 1,
|
||||
},
|
||||
},
|
||||
request: command.request,
|
||||
materials: authority.materials,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-key-retirement',
|
||||
event: 'key_retirement_completed',
|
||||
status: result.status,
|
||||
keyId: result.keyId,
|
||||
retirementId: result.retirementId,
|
||||
preparationDigest: result.preparationDigest,
|
||||
completionDigest: result.completionDigest,
|
||||
completedAtMs: result.completedAtMs,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-key-retirement',
|
||||
event: 'key_retirement_failed',
|
||||
name:
|
||||
typeof candidate.name === 'string'
|
||||
? candidate.name.slice(0, 128)
|
||||
: 'Error',
|
||||
...(typeof candidate.code === 'string'
|
||||
? { code: candidate.code.slice(0, 128) }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
authority?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/** Prompt Output key retirement transaction composition boundary. */
|
||||
import type { OpenPostgresDatabase } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/ai-maintenance';
|
||||
import {
|
||||
PluginPackagePromptOutputKeyRetirementCoordinator,
|
||||
normalizePluginPackagePromptOutputKeyRetirementRequest,
|
||||
type PluginPackagePromptOutputKeyRetirementMaterialAuthority,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-key-retirement';
|
||||
import { PostgresPluginPackagePromptOutputKeyRetirementRepository } from '@qinglong/ai/postgres-plugin-package-prompt-output-key-retirement-storage';
|
||||
import {
|
||||
assertPostgresPluginPackagePromptOutputMaintenanceReady,
|
||||
type PostgresPluginPackagePromptOutputMaintenanceReadinessReport,
|
||||
} from '@qinglong/ai/postgres-plugin-package-prompt-output-retention-storage';
|
||||
|
||||
export interface RunClusterPromptOutputKeyRetirementProcessOptions {
|
||||
readonly database: Readonly<{
|
||||
readonly connection: PostgresConnectionOptions;
|
||||
readonly pool?: PostgresPoolOptions;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
readonly keyId: string;
|
||||
readonly retirementId: string;
|
||||
readonly requestId: string;
|
||||
readonly mutationId: string;
|
||||
}>;
|
||||
readonly materials: PluginPackagePromptOutputKeyRetirementMaterialAuthority;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
}
|
||||
|
||||
export interface ClusterPromptOutputKeyRetirementProcessResult {
|
||||
readonly readiness: PostgresPluginPackagePromptOutputMaintenanceReadinessReport;
|
||||
readonly status: 'completed' | 'existing';
|
||||
readonly keyId: string;
|
||||
readonly retirementId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly completionDigest: string;
|
||||
readonly completedAtMs: number;
|
||||
}
|
||||
|
||||
export class ClusterPromptOutputKeyRetirementProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PROMPT_OUTPUT_KEY_RETIREMENT_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(
|
||||
`Prompt output key retirement process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPromptOutputKeyRetirementProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClusterPromptOutputKeyRetirementProcess(
|
||||
options: RunClusterPromptOutputKeyRetirementProcessOptions,
|
||||
): Promise<Readonly<ClusterPromptOutputKeyRetirementProcessResult>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!options.database ||
|
||||
typeof options.database !== 'object' ||
|
||||
Array.isArray(options.database) ||
|
||||
!options.materials ||
|
||||
typeof options.materials !== 'object' ||
|
||||
typeof options.materials.inspect !== 'function' ||
|
||||
typeof options.materials.retire !== 'function'
|
||||
) {
|
||||
throw new ClusterPromptOutputKeyRetirementProcessConfigError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
let request;
|
||||
try {
|
||||
request = normalizePluginPackagePromptOutputKeyRetirementRequest(
|
||||
options.request,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new ClusterPromptOutputKeyRetirementProcessConfigError(
|
||||
'request is invalid',
|
||||
cause,
|
||||
);
|
||||
}
|
||||
let poolError: Error | undefined;
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'ai-maintenance',
|
||||
connection: options.database.connection,
|
||||
...(options.database.pool === undefined
|
||||
? {}
|
||||
: { pool: options.database.pool }),
|
||||
onPoolError(error) {
|
||||
poolError ??= error;
|
||||
},
|
||||
});
|
||||
const database = await openDatabase();
|
||||
let failure: unknown;
|
||||
try {
|
||||
const readiness =
|
||||
await assertPostgresPluginPackagePromptOutputMaintenanceReady(
|
||||
database.pool,
|
||||
);
|
||||
const result = await new PluginPackagePromptOutputKeyRetirementCoordinator({
|
||||
repository: new PostgresPluginPackagePromptOutputKeyRetirementRepository({
|
||||
pool: database.pool,
|
||||
}),
|
||||
materials: options.materials,
|
||||
}).retire(request);
|
||||
if (poolError) throw poolError;
|
||||
return Object.freeze({
|
||||
readiness,
|
||||
status: result.status,
|
||||
keyId: result.preparation.keyId,
|
||||
retirementId: result.preparation.retirementId,
|
||||
preparationDigest: result.preparation.preparationDigest,
|
||||
completionDigest: result.completion.completionDigest,
|
||||
completedAtMs: result.completion.completedAtMs,
|
||||
});
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Prompt output key retirement failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** One-shot Prompt Output key rotation CLI boundary. */
|
||||
import { openPromptOutputKubernetesSecretAuthority } from './promptOutputKubernetesSecretAuthority';
|
||||
import { runClusterPromptOutputKeyRotationProcess } from './promptOutputKeyRotationProcess';
|
||||
import {
|
||||
readClusterPromptOutputKeyRotationCommand,
|
||||
readClusterPromptOutputKeyRotationMaterial,
|
||||
} from './promptOutputKeyRotationInput';
|
||||
import { loadPromptOutputPostgresMaintenanceConnection } from './promptOutputPostgresMaintenanceConnection';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-prompt-output-key-rotate run --command-file /absolute/rotation.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
!argv[2]
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PROMPT_OUTPUT_KEY_ROTATION_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
let authority:
|
||||
| Awaited<ReturnType<typeof openPromptOutputKubernetesSecretAuthority>>
|
||||
| undefined;
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
const command = readClusterPromptOutputKeyRotationCommand(argv[2]);
|
||||
authority = await openPromptOutputKubernetesSecretAuthority(
|
||||
command.kubernetes,
|
||||
);
|
||||
material = readClusterPromptOutputKeyRotationMaterial(
|
||||
command.stagedMaterialFile,
|
||||
);
|
||||
const result = await runClusterPromptOutputKeyRotationProcess({
|
||||
database: {
|
||||
connection: loadPromptOutputPostgresMaintenanceConnection(process.env),
|
||||
pool: {
|
||||
applicationName: 'qinglong3-prompt-output-key-rotation',
|
||||
maxConnections: 1,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
...command.request,
|
||||
expectedSecretUid: command.kubernetes.expectedSecretUid,
|
||||
},
|
||||
material,
|
||||
materials: authority.materials,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-key-rotation',
|
||||
event: 'key_rotation_completed',
|
||||
status: result.status,
|
||||
rotationId: result.rotationId,
|
||||
requestId: result.requestId,
|
||||
mutationId: result.mutationId,
|
||||
preparationDigest: result.preparationDigest,
|
||||
completionDigest: result.completionDigest,
|
||||
generation: result.generation,
|
||||
previousActiveKeyId: result.previousActiveKeyId,
|
||||
activeKeyId: result.activeKeyId,
|
||||
catalogDigest: result.catalogDigest,
|
||||
materialProof: result.materialProof,
|
||||
completedAtMs: result.completedAtMs,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-key-rotation',
|
||||
event: 'key_rotation_failed',
|
||||
name:
|
||||
typeof candidate.name === 'string'
|
||||
? candidate.name.slice(0, 128)
|
||||
: 'Error',
|
||||
...(typeof candidate.code === 'string'
|
||||
? { code: candidate.code.slice(0, 128) }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
authority?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/** Private staged Prompt Output key rotation input authority boundary. */
|
||||
import {
|
||||
constants,
|
||||
closeSync,
|
||||
fstatSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { ClusterPromptOutputKubernetesSecretKeyringOptions } from './promptOutputKubernetesSecretKeyring';
|
||||
|
||||
const MAX_COMMAND_FILE_BYTES = 16 * 1024;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface ClusterPromptOutputKeyRotationCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'cluster.prompt-output-key.rotate';
|
||||
readonly kubernetes: ClusterPromptOutputKubernetesSecretKeyringOptions;
|
||||
readonly stagedMaterialFile: string;
|
||||
readonly request: Readonly<{
|
||||
rotationId: string;
|
||||
requestId: string;
|
||||
mutationId: string;
|
||||
expectedActiveKeyId: string;
|
||||
expectedCatalogDigest: string;
|
||||
newKeyId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function absoluteFilePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readClusterPromptOutputKeyRotationCommand(
|
||||
filePathValue: string,
|
||||
): Readonly<ClusterPromptOutputKeyRotationCommand> {
|
||||
const filePath = absoluteFilePath(
|
||||
filePathValue,
|
||||
'Rotation command file path',
|
||||
);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = fstatSync(descriptor);
|
||||
if (!stat.isFile() || stat.size < 1 || stat.size > MAX_COMMAND_FILE_BYTES) {
|
||||
throw new TypeError('Rotation command file is invalid');
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(descriptor, 'utf8')) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
!exactKeys(parsed, [
|
||||
'kubernetes',
|
||||
'operation',
|
||||
'request',
|
||||
'schemaVersion',
|
||||
'stagedMaterialFile',
|
||||
])
|
||||
) {
|
||||
throw new TypeError('Rotation command shape is invalid');
|
||||
}
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
candidate.operation !== 'cluster.prompt-output-key.rotate' ||
|
||||
!candidate.kubernetes ||
|
||||
typeof candidate.kubernetes !== 'object' ||
|
||||
Array.isArray(candidate.kubernetes) ||
|
||||
!candidate.request ||
|
||||
typeof candidate.request !== 'object' ||
|
||||
Array.isArray(candidate.request)
|
||||
) {
|
||||
throw new TypeError('Rotation command value is invalid');
|
||||
}
|
||||
const kubernetes = candidate.kubernetes as Record<string, unknown>;
|
||||
const request = candidate.request as Record<string, unknown>;
|
||||
if (
|
||||
!exactKeys(kubernetes, [
|
||||
'dataKey',
|
||||
'expectedSecretUid',
|
||||
'namespace',
|
||||
'secretName',
|
||||
]) ||
|
||||
!exactKeys(request, [
|
||||
'expectedActiveKeyId',
|
||||
'expectedCatalogDigest',
|
||||
'mutationId',
|
||||
'newKeyId',
|
||||
'requestId',
|
||||
'rotationId',
|
||||
]) ||
|
||||
![
|
||||
request.rotationId,
|
||||
request.requestId,
|
||||
request.mutationId,
|
||||
request.expectedActiveKeyId,
|
||||
request.newKeyId,
|
||||
].every((value) => typeof value === 'string' && ID.test(value)) ||
|
||||
typeof request.expectedCatalogDigest !== 'string' ||
|
||||
!DIGEST.test(request.expectedCatalogDigest)
|
||||
) {
|
||||
throw new TypeError('Rotation command nested value is invalid');
|
||||
}
|
||||
const stagedMaterialFile = absoluteFilePath(
|
||||
candidate.stagedMaterialFile,
|
||||
'Staged material file path',
|
||||
);
|
||||
return Object.freeze({
|
||||
...(parsed as ClusterPromptOutputKeyRotationCommand),
|
||||
stagedMaterialFile,
|
||||
});
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function readClusterPromptOutputKeyRotationMaterial(
|
||||
filePathValue: string,
|
||||
): Buffer {
|
||||
const filePath = absoluteFilePath(filePathValue, 'Staged material file path');
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size !== 32 ||
|
||||
(before.mode & 0o222) !== 0 ||
|
||||
(before.mode & 0o111) !== 0 ||
|
||||
(before.mode & 0o007) !== 0 ||
|
||||
(before.mode & 0o440) === 0
|
||||
) {
|
||||
throw new TypeError('Staged rotation material is unavailable');
|
||||
}
|
||||
const material = readFileSync(descriptor);
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
material.byteLength !== 32 ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs
|
||||
) {
|
||||
material.fill(0);
|
||||
throw new TypeError('Staged rotation material changed during read');
|
||||
}
|
||||
return material;
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/** Prompt Output key rotation transaction composition boundary. */
|
||||
import type { OpenPostgresDatabase } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/ai-maintenance';
|
||||
import {
|
||||
PluginPackagePromptOutputKeyRotationCoordinator,
|
||||
normalizePluginPackagePromptOutputKeyRotationRequest,
|
||||
type PluginPackagePromptOutputKeyRotationMaterialAuthority,
|
||||
type PluginPackagePromptOutputKeyRotationRequest,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-key-rotation';
|
||||
import { PostgresPluginPackagePromptOutputKeyRotationRepository } from '@qinglong/ai/postgres-plugin-package-prompt-output-key-rotation-storage';
|
||||
import {
|
||||
assertPostgresPluginPackagePromptOutputMaintenanceReady,
|
||||
type PostgresPluginPackagePromptOutputMaintenanceReadinessReport,
|
||||
} from '@qinglong/ai/postgres-plugin-package-prompt-output-retention-storage';
|
||||
|
||||
export interface RunClusterPromptOutputKeyRotationProcessOptions {
|
||||
readonly database: Readonly<{
|
||||
readonly connection: PostgresConnectionOptions;
|
||||
readonly pool?: PostgresPoolOptions;
|
||||
}>;
|
||||
readonly request: Readonly<PluginPackagePromptOutputKeyRotationRequest>;
|
||||
readonly material: Uint8Array;
|
||||
readonly materials: PluginPackagePromptOutputKeyRotationMaterialAuthority;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
}
|
||||
|
||||
export interface ClusterPromptOutputKeyRotationProcessResult {
|
||||
readonly readiness: PostgresPluginPackagePromptOutputMaintenanceReadinessReport;
|
||||
readonly status: 'completed' | 'existing';
|
||||
readonly rotationId: string;
|
||||
readonly requestId: string;
|
||||
readonly mutationId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly completionDigest: string;
|
||||
readonly generation: number;
|
||||
readonly previousActiveKeyId: string;
|
||||
readonly activeKeyId: string;
|
||||
readonly catalogDigest: string;
|
||||
readonly materialProof: string;
|
||||
readonly completedAtMs: number;
|
||||
}
|
||||
|
||||
export class ClusterPromptOutputKeyRotationProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PROMPT_OUTPUT_KEY_ROTATION_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(
|
||||
`Prompt output key rotation process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPromptOutputKeyRotationProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClusterPromptOutputKeyRotationProcess(
|
||||
options: RunClusterPromptOutputKeyRotationProcessOptions,
|
||||
): Promise<Readonly<ClusterPromptOutputKeyRotationProcessResult>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!options.database ||
|
||||
typeof options.database !== 'object' ||
|
||||
Array.isArray(options.database) ||
|
||||
!(options.material instanceof Uint8Array) ||
|
||||
options.material.byteLength !== 32 ||
|
||||
!options.materials ||
|
||||
typeof options.materials !== 'object' ||
|
||||
typeof options.materials.rotate !== 'function'
|
||||
) {
|
||||
throw new ClusterPromptOutputKeyRotationProcessConfigError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
let request;
|
||||
try {
|
||||
request = normalizePluginPackagePromptOutputKeyRotationRequest(
|
||||
options.request,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new ClusterPromptOutputKeyRotationProcessConfigError(
|
||||
'request is invalid',
|
||||
cause,
|
||||
);
|
||||
}
|
||||
let poolError: Error | undefined;
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'ai-maintenance',
|
||||
connection: options.database.connection,
|
||||
...(options.database.pool === undefined
|
||||
? {}
|
||||
: { pool: options.database.pool }),
|
||||
onPoolError(error) {
|
||||
poolError ??= error;
|
||||
},
|
||||
});
|
||||
const database = await openDatabase();
|
||||
let failure: unknown;
|
||||
try {
|
||||
const readiness =
|
||||
await assertPostgresPluginPackagePromptOutputMaintenanceReady(
|
||||
database.pool,
|
||||
);
|
||||
const result = await new PluginPackagePromptOutputKeyRotationCoordinator({
|
||||
repository: new PostgresPluginPackagePromptOutputKeyRotationRepository({
|
||||
pool: database.pool,
|
||||
}),
|
||||
materials: options.materials,
|
||||
}).rotate({ request, material: options.material });
|
||||
if (poolError) throw poolError;
|
||||
return Object.freeze({
|
||||
readiness,
|
||||
status: result.status,
|
||||
rotationId: result.preparation.rotationId,
|
||||
requestId: result.preparation.requestId,
|
||||
mutationId: result.preparation.mutationId,
|
||||
preparationDigest: result.preparation.preparationDigest,
|
||||
completionDigest: result.completion.completionDigest,
|
||||
generation: result.completion.generation,
|
||||
previousActiveKeyId: result.completion.previousActiveKeyId,
|
||||
activeKeyId: result.completion.activeKeyId,
|
||||
catalogDigest: result.completion.catalogDigest,
|
||||
materialProof: result.completion.materialProof,
|
||||
completedAtMs: result.completion.completedAtMs,
|
||||
});
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Prompt output key rotation failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/** Least-privilege Kubernetes Secret authority assembly boundary. */
|
||||
import {
|
||||
ClusterPromptOutputKubernetesSecretKeyring,
|
||||
type ClusterPromptOutputKubernetesSecretApi,
|
||||
type ClusterPromptOutputKubernetesSecretKeyringOptions,
|
||||
} from './promptOutputKubernetesSecretKeyring';
|
||||
|
||||
interface AccessReviewAttributes {
|
||||
readonly namespace?: string;
|
||||
readonly verb: string;
|
||||
readonly resource: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
interface AuthorizationApi {
|
||||
createSelfSubjectAccessReview(
|
||||
request: Readonly<{
|
||||
body: Readonly<{
|
||||
apiVersion: 'authorization.k8s.io/v1';
|
||||
kind: 'SelfSubjectAccessReview';
|
||||
spec: Readonly<{ resourceAttributes: AccessReviewAttributes }>;
|
||||
}>;
|
||||
}>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status?: Readonly<{ allowed?: boolean; denied?: boolean }>;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
function accessMatrix(
|
||||
options: ClusterPromptOutputKubernetesSecretKeyringOptions,
|
||||
): Readonly<{
|
||||
allowed: readonly AccessReviewAttributes[];
|
||||
denied: readonly AccessReviewAttributes[];
|
||||
}> {
|
||||
return Object.freeze({
|
||||
allowed: Object.freeze([
|
||||
{
|
||||
namespace: options.namespace,
|
||||
verb: 'get',
|
||||
resource: 'secrets',
|
||||
name: options.secretName,
|
||||
},
|
||||
{
|
||||
namespace: options.namespace,
|
||||
verb: 'update',
|
||||
resource: 'secrets',
|
||||
name: options.secretName,
|
||||
},
|
||||
]),
|
||||
denied: Object.freeze([
|
||||
{ namespace: options.namespace, verb: 'list', resource: 'secrets' },
|
||||
{ namespace: options.namespace, verb: 'watch', resource: 'secrets' },
|
||||
{ namespace: options.namespace, verb: 'create', resource: 'secrets' },
|
||||
{
|
||||
namespace: options.namespace,
|
||||
verb: 'delete',
|
||||
resource: 'secrets',
|
||||
name: options.secretName,
|
||||
},
|
||||
{
|
||||
namespace: options.namespace,
|
||||
verb: 'patch',
|
||||
resource: 'secrets',
|
||||
name: options.secretName,
|
||||
},
|
||||
{
|
||||
namespace: options.namespace,
|
||||
verb: 'get',
|
||||
resource: 'secrets',
|
||||
name: `${options.secretName}-other`,
|
||||
},
|
||||
{ namespace: options.namespace, verb: 'get', resource: 'configmaps' },
|
||||
{ namespace: options.namespace, verb: 'get', resource: 'pods' },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async function assertExactKubernetesAuthority(
|
||||
api: AuthorizationApi,
|
||||
options: ClusterPromptOutputKubernetesSecretKeyringOptions,
|
||||
): Promise<void> {
|
||||
const matrix = accessMatrix(options);
|
||||
for (const [expected, checks] of [
|
||||
[true, matrix.allowed],
|
||||
[false, matrix.denied],
|
||||
] as const) {
|
||||
for (const attributes of checks) {
|
||||
const review = await api.createSelfSubjectAccessReview({
|
||||
body: {
|
||||
apiVersion: 'authorization.k8s.io/v1',
|
||||
kind: 'SelfSubjectAccessReview',
|
||||
spec: { resourceAttributes: attributes },
|
||||
},
|
||||
});
|
||||
if (
|
||||
review.status?.allowed !== expected ||
|
||||
(expected && review.status.denied === true)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Kubernetes Secret lifecycle authority is not exact',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function openPromptOutputKubernetesSecretAuthority(
|
||||
options: ClusterPromptOutputKubernetesSecretKeyringOptions,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
materials: ClusterPromptOutputKubernetesSecretKeyring;
|
||||
dispose(): void;
|
||||
}>
|
||||
> {
|
||||
const kubernetes = await import('@kubernetes/client-node');
|
||||
const config = new kubernetes.KubeConfig();
|
||||
config.loadFromCluster();
|
||||
const cluster = config.getCurrentCluster();
|
||||
let server: URL;
|
||||
try {
|
||||
server = new URL(cluster?.server ?? '');
|
||||
} catch {
|
||||
throw new TypeError('Kubernetes cluster authority is invalid');
|
||||
}
|
||||
if (
|
||||
!cluster ||
|
||||
server.protocol !== 'https:' ||
|
||||
server.username !== '' ||
|
||||
server.password !== '' ||
|
||||
server.hash !== '' ||
|
||||
cluster.skipTLSVerify === true ||
|
||||
(typeof cluster.caData !== 'string' && typeof cluster.caFile !== 'string')
|
||||
) {
|
||||
throw new TypeError('Kubernetes cluster authority is invalid');
|
||||
}
|
||||
const secrets = config.makeApiClient(
|
||||
kubernetes.CoreV1Api,
|
||||
) as unknown as ClusterPromptOutputKubernetesSecretApi;
|
||||
const authorization = config.makeApiClient(
|
||||
kubernetes.AuthorizationV1Api,
|
||||
) as unknown as AuthorizationApi;
|
||||
await assertExactKubernetesAuthority(authorization, options);
|
||||
let active = true;
|
||||
return Object.freeze({
|
||||
materials: new ClusterPromptOutputKubernetesSecretKeyring(secrets, options),
|
||||
dispose() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
for (const user of config.getUsers()) {
|
||||
(user as { token?: string }).token = '';
|
||||
}
|
||||
config.setCurrentContext('disposed');
|
||||
},
|
||||
});
|
||||
}
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
/** ResourceVersion-fenced Prompt Output Kubernetes keyring boundary. */
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import {
|
||||
canonicalPluginPackagePromptOutputKeyringManifest,
|
||||
inspectPluginPackagePromptOutputKeyringManifest,
|
||||
parsePluginPackagePromptOutputKeyringManifest,
|
||||
pluginPackagePromptOutputKeyringCatalogDigest,
|
||||
retirePluginPackagePromptOutputKeyringManifest,
|
||||
rotatePluginPackagePromptOutputKeyringManifest,
|
||||
type PluginPackagePromptOutputKeyringManifest,
|
||||
type PluginPackagePromptOutputKeyringRotationMutation,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-keyring-manifest';
|
||||
import {
|
||||
InvalidPluginPackagePromptOutputKeyRetirementError,
|
||||
PluginPackagePromptOutputKeyRetirementConflictError,
|
||||
PluginPackagePromptOutputKeyRetirementUnavailableError,
|
||||
type PluginPackagePromptOutputKeyMaterialState,
|
||||
type PluginPackagePromptOutputKeyRetirementMaterialAuthority,
|
||||
type PluginPackagePromptOutputKeyRetirementPreparation,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-key-retirement';
|
||||
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const DNS_SUBDOMAIN = /^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/;
|
||||
const DATA_KEY = /^[A-Za-z0-9._-]{1,253}$/;
|
||||
const UID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RESOURCE_VERSION = /^[1-9][0-9]{0,31}$/;
|
||||
const BASE64 =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||
const FIELD_MANAGER = 'qinglong-prompt-output-key-retirement';
|
||||
const ROTATION_FIELD_MANAGER = 'qinglong-prompt-output-key-rotation';
|
||||
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
||||
const MANAGED_BY_VALUE = 'qinglong3';
|
||||
const KEYRING_LABEL = 'qinglong.io/prompt-output-keyring';
|
||||
const KEYRING_LABEL_VALUE = 'v1';
|
||||
const GENERATION_ANNOTATION = 'qinglong.io/prompt-output-keyring-generation';
|
||||
const CATALOG_DIGEST_ANNOTATION =
|
||||
'qinglong.io/prompt-output-keyring-catalog-digest';
|
||||
const LAST_APPLIED_ANNOTATION =
|
||||
'kubectl.kubernetes.io/last-applied-configuration';
|
||||
const MAX_SECRET_DATA_BYTES = 384 * 1024;
|
||||
|
||||
export interface ClusterPromptOutputKubernetesSecretKeyringOptions {
|
||||
readonly namespace: string;
|
||||
readonly secretName: string;
|
||||
readonly expectedSecretUid: string;
|
||||
readonly dataKey?: string;
|
||||
}
|
||||
|
||||
export interface ClusterPromptOutputKubernetesSecret {
|
||||
readonly apiVersion?: string;
|
||||
readonly kind?: string;
|
||||
readonly type?: string;
|
||||
readonly immutable?: boolean;
|
||||
readonly stringData?: Readonly<Record<string, string>>;
|
||||
readonly data?: Readonly<Record<string, string>>;
|
||||
readonly metadata?: Readonly<{
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
deletionTimestamp?: Date | string;
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
annotations?: Readonly<Record<string, string>>;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClusterPromptOutputKubernetesSecretWrite
|
||||
extends ClusterPromptOutputKubernetesSecret {
|
||||
readonly apiVersion: 'v1';
|
||||
readonly kind: 'Secret';
|
||||
readonly type: 'Opaque';
|
||||
readonly immutable: false;
|
||||
readonly metadata: NonNullable<
|
||||
ClusterPromptOutputKubernetesSecret['metadata']
|
||||
>;
|
||||
readonly data: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface ClusterPromptOutputKubernetesSecretApi {
|
||||
readNamespacedSecret(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
}>,
|
||||
): Promise<ClusterPromptOutputKubernetesSecret>;
|
||||
replaceNamespacedSecret(
|
||||
request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
body: ClusterPromptOutputKubernetesSecretWrite;
|
||||
fieldManager: typeof FIELD_MANAGER | typeof ROTATION_FIELD_MANAGER;
|
||||
fieldValidation: 'Strict';
|
||||
}>,
|
||||
): Promise<ClusterPromptOutputKubernetesSecret>;
|
||||
}
|
||||
|
||||
interface StoredKeyring {
|
||||
readonly secret: ClusterPromptOutputKubernetesSecret;
|
||||
readonly manifest: Readonly<PluginPackagePromptOutputKeyringManifest>;
|
||||
readonly resourceVersion: string;
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackagePromptOutputKeyRetirementUnavailableError {
|
||||
return new PluginPackagePromptOutputKeyRetirementUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function apiStatus(cause: unknown): number | null {
|
||||
if (!cause || typeof cause !== 'object') return null;
|
||||
if ('code' in cause && typeof cause.code === 'number') return cause.code;
|
||||
if (
|
||||
'response' in cause &&
|
||||
cause.response &&
|
||||
typeof cause.response === 'object' &&
|
||||
'statusCode' in cause.response &&
|
||||
typeof cause.response.statusCode === 'number'
|
||||
) {
|
||||
return cause.response.statusCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function option(value: unknown, pattern: RegExp, label: string): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
throw new InvalidPluginPackagePromptOutputKeyRetirementError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameAbsentState(
|
||||
left: Readonly<{
|
||||
state: 'absent';
|
||||
keyId: string;
|
||||
catalogDigest: string;
|
||||
absenceProof: string;
|
||||
}>,
|
||||
right: PluginPackagePromptOutputKeyMaterialState,
|
||||
): boolean {
|
||||
return (
|
||||
right.state === 'absent' &&
|
||||
right.keyId === left.keyId &&
|
||||
right.catalogDigest === left.catalogDigest &&
|
||||
right.absenceProof === left.absenceProof
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived, resourceVersion-fenced adapter for one dedicated mutable Secret.
|
||||
* It owns no timer, watcher, cache, Secret creation, or runtime key resolution.
|
||||
*/
|
||||
export class ClusterPromptOutputKubernetesSecretKeyring
|
||||
implements PluginPackagePromptOutputKeyRetirementMaterialAuthority
|
||||
{
|
||||
readonly #namespace: string;
|
||||
readonly #secretName: string;
|
||||
readonly #expectedSecretUid: string;
|
||||
readonly #dataKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly api: ClusterPromptOutputKubernetesSecretApi,
|
||||
options: ClusterPromptOutputKubernetesSecretKeyringOptions,
|
||||
) {
|
||||
if (
|
||||
!api ||
|
||||
typeof api !== 'object' ||
|
||||
typeof api.readNamespacedSecret !== 'function' ||
|
||||
typeof api.replaceNamespacedSecret !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options)
|
||||
) {
|
||||
throw new InvalidPluginPackagePromptOutputKeyRetirementError(
|
||||
'Kubernetes Secret keyring options are invalid',
|
||||
);
|
||||
}
|
||||
this.#namespace = option(options.namespace, DNS_LABEL, 'namespace');
|
||||
this.#secretName = option(options.secretName, DNS_SUBDOMAIN, 'secretName');
|
||||
this.#expectedSecretUid = option(
|
||||
options.expectedSecretUid,
|
||||
UID,
|
||||
'expectedSecretUid',
|
||||
);
|
||||
this.#dataKey = option(
|
||||
options.dataKey ?? 'keyring.json',
|
||||
DATA_KEY,
|
||||
'dataKey',
|
||||
);
|
||||
}
|
||||
|
||||
async inspect(
|
||||
keyId: string,
|
||||
): Promise<PluginPackagePromptOutputKeyMaterialState> {
|
||||
return inspectPluginPackagePromptOutputKeyringManifest(
|
||||
(await this.#read()).manifest,
|
||||
keyId,
|
||||
);
|
||||
}
|
||||
|
||||
async retire(
|
||||
command: Readonly<{
|
||||
preparation: Readonly<PluginPackagePromptOutputKeyRetirementPreparation>;
|
||||
}>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
state: 'absent';
|
||||
keyId: string;
|
||||
catalogDigest: string;
|
||||
absenceProof: string;
|
||||
}>
|
||||
> {
|
||||
const current = await this.#read();
|
||||
const mutation = retirePluginPackagePromptOutputKeyringManifest(
|
||||
current.manifest,
|
||||
command.preparation,
|
||||
);
|
||||
if (!mutation.changed) return mutation.state;
|
||||
try {
|
||||
const written = this.#parse(
|
||||
await this.api.replaceNamespacedSecret({
|
||||
name: this.#secretName,
|
||||
namespace: this.#namespace,
|
||||
body: this.#body(current, mutation.manifest),
|
||||
fieldManager: FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
}),
|
||||
);
|
||||
const state = inspectPluginPackagePromptOutputKeyringManifest(
|
||||
written.manifest,
|
||||
mutation.state.keyId,
|
||||
);
|
||||
if (!sameAbsentState(mutation.state, state)) {
|
||||
throw new PluginPackagePromptOutputKeyRetirementConflictError();
|
||||
}
|
||||
return mutation.state;
|
||||
} catch (cause) {
|
||||
const status = apiStatus(cause);
|
||||
if (status !== 409 && status !== null) {
|
||||
if (
|
||||
cause instanceof
|
||||
PluginPackagePromptOutputKeyRetirementConflictError ||
|
||||
cause instanceof
|
||||
PluginPackagePromptOutputKeyRetirementUnavailableError
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
throw unavailable(cause);
|
||||
}
|
||||
try {
|
||||
const winner = retirePluginPackagePromptOutputKeyringManifest(
|
||||
(await this.#read()).manifest,
|
||||
command.preparation,
|
||||
);
|
||||
if (!winner.changed && sameAbsentState(mutation.state, winner.state)) {
|
||||
return winner.state;
|
||||
}
|
||||
} catch (replayCause) {
|
||||
if (status === null) throw unavailable(cause);
|
||||
throw replayCause;
|
||||
}
|
||||
if (status === null) throw unavailable(cause);
|
||||
throw new PluginPackagePromptOutputKeyRetirementConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async rotate(
|
||||
command: Readonly<{
|
||||
expectedActiveKeyId: string;
|
||||
expectedCatalogDigest: string;
|
||||
newKeyId: string;
|
||||
material: Uint8Array;
|
||||
}>,
|
||||
): Promise<
|
||||
Readonly<PluginPackagePromptOutputKeyringRotationMutation['state']>
|
||||
> {
|
||||
const current = await this.#read();
|
||||
const mutation = rotatePluginPackagePromptOutputKeyringManifest(
|
||||
current.manifest,
|
||||
command,
|
||||
);
|
||||
if (!mutation.changed) return mutation.state;
|
||||
try {
|
||||
const written = this.#parse(
|
||||
await this.api.replaceNamespacedSecret({
|
||||
name: this.#secretName,
|
||||
namespace: this.#namespace,
|
||||
body: this.#body(current, mutation.manifest),
|
||||
fieldManager: ROTATION_FIELD_MANAGER,
|
||||
fieldValidation: 'Strict',
|
||||
}),
|
||||
);
|
||||
const winner = rotatePluginPackagePromptOutputKeyringManifest(
|
||||
written.manifest,
|
||||
command,
|
||||
);
|
||||
if (winner.changed) {
|
||||
throw new PluginPackagePromptOutputKeyRetirementConflictError();
|
||||
}
|
||||
return winner.state;
|
||||
} catch (cause) {
|
||||
const status = apiStatus(cause);
|
||||
if (status !== 409 && status !== null) {
|
||||
if (
|
||||
cause instanceof
|
||||
PluginPackagePromptOutputKeyRetirementConflictError ||
|
||||
cause instanceof
|
||||
PluginPackagePromptOutputKeyRetirementUnavailableError
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
throw unavailable(cause);
|
||||
}
|
||||
try {
|
||||
const winner = rotatePluginPackagePromptOutputKeyringManifest(
|
||||
(await this.#read()).manifest,
|
||||
command,
|
||||
);
|
||||
if (!winner.changed) return winner.state;
|
||||
} catch (replayCause) {
|
||||
if (status === null) throw unavailable(cause);
|
||||
throw replayCause;
|
||||
}
|
||||
if (status === null) throw unavailable(cause);
|
||||
throw new PluginPackagePromptOutputKeyRetirementConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async #read(): Promise<StoredKeyring> {
|
||||
try {
|
||||
return this.#parse(
|
||||
await this.api.readNamespacedSecret({
|
||||
name: this.#secretName,
|
||||
namespace: this.#namespace,
|
||||
}),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof PluginPackagePromptOutputKeyRetirementConflictError ||
|
||||
cause instanceof PluginPackagePromptOutputKeyRetirementUnavailableError
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
throw unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
#parse(secret: ClusterPromptOutputKubernetesSecret): StoredKeyring {
|
||||
const metadata = secret?.metadata;
|
||||
const data = secret?.data;
|
||||
const encoded = data?.[this.#dataKey];
|
||||
let bytes: Buffer | undefined;
|
||||
let canonical: Buffer | undefined;
|
||||
try {
|
||||
if (
|
||||
secret.apiVersion !== 'v1' ||
|
||||
secret.kind !== 'Secret' ||
|
||||
secret.type !== 'Opaque' ||
|
||||
secret.immutable !== false ||
|
||||
secret.stringData !== undefined ||
|
||||
!metadata ||
|
||||
metadata.name !== this.#secretName ||
|
||||
metadata.namespace !== this.#namespace ||
|
||||
metadata.uid !== this.#expectedSecretUid ||
|
||||
typeof metadata.resourceVersion !== 'string' ||
|
||||
!RESOURCE_VERSION.test(metadata.resourceVersion) ||
|
||||
metadata.deletionTimestamp !== undefined ||
|
||||
metadata.labels?.[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE ||
|
||||
metadata.labels?.[KEYRING_LABEL] !== KEYRING_LABEL_VALUE ||
|
||||
metadata.annotations?.[LAST_APPLIED_ANNOTATION] !== undefined ||
|
||||
!data ||
|
||||
!exactKeys(data, [this.#dataKey]) ||
|
||||
typeof encoded !== 'string' ||
|
||||
encoded.length < 1 ||
|
||||
encoded.length > MAX_SECRET_DATA_BYTES ||
|
||||
!BASE64.test(encoded)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
bytes = Buffer.from(encoded, 'base64');
|
||||
if (bytes.toString('base64') !== encoded) throw unavailable();
|
||||
const manifest = parsePluginPackagePromptOutputKeyringManifest(bytes);
|
||||
canonical = canonicalPluginPackagePromptOutputKeyringManifest(manifest);
|
||||
if (
|
||||
!bytes.equals(canonical) ||
|
||||
metadata.annotations?.[GENERATION_ANNOTATION] !==
|
||||
String(manifest.generation) ||
|
||||
metadata.annotations?.[CATALOG_DIGEST_ANNOTATION] !==
|
||||
pluginPackagePromptOutputKeyringCatalogDigest(manifest)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
secret,
|
||||
manifest,
|
||||
resourceVersion: metadata.resourceVersion,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw cause instanceof
|
||||
PluginPackagePromptOutputKeyRetirementUnavailableError
|
||||
? cause
|
||||
: unavailable(cause);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
canonical?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
#body(
|
||||
current: StoredKeyring,
|
||||
manifest: Readonly<PluginPackagePromptOutputKeyringManifest>,
|
||||
): ClusterPromptOutputKubernetesSecretWrite {
|
||||
const bytes = canonicalPluginPackagePromptOutputKeyringManifest(manifest);
|
||||
const { stringData: _ignoredStringData, ...secretWithoutStringData } =
|
||||
current.secret;
|
||||
try {
|
||||
return Object.freeze({
|
||||
...secretWithoutStringData,
|
||||
apiVersion: 'v1' as const,
|
||||
kind: 'Secret' as const,
|
||||
type: 'Opaque' as const,
|
||||
immutable: false as const,
|
||||
metadata: Object.freeze({
|
||||
...current.secret.metadata,
|
||||
name: this.#secretName,
|
||||
namespace: this.#namespace,
|
||||
uid: this.#expectedSecretUid,
|
||||
resourceVersion: current.resourceVersion,
|
||||
labels: Object.freeze({
|
||||
...current.secret.metadata?.labels,
|
||||
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
|
||||
[KEYRING_LABEL]: KEYRING_LABEL_VALUE,
|
||||
}),
|
||||
annotations: Object.freeze({
|
||||
...current.secret.metadata?.annotations,
|
||||
[GENERATION_ANNOTATION]: String(manifest.generation),
|
||||
[CATALOG_DIGEST_ANNOTATION]:
|
||||
pluginPackagePromptOutputKeyringCatalogDigest(manifest),
|
||||
}),
|
||||
}),
|
||||
data: Object.freeze({ [this.#dataKey]: bytes.toString('base64') }),
|
||||
});
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const clusterPromptOutputKubernetesSecretKeyringMetadata = Object.freeze(
|
||||
{
|
||||
fieldManager: FIELD_MANAGER,
|
||||
rotationFieldManager: ROTATION_FIELD_MANAGER,
|
||||
managedByLabel: MANAGED_BY_LABEL,
|
||||
managedByValue: MANAGED_BY_VALUE,
|
||||
keyringLabel: KEYRING_LABEL,
|
||||
keyringLabelValue: KEYRING_LABEL_VALUE,
|
||||
generationAnnotation: GENERATION_ANNOTATION,
|
||||
catalogDigestAnnotation: CATALOG_DIGEST_ANNOTATION,
|
||||
},
|
||||
);
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/** Bounded Prompt Output maintenance PostgreSQL connection boundary. */
|
||||
import {
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
} from '@qinglong/cluster-postgres/ai-maintenance';
|
||||
|
||||
export function loadPromptOutputPostgresMaintenanceConnection(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): PostgresConnectionOptions {
|
||||
const base = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_AI_MAINTENANCE_URL',
|
||||
host: 'QL3_POSTGRES_AI_MAINTENANCE_HOST',
|
||||
port: 'QL3_POSTGRES_AI_MAINTENANCE_PORT',
|
||||
database: 'QL3_POSTGRES_AI_MAINTENANCE_DATABASE',
|
||||
user: 'QL3_POSTGRES_AI_MAINTENANCE_USER',
|
||||
password: 'QL3_POSTGRES_AI_MAINTENANCE_PASSWORD',
|
||||
});
|
||||
const mode = environment.QL3_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
if (mode === 'disable') {
|
||||
if (environment.QL3_POSTGRES_ALLOW_INSECURE !== 'true') {
|
||||
throw new TypeError('Insecure PostgreSQL requires an explicit gate');
|
||||
}
|
||||
return Object.freeze({ ...base, tls: { mode: 'disable' as const } });
|
||||
}
|
||||
if (mode !== 'verify-full') {
|
||||
throw new TypeError('PostgreSQL TLS mode is invalid');
|
||||
}
|
||||
const caFile = environment.QL3_POSTGRES_TLS_CA_FILE;
|
||||
const servername = environment.QL3_POSTGRES_TLS_SERVERNAME;
|
||||
if (!caFile || !servername) {
|
||||
throw new TypeError('PostgreSQL TLS CA and servername are required');
|
||||
}
|
||||
return Object.freeze({
|
||||
...base,
|
||||
tls: {
|
||||
mode: 'verify-full' as const,
|
||||
ca: loadPostgresCertificateAuthorityFile(caFile),
|
||||
servername,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** One-shot Prompt Output garbage collection CLI boundary. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
} from '@qinglong/cluster-postgres/ai-maintenance';
|
||||
import type { PluginPackagePromptOutputRetentionPolicyCatalog } from '@qinglong/ai/plugin-package-prompt-output-retention';
|
||||
|
||||
import { runClusterPromptOutputGcProcess } from './promptOutputGcProcess';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-prompt-output-gc run --policy-file /absolute/retention-policies.json';
|
||||
const MAX_POLICY_FILE_BYTES = 65_536;
|
||||
|
||||
function policyCatalog(
|
||||
filePath: string,
|
||||
): PluginPackagePromptOutputRetentionPolicyCatalog {
|
||||
if (!path.isAbsolute(filePath) || filePath.includes('\0')) {
|
||||
throw new TypeError('Policy file path is invalid');
|
||||
}
|
||||
const bytes = readFileSync(filePath);
|
||||
if (bytes.length < 1 || bytes.length > MAX_POLICY_FILE_BYTES) {
|
||||
throw new TypeError('Policy file size is invalid');
|
||||
}
|
||||
return JSON.parse(
|
||||
bytes.toString('utf8'),
|
||||
) as PluginPackagePromptOutputRetentionPolicyCatalog;
|
||||
}
|
||||
|
||||
function limit(): number {
|
||||
const raw = process.env.QL3_PROMPT_OUTPUT_GC_LIMIT ?? '32';
|
||||
if (!/^\d+$/.test(raw)) throw new TypeError('GC limit is invalid');
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > 128) {
|
||||
throw new TypeError('GC limit is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function connection(): PostgresConnectionOptions {
|
||||
const base = loadPostgresConnectionEnvironment(process.env, {
|
||||
connectionString: 'QL3_POSTGRES_AI_MAINTENANCE_URL',
|
||||
host: 'QL3_POSTGRES_AI_MAINTENANCE_HOST',
|
||||
port: 'QL3_POSTGRES_AI_MAINTENANCE_PORT',
|
||||
database: 'QL3_POSTGRES_AI_MAINTENANCE_DATABASE',
|
||||
user: 'QL3_POSTGRES_AI_MAINTENANCE_USER',
|
||||
password: 'QL3_POSTGRES_AI_MAINTENANCE_PASSWORD',
|
||||
});
|
||||
const mode = process.env.QL3_POSTGRES_TLS_MODE ?? 'verify-full';
|
||||
if (mode === 'disable') {
|
||||
if (process.env.QL3_POSTGRES_ALLOW_INSECURE !== 'true') {
|
||||
throw new TypeError('Insecure PostgreSQL requires an explicit gate');
|
||||
}
|
||||
return Object.freeze({ ...base, tls: { mode: 'disable' as const } });
|
||||
}
|
||||
if (mode !== 'verify-full')
|
||||
throw new TypeError('PostgreSQL TLS mode is invalid');
|
||||
const caFile = process.env.QL3_POSTGRES_TLS_CA_FILE;
|
||||
const servername = process.env.QL3_POSTGRES_TLS_SERVERNAME;
|
||||
if (!caFile || !servername) {
|
||||
throw new TypeError('PostgreSQL TLS CA and servername are required');
|
||||
}
|
||||
return Object.freeze({
|
||||
...base,
|
||||
tls: {
|
||||
mode: 'verify-full' as const,
|
||||
ca: loadPostgresCertificateAuthorityFile(caFile),
|
||||
servername,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--policy-file' ||
|
||||
!argv[2]
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_PROMPT_OUTPUT_GC_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runClusterPromptOutputGcProcess({
|
||||
database: {
|
||||
connection: connection(),
|
||||
pool: {
|
||||
applicationName: 'qinglong3-prompt-output-gc',
|
||||
maxConnections: 1,
|
||||
},
|
||||
},
|
||||
retentionPolicyCatalog: policyCatalog(argv[2]),
|
||||
limit: limit(),
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-gc',
|
||||
event: 'gc_completed',
|
||||
scanned: result.scanned,
|
||||
tombstoned: result.tombstoned,
|
||||
skipped: result.skipped,
|
||||
hasMore: result.hasMore,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-prompt-output-gc',
|
||||
event: 'gc_failed',
|
||||
name:
|
||||
typeof candidate.name === 'string'
|
||||
? candidate.name.slice(0, 128)
|
||||
: 'Error',
|
||||
...(typeof candidate.code === 'string'
|
||||
? { code: candidate.code.slice(0, 128) }
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,112 @@
|
||||
/** Prompt Output garbage collection PostgreSQL process boundary. */
|
||||
import type { OpenPostgresDatabase } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/ai-maintenance';
|
||||
import {
|
||||
PostgresPluginPackagePromptOutputGarbageCollector,
|
||||
assertPostgresPluginPackagePromptOutputMaintenanceReady,
|
||||
type PostgresPluginPackagePromptOutputMaintenanceReadinessReport,
|
||||
} from '@qinglong/ai/postgres-plugin-package-prompt-output-retention-storage';
|
||||
import {
|
||||
createPluginPackagePromptOutputRetentionPolicyCatalogResolver,
|
||||
type PluginPackagePromptOutputRetentionPolicyCatalog,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-retention';
|
||||
|
||||
export interface RunClusterPromptOutputGcProcessOptions {
|
||||
readonly database: Readonly<{
|
||||
readonly connection: PostgresConnectionOptions;
|
||||
readonly pool?: PostgresPoolOptions;
|
||||
}>;
|
||||
readonly retentionPolicyCatalog: PluginPackagePromptOutputRetentionPolicyCatalog;
|
||||
readonly limit?: number;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
}
|
||||
|
||||
export interface ClusterPromptOutputGcProcessResult {
|
||||
readonly readiness: PostgresPluginPackagePromptOutputMaintenanceReadinessReport;
|
||||
readonly scanned: number;
|
||||
readonly tombstoned: number;
|
||||
readonly skipped: number;
|
||||
readonly hasMore: boolean;
|
||||
}
|
||||
|
||||
export class ClusterPromptOutputGcProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_PROMPT_OUTPUT_GC_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Prompt output GC process configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterPromptOutputGcProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClusterPromptOutputGcProcess(
|
||||
options: RunClusterPromptOutputGcProcessOptions,
|
||||
): Promise<Readonly<ClusterPromptOutputGcProcessResult>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!options.database ||
|
||||
typeof options.database !== 'object' ||
|
||||
Array.isArray(options.database)
|
||||
) {
|
||||
throw new ClusterPromptOutputGcProcessConfigError('options are invalid');
|
||||
}
|
||||
let policies;
|
||||
try {
|
||||
policies = createPluginPackagePromptOutputRetentionPolicyCatalogResolver(
|
||||
options.retentionPolicyCatalog,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new ClusterPromptOutputGcProcessConfigError(
|
||||
'retention policy catalog is invalid',
|
||||
cause,
|
||||
);
|
||||
}
|
||||
let poolError: Error | undefined;
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'ai-maintenance',
|
||||
connection: options.database.connection,
|
||||
...(options.database.pool === undefined
|
||||
? {}
|
||||
: { pool: options.database.pool }),
|
||||
onPoolError(error) {
|
||||
poolError ??= error;
|
||||
},
|
||||
});
|
||||
const database = await openDatabase();
|
||||
let failure: unknown;
|
||||
try {
|
||||
const readiness =
|
||||
await assertPostgresPluginPackagePromptOutputMaintenanceReady(
|
||||
database.pool,
|
||||
);
|
||||
const result = await new PostgresPluginPackagePromptOutputGarbageCollector({
|
||||
pool: database.pool,
|
||||
policies,
|
||||
...(options.limit === undefined ? {} : { limit: options.limit }),
|
||||
}).collect();
|
||||
if (poolError) throw poolError;
|
||||
return Object.freeze({ readiness, ...result });
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Prompt output GC failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// Security Administration owns identity and API credential mutations.
|
||||
import { randomBytes as nodeRandomBytes } from 'node:crypto';
|
||||
import {
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
type ApiCredentialAdministrationRepository,
|
||||
type AppendApiCredentialResult,
|
||||
type ResolvedApiCredentialMutation,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
apiCredentialSecretDigest,
|
||||
assertApiCredentialPepper,
|
||||
formatApiCredentialToken,
|
||||
} from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
type ApiCredentialRecord,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
type AppendIdentitySubjectResult,
|
||||
IdentityAdministrationMutationConflictError,
|
||||
type IdentityAdministrationOperation,
|
||||
type IdentityAdministrationRepository,
|
||||
type ResolvedIdentitySubjectMutation,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const MAX_CREDENTIAL_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export interface ClusterAdministrationOptions {
|
||||
readonly now?: () => number;
|
||||
readonly randomBytes?: (size: number) => Buffer;
|
||||
}
|
||||
|
||||
export interface IdentityAdministrationRequest {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface CredentialAdministrationRequest {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly credentialId: string;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ActiveCredentialAdministrationRequest
|
||||
extends CredentialAdministrationRequest {
|
||||
readonly notBeforeAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface CredentialAdministrationResult
|
||||
extends AppendApiCredentialResult {
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
export interface ClusterAdministrationService {
|
||||
registerIdentity(
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult>;
|
||||
enableIdentity(
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult>;
|
||||
disableIdentity(
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult>;
|
||||
issueCredential(
|
||||
request: ActiveCredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult>;
|
||||
rotateCredential(
|
||||
request: ActiveCredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult>;
|
||||
revokeCredential(
|
||||
request: CredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult>;
|
||||
}
|
||||
|
||||
export class ClusterAdministrationConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Cluster administration configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterAdministrationConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAdministrationAuthenticationError extends Error {
|
||||
readonly code = 'CLUSTER_ADMINISTRATION_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Cluster administration requires a strong principal');
|
||||
this.name = 'ClusterAdministrationAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAdministrationSubjectUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_ADMINISTRATION_SUBJECT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster administration subject is unavailable');
|
||||
this.name = 'ClusterAdministrationSubjectUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(value: unknown, name: string): asserts value is object {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterAdministrationConfigurationError(`${name} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
name: string,
|
||||
allowed: ReadonlySet<string>,
|
||||
): void {
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
`${name} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_REQUEST_KEYS = new Set([
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'expectedCurrentVersion',
|
||||
'subject',
|
||||
'principal',
|
||||
]);
|
||||
const CREDENTIAL_REQUEST_KEYS = new Set([
|
||||
...IDENTITY_REQUEST_KEYS,
|
||||
'credentialId',
|
||||
]);
|
||||
const ACTIVE_CREDENTIAL_REQUEST_KEYS = new Set([
|
||||
...CREDENTIAL_REQUEST_KEYS,
|
||||
'notBeforeAtMs',
|
||||
'expiresAtMs',
|
||||
]);
|
||||
|
||||
function administrationPrincipal(
|
||||
principal: SecurityPrincipal,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let normalized: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
normalized = normalizeSecurityPrincipal(principal, nowMs);
|
||||
} catch {
|
||||
throw new ClusterAdministrationAuthenticationError();
|
||||
}
|
||||
const human =
|
||||
normalized.subject.type === 'user' &&
|
||||
STRONG_USER_ASSURANCES.has(normalized.assurance);
|
||||
const system =
|
||||
normalized.subject.type === 'system' && normalized.assurance === 'service';
|
||||
if (!human && !system) {
|
||||
throw new ClusterAdministrationAuthenticationError();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function audit(
|
||||
mutationId: string,
|
||||
requestId: string,
|
||||
operationId: string,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
reason: 'identity_admin' | 'credential_admin',
|
||||
nowMs: number,
|
||||
): SecurityAuditRecord {
|
||||
return {
|
||||
eventId: mutationId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId: null,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: [reason],
|
||||
fence: null,
|
||||
occurredAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function sameReplayAudit(
|
||||
stored: Readonly<SecurityAuditRecord>,
|
||||
expected: Readonly<SecurityAuditRecord>,
|
||||
): boolean {
|
||||
const { occurredAtMs: _storedOccurredAtMs, ...storedSemantic } = stored;
|
||||
const { occurredAtMs: _expectedOccurredAtMs, ...expectedSemantic } = expected;
|
||||
return JSON.stringify(storedSemantic) === JSON.stringify(expectedSemantic);
|
||||
}
|
||||
|
||||
function sameIdentityReplay(
|
||||
stored: ResolvedIdentitySubjectMutation,
|
||||
operation: IdentityAdministrationOperation,
|
||||
request: IdentityAdministrationRequest,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
return (
|
||||
stored.mutation.operation === operation &&
|
||||
sameSubject(stored.mutation.subject, request.subject) &&
|
||||
stored.mutation.subjectVersion === request.expectedCurrentVersion + 1 &&
|
||||
stored.mutation.expectedPreviousVersion ===
|
||||
request.expectedCurrentVersion &&
|
||||
stored.mutation.status ===
|
||||
(operation === 'disable' ? 'disabled' : 'active') &&
|
||||
sameSubject(stored.mutation.changedBy, principal.subject) &&
|
||||
sameReplayAudit(
|
||||
stored.audit,
|
||||
audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`identity.${operation}`,
|
||||
principal,
|
||||
'identity_admin',
|
||||
nowMs,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function sameCredentialReplay(
|
||||
stored: ResolvedApiCredentialMutation,
|
||||
operation: 'issue' | 'rotate' | 'revoke',
|
||||
request:
|
||||
| ActiveCredentialAdministrationRequest
|
||||
| CredentialAdministrationRequest,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
const active =
|
||||
operation === 'revoke'
|
||||
? null
|
||||
: (request as ActiveCredentialAdministrationRequest);
|
||||
return (
|
||||
stored.mutation.operation === operation &&
|
||||
stored.mutation.credentialId === request.credentialId &&
|
||||
stored.mutation.credentialVersion === request.expectedCurrentVersion + 1 &&
|
||||
stored.mutation.expectedPreviousVersion ===
|
||||
request.expectedCurrentVersion &&
|
||||
sameSubject(stored.mutation.changedBy, principal.subject) &&
|
||||
stored.credential.state ===
|
||||
(operation === 'revoke' ? 'revoked' : 'active') &&
|
||||
sameSubject(stored.credential.subject, request.subject) &&
|
||||
(active === null ||
|
||||
(stored.credential.notBeforeAtMs === active.notBeforeAtMs &&
|
||||
stored.credential.expiresAtMs === active.expiresAtMs)) &&
|
||||
sameReplayAudit(
|
||||
stored.audit,
|
||||
audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`credential.${operation}`,
|
||||
principal,
|
||||
'credential_admin',
|
||||
nowMs,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function createClusterAdministrationService(
|
||||
identities: IdentityAdministrationRepository,
|
||||
credentials: ApiCredentialAdministrationRepository,
|
||||
pepper: string,
|
||||
options: ClusterAdministrationOptions = {},
|
||||
): ClusterAdministrationService {
|
||||
if (
|
||||
!identities ||
|
||||
typeof identities.resolve !== 'function' ||
|
||||
typeof identities.resolveMutation !== 'function' ||
|
||||
typeof identities.append !== 'function'
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'identity repository is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!credentials ||
|
||||
typeof credentials.resolveMutation !== 'function' ||
|
||||
typeof credentials.append !== 'function'
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'credential repository is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertApiCredentialPepper(pepper);
|
||||
} catch {
|
||||
throw new ClusterAdministrationConfigurationError('pepper is invalid');
|
||||
}
|
||||
exactObject(options, 'options');
|
||||
const optionKeys = Object.keys(options);
|
||||
if (optionKeys.some((key) => key !== 'now' && key !== 'randomBytes')) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'options shape is invalid',
|
||||
);
|
||||
}
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw new ClusterAdministrationConfigurationError('now is invalid');
|
||||
}
|
||||
if (
|
||||
options.randomBytes !== undefined &&
|
||||
typeof options.randomBytes !== 'function'
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError('randomBytes is invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const randomBytes = options.randomBytes ?? nodeRandomBytes;
|
||||
|
||||
const mutateIdentity = async (
|
||||
operation: IdentityAdministrationOperation,
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult> => {
|
||||
exactObject(request, 'identity request');
|
||||
exactKeys(request, 'identity request', IDENTITY_REQUEST_KEYS);
|
||||
const nowMs = now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new ClusterAdministrationConfigurationError('clock is invalid');
|
||||
}
|
||||
const principal = administrationPrincipal(request.principal, nowMs);
|
||||
const existing = await identities.resolveMutation(request.mutationId);
|
||||
if (existing) {
|
||||
if (!sameIdentityReplay(existing, operation, request, principal, nowMs)) {
|
||||
throw new IdentityAdministrationMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
identity: existing.identity,
|
||||
mutation: existing.mutation,
|
||||
});
|
||||
}
|
||||
return identities.append({
|
||||
expectedCurrentVersion: request.expectedCurrentVersion,
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation,
|
||||
subject: request.subject,
|
||||
subjectVersion: request.expectedCurrentVersion + 1,
|
||||
expectedPreviousVersion: request.expectedCurrentVersion,
|
||||
status: operation === 'disable' ? 'disabled' : 'active',
|
||||
changedBy: principal.subject,
|
||||
createdAtMs: nowMs,
|
||||
},
|
||||
audit: audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`identity.${operation}`,
|
||||
principal,
|
||||
'identity_admin',
|
||||
nowMs,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const mutateCredential = async (
|
||||
operation: 'issue' | 'rotate' | 'revoke',
|
||||
request:
|
||||
| ActiveCredentialAdministrationRequest
|
||||
| CredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult> => {
|
||||
exactObject(request, 'credential request');
|
||||
exactKeys(
|
||||
request,
|
||||
'credential request',
|
||||
operation === 'revoke'
|
||||
? CREDENTIAL_REQUEST_KEYS
|
||||
: ACTIVE_CREDENTIAL_REQUEST_KEYS,
|
||||
);
|
||||
const nowMs = now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new ClusterAdministrationConfigurationError('clock is invalid');
|
||||
}
|
||||
const principal = administrationPrincipal(request.principal, nowMs);
|
||||
const existing = await credentials.resolveMutation(request.mutationId);
|
||||
if (existing) {
|
||||
if (
|
||||
!sameCredentialReplay(existing, operation, request, principal, nowMs)
|
||||
) {
|
||||
throw new ApiCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credential: existing.credential,
|
||||
mutation: existing.mutation,
|
||||
token: null,
|
||||
});
|
||||
}
|
||||
const identity = await identities.resolve(request.subject);
|
||||
if (!identity || (operation !== 'revoke' && identity.status !== 'active')) {
|
||||
throw new ClusterAdministrationSubjectUnavailableError();
|
||||
}
|
||||
|
||||
let secret: Buffer | undefined;
|
||||
let secretBase64Url: string | null = null;
|
||||
let secretDigest = REVOKED_API_CREDENTIAL_DIGEST;
|
||||
let notBeforeAtMs = nowMs;
|
||||
let expiresAtMs = nowMs + 1;
|
||||
if (operation !== 'revoke') {
|
||||
const activeRequest = request as ActiveCredentialAdministrationRequest;
|
||||
notBeforeAtMs = activeRequest.notBeforeAtMs;
|
||||
expiresAtMs = activeRequest.expiresAtMs;
|
||||
if (
|
||||
!Number.isSafeInteger(notBeforeAtMs) ||
|
||||
notBeforeAtMs < nowMs ||
|
||||
!Number.isSafeInteger(expiresAtMs) ||
|
||||
expiresAtMs <= notBeforeAtMs ||
|
||||
expiresAtMs - nowMs > MAX_CREDENTIAL_LIFETIME_MS
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'credential lifetime is invalid',
|
||||
);
|
||||
}
|
||||
secret = randomBytes(32);
|
||||
if (!Buffer.isBuffer(secret) || secret.byteLength !== 32) {
|
||||
if (Buffer.isBuffer(secret)) secret.fill(0);
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'randomBytes returned invalid secret material',
|
||||
);
|
||||
}
|
||||
try {
|
||||
secretBase64Url = secret.toString('base64url');
|
||||
secretDigest = apiCredentialSecretDigest(
|
||||
pepper,
|
||||
request.credentialId,
|
||||
secretBase64Url,
|
||||
);
|
||||
} finally {
|
||||
secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
const credential: ApiCredentialRecord = {
|
||||
credentialId: request.credentialId,
|
||||
version: request.expectedCurrentVersion + 1,
|
||||
pepperKeyId: LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
state: operation === 'revoke' ? 'revoked' : 'active',
|
||||
subject: request.subject,
|
||||
subjectStatus: identity.status,
|
||||
secretDigest,
|
||||
createdAtMs: nowMs,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
const result = await credentials.append({
|
||||
expectedCurrentVersion: request.expectedCurrentVersion,
|
||||
credential,
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation,
|
||||
credentialId: request.credentialId,
|
||||
credentialVersion: request.expectedCurrentVersion + 1,
|
||||
expectedPreviousVersion: request.expectedCurrentVersion,
|
||||
changedBy: principal.subject,
|
||||
createdAtMs: nowMs,
|
||||
},
|
||||
audit: audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`credential.${operation}`,
|
||||
principal,
|
||||
'credential_admin',
|
||||
nowMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
...result,
|
||||
token:
|
||||
result.status === 'inserted' && secretBase64Url
|
||||
? formatApiCredentialToken(request.credentialId, secretBase64Url)
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
registerIdentity: (request: IdentityAdministrationRequest) =>
|
||||
mutateIdentity('register', request),
|
||||
enableIdentity: (request: IdentityAdministrationRequest) =>
|
||||
mutateIdentity('enable', request),
|
||||
disableIdentity: (request: IdentityAdministrationRequest) =>
|
||||
mutateIdentity('disable', request),
|
||||
issueCredential: (request: ActiveCredentialAdministrationRequest) =>
|
||||
mutateCredential('issue', request),
|
||||
rotateCredential: (request: ActiveCredentialAdministrationRequest) =>
|
||||
mutateCredential('rotate', request),
|
||||
revokeCredential: (request: CredentialAdministrationRequest) =>
|
||||
mutateCredential('revoke', request),
|
||||
});
|
||||
}
|
||||
+800
@@ -0,0 +1,800 @@
|
||||
/** Worker credential management application service boundary. */
|
||||
import {
|
||||
PostgresApprovalRequestRepository,
|
||||
PostgresProjectPolicyRepository,
|
||||
PostgresWorkerCredentialManagementPlanRepository,
|
||||
} from '@qinglong/cluster-postgres/worker-credential-manager';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
type CreateApprovalRequestResult,
|
||||
type DecideApprovalRequestResult,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
InvalidWorkerCredentialManagementPlanError,
|
||||
MAX_WORKER_CREDENTIAL_MANAGEMENT_PLAN_LIFETIME_MS,
|
||||
WorkerCredentialManagementPlanConflictError,
|
||||
WorkerCredentialManagementPlanUnavailableError,
|
||||
createWorkerCredentialManagementPlan,
|
||||
normalizeWorkerCredentialManagementPlan,
|
||||
type CreateWorkerCredentialManagementPlanResult,
|
||||
type WorkerCredentialManagementAction,
|
||||
type WorkerCredentialManagementPlan,
|
||||
} from '@qinglong/runtime-core/worker-credential-management-plan';
|
||||
|
||||
const DEFAULT_APPROVAL_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export class WorkerCredentialManagementRequestError extends TypeError {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_REQUEST_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Worker credential management request is invalid: ${message}`);
|
||||
this.name = 'WorkerCredentialManagementRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialManagementAuthorizationError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Worker credential management is not authorized');
|
||||
this.name = 'WorkerCredentialManagementAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialManagementConflictError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Worker credential management conflicts with durable state: ${message}`,
|
||||
);
|
||||
this.name = 'WorkerCredentialManagementConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialManagementUnavailableError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Worker credential management is unavailable', options);
|
||||
this.name = 'WorkerCredentialManagementUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialManagementQuotaExceededError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_QUOTA_EXCEEDED';
|
||||
|
||||
constructor(readonly retryAfterMs: number) {
|
||||
super('Worker credential management quota is exceeded');
|
||||
this.name = 'WorkerCredentialManagementQuotaExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerCredentialManagementQuotaOperation =
|
||||
| 'worker-credential.plan'
|
||||
| 'worker-credential.propose'
|
||||
| 'worker-credential.decide'
|
||||
| 'worker-credential.inspect';
|
||||
|
||||
export interface WorkerCredentialManagementQuotaPort {
|
||||
consume(
|
||||
command: Readonly<{
|
||||
projectId: string;
|
||||
subject: Readonly<SecuritySubject>;
|
||||
operation: WorkerCredentialManagementQuotaOperation;
|
||||
idempotencyKey: string;
|
||||
}>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
admitted: boolean;
|
||||
retryAfterMs: number | null;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface PlanClusterWorkerCredentialRequest {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly action: WorkerCredentialManagementAction;
|
||||
readonly deliveryId: string;
|
||||
readonly workerId: string;
|
||||
readonly credentialId: string;
|
||||
readonly previousCredentialId: string | null;
|
||||
readonly credentialNotBeforeAtMs: number;
|
||||
readonly credentialExpiresAtMs: number;
|
||||
readonly deploymentTargetDigest: string;
|
||||
readonly deploymentGeneration: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ProposeClusterWorkerCredentialRequest {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ProposeClusterWorkerCredentialResult {
|
||||
readonly plan: Readonly<WorkerCredentialManagementPlan>;
|
||||
readonly approvalStatus: CreateApprovalRequestResult['status'];
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord>;
|
||||
}
|
||||
|
||||
export interface DecideClusterWorkerCredentialRequest {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly decisionId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterWorkerCredentialRequest {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterWorkerCredentialResult {
|
||||
readonly plan: Readonly<WorkerCredentialManagementPlan> | null;
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord> | null;
|
||||
readonly stale: boolean;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerCredentialManagementService {
|
||||
plan(
|
||||
request: PlanClusterWorkerCredentialRequest,
|
||||
): Promise<Readonly<CreateWorkerCredentialManagementPlanResult>>;
|
||||
propose(
|
||||
request: ProposeClusterWorkerCredentialRequest,
|
||||
): Promise<Readonly<ProposeClusterWorkerCredentialResult>>;
|
||||
decide(
|
||||
request: DecideClusterWorkerCredentialRequest,
|
||||
): Promise<Readonly<DecideApprovalRequestResult>>;
|
||||
inspectAuthorized(
|
||||
request: InspectClusterWorkerCredentialRequest,
|
||||
): Promise<Readonly<InspectClusterWorkerCredentialResult>>;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerCredentialManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly quota?: WorkerCredentialManagementQuotaPort;
|
||||
readonly now?: () => number;
|
||||
readonly planLifetimeMs?: number;
|
||||
readonly approvalLifetimeMs?: number;
|
||||
}
|
||||
|
||||
function exact(value: unknown, keys: readonly string[], label: string): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerCredentialManagementRequestError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new WorkerCredentialManagementRequestError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new WorkerCredentialManagementRequestError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new WorkerCredentialManagementRequestError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerCredentialManagementUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function binding(plan: Readonly<WorkerCredentialManagementPlan>) {
|
||||
return Object.freeze({
|
||||
permission: 'worker.manage' as const,
|
||||
actionType: `worker_credential.delivery.${plan.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: plan.planDigest,
|
||||
previewDigest: plan.previewDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
requestId: string,
|
||||
operationId: 'approval.request' | 'approval.decide',
|
||||
projectId: string,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
outcome: 'allowed' | 'approval_required',
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome,
|
||||
reasons: Object.freeze(['worker_credential_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterWorkerCredentialManagementService(
|
||||
options: ClusterWorkerCredentialManagementOptions,
|
||||
): Readonly<ClusterWorkerCredentialManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'approvalLifetimeMs' &&
|
||||
key !== 'now' &&
|
||||
key !== 'planLifetimeMs' &&
|
||||
key !== 'pool' &&
|
||||
key !== 'quota',
|
||||
)
|
||||
) {
|
||||
throw new WorkerCredentialManagementRequestError(
|
||||
'options shape is invalid',
|
||||
);
|
||||
}
|
||||
if (!options.pool || typeof options.pool.query !== 'function') {
|
||||
throw new WorkerCredentialManagementRequestError('pool is invalid');
|
||||
}
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw new WorkerCredentialManagementRequestError('now is invalid');
|
||||
}
|
||||
if (
|
||||
options.quota !== undefined &&
|
||||
(!options.quota || typeof options.quota.consume !== 'function')
|
||||
) {
|
||||
throw new WorkerCredentialManagementRequestError('quota is invalid');
|
||||
}
|
||||
const planLifetimeMs =
|
||||
options.planLifetimeMs ?? MAX_WORKER_CREDENTIAL_MANAGEMENT_PLAN_LIFETIME_MS;
|
||||
const approvalLifetimeMs =
|
||||
options.approvalLifetimeMs ?? DEFAULT_APPROVAL_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(planLifetimeMs) ||
|
||||
planLifetimeMs < 1_000 ||
|
||||
planLifetimeMs > MAX_WORKER_CREDENTIAL_MANAGEMENT_PLAN_LIFETIME_MS ||
|
||||
!Number.isSafeInteger(approvalLifetimeMs) ||
|
||||
approvalLifetimeMs < 1_000 ||
|
||||
approvalLifetimeMs > DEFAULT_APPROVAL_LIFETIME_MS
|
||||
) {
|
||||
throw new WorkerCredentialManagementRequestError('lifetime is invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const plans = new PostgresWorkerCredentialManagementPlanRepository(
|
||||
options.pool,
|
||||
);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
|
||||
const consumeQuota = async (
|
||||
projectId: string,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
operation: WorkerCredentialManagementQuotaOperation,
|
||||
idempotencyKey: string,
|
||||
): Promise<void> => {
|
||||
if (!options.quota) return;
|
||||
try {
|
||||
const result = await options.quota.consume({
|
||||
projectId,
|
||||
subject: principal.subject,
|
||||
operation,
|
||||
idempotencyKey,
|
||||
});
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== 'object' ||
|
||||
typeof result.admitted !== 'boolean' ||
|
||||
(result.retryAfterMs !== null &&
|
||||
(!Number.isSafeInteger(result.retryAfterMs) ||
|
||||
result.retryAfterMs < 1))
|
||||
) {
|
||||
throw new Error('quota result is invalid');
|
||||
}
|
||||
if (!result.admitted) {
|
||||
if (result.retryAfterMs === null) {
|
||||
throw new Error('quota rejection has no retry bound');
|
||||
}
|
||||
throw new WorkerCredentialManagementQuotaExceededError(
|
||||
result.retryAfterMs,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialManagementQuotaExceededError) {
|
||||
throw error;
|
||||
}
|
||||
throw new WorkerCredentialManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
permission: 'worker.manage' | 'approval.decide',
|
||||
observedAtMs: number,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
}>
|
||||
> => {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new WorkerCredentialManagementAuthorizationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new WorkerCredentialManagementAuthorizationError();
|
||||
}
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(principal, projectId, permission);
|
||||
} catch (error) {
|
||||
throw new WorkerCredentialManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (decision.effect !== 'allow' || decision.fence === null) {
|
||||
throw new WorkerCredentialManagementAuthorizationError();
|
||||
}
|
||||
return Object.freeze({ principal, fence: decision.fence });
|
||||
};
|
||||
|
||||
const loadPlan = async (
|
||||
requestedActionRef: string,
|
||||
): Promise<Readonly<WorkerCredentialManagementPlan>> => {
|
||||
let value;
|
||||
try {
|
||||
value = await plans.findByActionRef(actionRef(requestedActionRef));
|
||||
} catch (error) {
|
||||
throw new WorkerCredentialManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!value) {
|
||||
throw new WorkerCredentialManagementConflictError('plan does not exist');
|
||||
}
|
||||
return normalizeWorkerCredentialManagementPlan(value);
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async plan(request: PlanClusterWorkerCredentialRequest) {
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'action',
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'credentialExpiresAtMs',
|
||||
'credentialId',
|
||||
'credentialNotBeforeAtMs',
|
||||
'deliveryId',
|
||||
'deploymentGeneration',
|
||||
'deploymentTargetDigest',
|
||||
'previousCredentialId',
|
||||
'principal',
|
||||
'workerId',
|
||||
],
|
||||
'plan request',
|
||||
);
|
||||
const observedAtMs = currentTime(now);
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
identifier(request.authorityProjectId, 'authorityProjectId'),
|
||||
'worker.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
await consumeQuota(
|
||||
request.authorityProjectId,
|
||||
authorization.principal,
|
||||
'worker-credential.plan',
|
||||
actionRef(request.actionRef),
|
||||
);
|
||||
try {
|
||||
const plan = createWorkerCredentialManagementPlan({
|
||||
actionRef: actionRef(request.actionRef),
|
||||
authorityProjectId: request.authorityProjectId,
|
||||
action: request.action,
|
||||
target: {
|
||||
deliveryId: request.deliveryId,
|
||||
workerId: request.workerId,
|
||||
credentialId: request.credentialId,
|
||||
previousCredentialId: request.previousCredentialId,
|
||||
credentialNotBeforeAtMs: request.credentialNotBeforeAtMs,
|
||||
credentialExpiresAtMs: request.credentialExpiresAtMs,
|
||||
deploymentTargetDigest: request.deploymentTargetDigest,
|
||||
deploymentGeneration: request.deploymentGeneration,
|
||||
},
|
||||
requestedBy: authorization.principal.subject,
|
||||
plannedAtMs: observedAtMs,
|
||||
expiresAtMs: observedAtMs + planLifetimeMs,
|
||||
});
|
||||
return await plans.create(plan);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidWorkerCredentialManagementPlanError) {
|
||||
throw new WorkerCredentialManagementRequestError('plan is invalid');
|
||||
}
|
||||
if (error instanceof WorkerCredentialManagementPlanConflictError) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'plan identity is already bound',
|
||||
);
|
||||
}
|
||||
throw new WorkerCredentialManagementUnavailableError({
|
||||
cause:
|
||||
error instanceof WorkerCredentialManagementPlanUnavailableError
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async propose(request: ProposeClusterWorkerCredentialRequest) {
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'approvalAuditEventId',
|
||||
'approvalRequestId',
|
||||
'principal',
|
||||
],
|
||||
'proposal request',
|
||||
);
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const approvalAuditEventId = identifier(
|
||||
request.approvalAuditEventId,
|
||||
'approvalAuditEventId',
|
||||
);
|
||||
const observedAtMs = currentTime(now);
|
||||
const authorityProjectId = identifier(
|
||||
request.authorityProjectId,
|
||||
'authorityProjectId',
|
||||
);
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
authorityProjectId,
|
||||
'worker.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
await consumeQuota(
|
||||
authorityProjectId,
|
||||
authorization.principal,
|
||||
'worker-credential.propose',
|
||||
approvalRequestId,
|
||||
);
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
if (plan.authorityProjectId !== authorityProjectId) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'plan belongs to another authority Project',
|
||||
);
|
||||
}
|
||||
if (observedAtMs > plan.expiresAtMs) {
|
||||
throw new WorkerCredentialManagementConflictError('plan expired');
|
||||
}
|
||||
if (!sameSubject(plan.requestedBy, authorization.principal.subject)) {
|
||||
throw new WorkerCredentialManagementAuthorizationError();
|
||||
}
|
||||
const action = binding(plan);
|
||||
let existing;
|
||||
try {
|
||||
existing = await approvals.findById(approvalRequestId);
|
||||
} catch (error) {
|
||||
throw new WorkerCredentialManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (existing) {
|
||||
const normalized = normalizeApprovalRequestRecord(existing);
|
||||
if (
|
||||
normalized.projectId !== plan.authorityProjectId ||
|
||||
normalized.decisionMode !== 'separation_of_duty' ||
|
||||
!sameSubject(normalized.requestedBy, plan.requestedBy) ||
|
||||
!same(normalized.action, action)
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approval is bound to another plan',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: 'existing' as const,
|
||||
approvalRequest: normalized,
|
||||
});
|
||||
}
|
||||
const expiresAtMs = Math.min(
|
||||
observedAtMs + approvalLifetimeMs,
|
||||
plan.expiresAtMs,
|
||||
);
|
||||
if (expiresAtMs <= observedAtMs) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'plan has no approval lifetime',
|
||||
);
|
||||
}
|
||||
const created = await approvals.create({
|
||||
request: createApprovalRequest({
|
||||
id: approvalRequestId,
|
||||
projectId: plan.authorityProjectId,
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'separation_of_duty',
|
||||
requestedBy: authorization.principal.subject,
|
||||
requestedAtMs: observedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: authorization.fence,
|
||||
}),
|
||||
audit: audit(
|
||||
approvalAuditEventId,
|
||||
approvalRequestId,
|
||||
'approval.request',
|
||||
plan.authorityProjectId,
|
||||
authorization.principal,
|
||||
'approval_required',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: created.status,
|
||||
approvalRequest: created.request,
|
||||
});
|
||||
},
|
||||
|
||||
async decide(request: DecideClusterWorkerCredentialRequest) {
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'approvalRequestId',
|
||||
'auditEventId',
|
||||
'decision',
|
||||
'decisionId',
|
||||
'expectedVersion',
|
||||
'principal',
|
||||
'reasonCode',
|
||||
],
|
||||
'decision request',
|
||||
);
|
||||
if (
|
||||
(request.decision !== 'approved' && request.decision !== 'rejected') ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode) ||
|
||||
!Number.isSafeInteger(request.expectedVersion) ||
|
||||
request.expectedVersion < 1
|
||||
) {
|
||||
throw new WorkerCredentialManagementRequestError(
|
||||
'decision tuple is invalid',
|
||||
);
|
||||
}
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const observedAtMs = currentTime(now);
|
||||
const authorityProjectId = identifier(
|
||||
request.authorityProjectId,
|
||||
'authorityProjectId',
|
||||
);
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
authorityProjectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
const decisionId = identifier(request.decisionId, 'decisionId');
|
||||
await consumeQuota(
|
||||
authorityProjectId,
|
||||
authorization.principal,
|
||||
'worker-credential.decide',
|
||||
decisionId,
|
||||
);
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
if (plan.authorityProjectId !== authorityProjectId) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'plan belongs to another authority Project',
|
||||
);
|
||||
}
|
||||
const current = await approvals.findById(approvalRequestId);
|
||||
if (!current) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approval does not exist',
|
||||
);
|
||||
}
|
||||
const approval = normalizeApprovalRequestRecord(current);
|
||||
if (
|
||||
approval.projectId !== plan.authorityProjectId ||
|
||||
!same(approval.action, binding(plan))
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approval does not match plan',
|
||||
);
|
||||
}
|
||||
if (
|
||||
approval.decisionId === decisionId &&
|
||||
approval.decision === request.decision &&
|
||||
approval.decisionReasonCode === request.reasonCode &&
|
||||
approval.decidedBy &&
|
||||
sameSubject(approval.decidedBy, authorization.principal.subject)
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
request: approval,
|
||||
});
|
||||
}
|
||||
return approvals.decide({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: request.expectedVersion,
|
||||
decisionId,
|
||||
decision: request.decision,
|
||||
reasonCode: request.reasonCode,
|
||||
principal: authorization.principal,
|
||||
decidedAtMs: observedAtMs,
|
||||
authorizationFence: authorization.fence,
|
||||
audit: audit(
|
||||
identifier(request.auditEventId, 'auditEventId'),
|
||||
approvalRequestId,
|
||||
'approval.decide',
|
||||
approval.projectId,
|
||||
authorization.principal,
|
||||
'allowed',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
async inspectAuthorized(request: InspectClusterWorkerCredentialRequest) {
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'approvalRequestId',
|
||||
'inspectionId',
|
||||
'principal',
|
||||
],
|
||||
'inspection request',
|
||||
);
|
||||
const inspectionId = identifier(request.inspectionId, 'inspectionId');
|
||||
const projectId = identifier(
|
||||
request.authorityProjectId,
|
||||
'authorityProjectId',
|
||||
);
|
||||
const observedAtMs = currentTime(now);
|
||||
let authorization;
|
||||
try {
|
||||
authorization = await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'worker.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkerCredentialManagementAuthorizationError)) {
|
||||
throw error;
|
||||
}
|
||||
authorization = await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
}
|
||||
await consumeQuota(
|
||||
projectId,
|
||||
authorization.principal,
|
||||
'worker-credential.inspect',
|
||||
inspectionId,
|
||||
);
|
||||
const [planValue, approvalValue] = await Promise.all([
|
||||
plans.findByActionRef(actionRef(request.actionRef)),
|
||||
approvals.findById(
|
||||
identifier(request.approvalRequestId, 'approvalRequestId'),
|
||||
),
|
||||
]);
|
||||
if (!planValue && !approvalValue) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'management state does not exist',
|
||||
);
|
||||
}
|
||||
const plan = planValue
|
||||
? normalizeWorkerCredentialManagementPlan(planValue)
|
||||
: null;
|
||||
const approval = approvalValue
|
||||
? normalizeApprovalRequestRecord(approvalValue)
|
||||
: null;
|
||||
if (
|
||||
(plan && plan.authorityProjectId !== projectId) ||
|
||||
(approval && approval.projectId !== projectId)
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'management state belongs to another authority Project',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalRequest: approval,
|
||||
stale:
|
||||
plan === null ||
|
||||
approval === null ||
|
||||
approval.projectId !== plan.authorityProjectId ||
|
||||
!same(approval.action, binding(plan)) ||
|
||||
observedAtMs > plan.expiresAtMs,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Worker credential management service CLI boundary. */
|
||||
import {
|
||||
startClusterWorkerCredentialManagementProcess,
|
||||
type ClusterWorkerCredentialManagementProcessRuntime,
|
||||
} from './workerCredentialManagementProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-worker-credential-manage';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management',
|
||||
event: 'management_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function emit(value: Readonly<Record<string, unknown>>): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
|
||||
let runtime: Readonly<ClusterWorkerCredentialManagementProcessRuntime>;
|
||||
try {
|
||||
runtime = await startClusterWorkerCredentialManagementProcess({
|
||||
environment: process.env,
|
||||
onError() {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management',
|
||||
event: 'management_unavailable',
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (runtime.status === 'disabled') {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management',
|
||||
event: 'management_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management',
|
||||
event: 'management_started',
|
||||
address: runtime.address,
|
||||
identityGeneration: runtime.identity.generation,
|
||||
databaseContractVersion: runtime.database.contractVersion,
|
||||
databaseMigrationCount: runtime.database.migrationIds.length,
|
||||
});
|
||||
|
||||
let stopping: Promise<void> | undefined;
|
||||
const stop = (): Promise<void> => {
|
||||
stopping ??= runtime.close().then(() => {
|
||||
emit({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management',
|
||||
event: 'management_stopped',
|
||||
});
|
||||
});
|
||||
return stopping;
|
||||
};
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
void stop().then(
|
||||
() => {
|
||||
process.exitCode = 0;
|
||||
},
|
||||
(error) => {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/** TLS 1.3 Worker credential management HTTP adapter boundary. */
|
||||
import {
|
||||
CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
startClusterPluginPackageManagementHttp,
|
||||
type ClusterPluginPackageManagementHttpApplication,
|
||||
type ClusterPluginPackageManagementHttpLimits,
|
||||
} from '../../management-support/pluginPackageManagementHttp';
|
||||
import type { ClusterPluginPackageIdentityKeysetFile } from '../../management-support/pluginPackageIdentityKeyset';
|
||||
import type { ClusterWorkerCredentialManagementTransport } from './workerCredentialManagementTransport';
|
||||
|
||||
export type ClusterWorkerCredentialManagementHttpLimits =
|
||||
ClusterPluginPackageManagementHttpLimits;
|
||||
|
||||
export type ClusterWorkerCredentialManagementHttpApplication =
|
||||
ClusterPluginPackageManagementHttpApplication;
|
||||
|
||||
export interface StartClusterWorkerCredentialManagementHttpOptions {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly tls: Readonly<{
|
||||
readonly privateKey: Buffer;
|
||||
readonly certificate: Buffer;
|
||||
readonly clientCertificateAuthority: Buffer;
|
||||
readonly clientCertificateRevocationList: Buffer;
|
||||
}>;
|
||||
readonly transport: ClusterWorkerCredentialManagementTransport;
|
||||
readonly identities: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly limits?: ClusterWorkerCredentialManagementHttpLimits;
|
||||
readonly now?: () => number;
|
||||
readonly createRequestId?: () => string;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Worker credential management endpoint on the shared Cluster Admin
|
||||
* TLS 1.3/OIDC boundary. The public manager process never receives credential
|
||||
* delivery or Kubernetes execution capabilities.
|
||||
*/
|
||||
export async function startClusterWorkerCredentialManagementHttp(
|
||||
options: StartClusterWorkerCredentialManagementHttpOptions,
|
||||
): Promise<Readonly<ClusterWorkerCredentialManagementHttpApplication>> {
|
||||
return startClusterPluginPackageManagementHttp({
|
||||
...options,
|
||||
managementPath: CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
});
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/** Shared Cluster management mutual-TLS trust validation boundary. */
|
||||
import { createHash, X509Certificate } from 'node:crypto';
|
||||
import { createSecureContext } from 'node:tls';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
import type { ClusterManagementProcessConfigurationFailure } from '../../management-support/managementProcessSupport';
|
||||
|
||||
const MAX_PEM_BLOCKS = 16;
|
||||
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
function exactPemBlocks(
|
||||
bytes: Buffer,
|
||||
label: 'CERTIFICATE' | 'X509 CRL',
|
||||
description: string,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): readonly Buffer[] {
|
||||
let value: string;
|
||||
try {
|
||||
value = STRICT_UTF8.decode(bytes);
|
||||
} catch {
|
||||
throw failure(`${description} bundle must be strict UTF-8`);
|
||||
}
|
||||
const pattern = new RegExp(
|
||||
`-----BEGIN ${label}-----[\\s\\S]*?-----END ${label}-----`,
|
||||
'g',
|
||||
);
|
||||
const matches = value.match(pattern);
|
||||
if (!matches || matches.length < 1 || matches.length > MAX_PEM_BLOCKS) {
|
||||
throw failure(
|
||||
`${description} bundle must contain 1 to ${MAX_PEM_BLOCKS} PEM blocks`,
|
||||
);
|
||||
}
|
||||
if (value.replace(pattern, '').trim() !== '') {
|
||||
throw failure(`${description} bundle contains unsupported data`);
|
||||
}
|
||||
return Object.freeze(
|
||||
matches.map((match) => Buffer.from(`${match}\n`, 'utf8')),
|
||||
);
|
||||
}
|
||||
|
||||
function validateCertificateAuthorities(
|
||||
authorities: readonly Buffer[],
|
||||
now: number,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): void {
|
||||
const fingerprints = new Set<string>();
|
||||
for (const authorityBytes of authorities) {
|
||||
let authority: X509Certificate;
|
||||
try {
|
||||
authority = new X509Certificate(authorityBytes);
|
||||
} catch {
|
||||
throw failure('client certificate authority is not an X.509 certificate');
|
||||
}
|
||||
const validFrom = Date.parse(authority.validFrom);
|
||||
const validTo = Date.parse(authority.validTo);
|
||||
if (
|
||||
!Number.isFinite(validFrom) ||
|
||||
!Number.isFinite(validTo) ||
|
||||
now < validFrom ||
|
||||
now >= validTo
|
||||
) {
|
||||
throw failure('client certificate authority is not currently valid');
|
||||
}
|
||||
if (!authority.ca) {
|
||||
throw failure('client certificate authority is not a CA');
|
||||
}
|
||||
if (fingerprints.has(authority.fingerprint256)) {
|
||||
throw failure('client certificate authority bundle contains a duplicate');
|
||||
}
|
||||
fingerprints.add(authority.fingerprint256);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectDuplicateRevocationLists(
|
||||
revocationLists: readonly Buffer[],
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): void {
|
||||
const digests = new Set<string>();
|
||||
for (const revocationList of revocationLists) {
|
||||
const digest = createHash('sha256').update(revocationList).digest('hex');
|
||||
if (digests.has(digest)) {
|
||||
throw failure(
|
||||
'client certificate revocation list bundle contains a duplicate',
|
||||
);
|
||||
}
|
||||
digests.add(digest);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateWorkerCredentialManagementClientTrust(
|
||||
certificateAuthorityBundle: Buffer,
|
||||
certificateRevocationListBundle: Buffer,
|
||||
now: number,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(now) || now < 0) {
|
||||
throw failure('TLS observation time is invalid');
|
||||
}
|
||||
const authorities = exactPemBlocks(
|
||||
certificateAuthorityBundle,
|
||||
'CERTIFICATE',
|
||||
'client certificate authority',
|
||||
failure,
|
||||
);
|
||||
let revocationLists: readonly Buffer[] = Object.freeze([]);
|
||||
try {
|
||||
revocationLists = exactPemBlocks(
|
||||
certificateRevocationListBundle,
|
||||
'X509 CRL',
|
||||
'client certificate revocation list',
|
||||
failure,
|
||||
);
|
||||
validateCertificateAuthorities(authorities, now, failure);
|
||||
rejectDuplicateRevocationLists(revocationLists, failure);
|
||||
try {
|
||||
createSecureContext({
|
||||
ca: [...authorities],
|
||||
crl: [...revocationLists],
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
});
|
||||
} catch {
|
||||
throw failure('client trust or revocation bundle is invalid');
|
||||
}
|
||||
} finally {
|
||||
for (const authority of authorities) authority.fill(0);
|
||||
for (const revocationList of revocationLists) revocationList.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export const validateClusterManagementClientTrust =
|
||||
validateWorkerCredentialManagementClientTrust;
|
||||
+673
@@ -0,0 +1,673 @@
|
||||
/** Worker credential management PostgreSQL process composition boundary. */
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
assertPostgresWorkerCredentialManagerSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresWorkerCredentialManagementIdentityKeysetLedgerRepository,
|
||||
PostgresWorkerCredentialManagementQuotaRepository,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/worker-credential-manager';
|
||||
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
readManagementTlsFile,
|
||||
} from '../../management-support/managementProcessSupport';
|
||||
import {
|
||||
createClusterWorkerCredentialIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
type ClusterPluginPackageIdentityKeysetSnapshot,
|
||||
} from '../../management-support/pluginPackageIdentityKeyset';
|
||||
import { createClusterWorkerCredentialManagementService } from './workerCredentialManagement';
|
||||
import {
|
||||
startClusterWorkerCredentialManagementHttp,
|
||||
type ClusterWorkerCredentialManagementHttpApplication,
|
||||
type StartClusterWorkerCredentialManagementHttpOptions,
|
||||
} from './workerCredentialManagementHttp';
|
||||
import { createClusterWorkerCredentialManagementTransport } from './workerCredentialManagementTransport';
|
||||
import { validateWorkerCredentialManagementClientTrust } from './workerCredentialManagementMutualTls';
|
||||
|
||||
const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.:-]{0,254}$/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
|
||||
export type ClusterWorkerCredentialManagementProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterWorkerCredentialManagementProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
host: string;
|
||||
port: number;
|
||||
certificateFile: string;
|
||||
privateKeyFile: string;
|
||||
clientCertificateAuthorityFile: string;
|
||||
clientCertificateRevocationListFile: string;
|
||||
identityKeysetFile: string;
|
||||
planLifetimeMs: number;
|
||||
approvalLifetimeMs: number;
|
||||
quota: Readonly<{
|
||||
windowMs: number;
|
||||
planLimit: number;
|
||||
proposeLimit: number;
|
||||
decideLimit: number;
|
||||
inspectLimit: number;
|
||||
}>;
|
||||
http: Readonly<{
|
||||
maxBodyBytes: number;
|
||||
maxConnections: number;
|
||||
maxConcurrentRequests: number;
|
||||
requestTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
rateWindowMs: number;
|
||||
peerRequestLimit: number;
|
||||
globalRequestLimit: number;
|
||||
maxRateLimitPeers: number;
|
||||
}>;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterWorkerCredentialManagementProcessRuntime =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
close(): Promise<void>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
address: Readonly<{ host: string; port: number }>;
|
||||
database: PostgresSchemaReadinessReport;
|
||||
identity: ClusterPluginPackageIdentityKeysetSnapshot;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export interface StartClusterWorkerCredentialManagementProcessOptions {
|
||||
readonly environment: ClusterWorkerCredentialManagementProcessEnvironment;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly identities?: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly assertReady?: (
|
||||
pool: PostgresDatabaseResource['pool'],
|
||||
) => Promise<PostgresSchemaReadinessReport>;
|
||||
readonly startHttp?: (
|
||||
options: StartClusterWorkerCredentialManagementHttpOptions,
|
||||
) => Promise<Readonly<ClusterWorkerCredentialManagementHttpApplication>>;
|
||||
readonly now?: () => number;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class ClusterWorkerCredentialManagementProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_WORKER_CREDENTIAL_MANAGEMENT_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Worker credential management process configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterWorkerCredentialManagementProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function configFailure(
|
||||
message: string,
|
||||
): ClusterWorkerCredentialManagementProcessConfigError {
|
||||
return new ClusterWorkerCredentialManagementProcessConfigError(message);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterWorkerCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
configFailure,
|
||||
required,
|
||||
);
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterWorkerCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterWorkerCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function absoluteFile(
|
||||
environment: ClusterWorkerCredentialManagementProcessEnvironment,
|
||||
name: string,
|
||||
): string {
|
||||
return absoluteManagementEnvironmentFile(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterWorkerCredentialManagementProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_URL',
|
||||
host: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_HOST',
|
||||
port: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_PORT',
|
||||
database: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_DATABASE',
|
||||
user: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_USER',
|
||||
password: 'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw configFailure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Worker credential manager connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_MODE ??
|
||||
'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_ALLOW_INSECURE',
|
||||
)
|
||||
) {
|
||||
throw configFailure(
|
||||
'disabling Worker credential manager PostgreSQL TLS requires QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_ALLOW_INSECURE=true',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_CA_FILE cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_TLS_CA_FILE is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-worker-credential-manager';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_APPLICATION_NAME is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? { mode: 'disable' as const }
|
||||
: {
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
},
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_MAX_CONNECTIONS',
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_MANAGER_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterWorkerCredentialManagementProcessConfig(
|
||||
environment: ClusterWorkerCredentialManagementProcessEnvironment,
|
||||
): Readonly<ClusterWorkerCredentialManagementProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw configFailure('environment is invalid');
|
||||
}
|
||||
if (!booleanValue(environment, 'QL3_WORKER_CREDENTIAL_MANAGEMENT_ENABLED')) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw configFailure(
|
||||
'QL3_PROFILE must be cluster-admin when Worker credential management is enabled',
|
||||
);
|
||||
}
|
||||
const host =
|
||||
boundedValue(environment, 'QL3_WORKER_CREDENTIAL_MANAGEMENT_HOST', 255) ??
|
||||
'0.0.0.0';
|
||||
if (!SAFE_HOST.test(host)) {
|
||||
throw configFailure('QL3_WORKER_CREDENTIAL_MANAGEMENT_HOST is invalid');
|
||||
}
|
||||
const http = Object.freeze({
|
||||
maxBodyBytes: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_MAX_BODY_BYTES',
|
||||
64 * 1024,
|
||||
1_024,
|
||||
256 * 1024,
|
||||
),
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_MAX_CONNECTIONS',
|
||||
64,
|
||||
1,
|
||||
512,
|
||||
),
|
||||
maxConcurrentRequests: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_MAX_CONCURRENT_REQUESTS',
|
||||
32,
|
||||
1,
|
||||
256,
|
||||
),
|
||||
requestTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_REQUEST_TIMEOUT_MS',
|
||||
10_000,
|
||||
1_000,
|
||||
60_000,
|
||||
),
|
||||
drainTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_DRAIN_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
rateWindowMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_RATE_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
peerRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_PEER_REQUEST_LIMIT',
|
||||
60,
|
||||
1,
|
||||
10_000,
|
||||
),
|
||||
globalRequestLimit: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_GLOBAL_REQUEST_LIMIT',
|
||||
600,
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
maxRateLimitPeers: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_MAX_RATE_LIMIT_PEERS',
|
||||
1_024,
|
||||
1,
|
||||
16_384,
|
||||
),
|
||||
});
|
||||
if (http.globalRequestLimit < http.peerRequestLimit) {
|
||||
throw configFailure(
|
||||
'global request limit cannot be below the peer request limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
host,
|
||||
port: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_PORT',
|
||||
8_444,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
certificateFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_TLS_CERT_FILE',
|
||||
),
|
||||
privateKeyFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_TLS_KEY_FILE',
|
||||
),
|
||||
clientCertificateAuthorityFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_CA_FILE',
|
||||
),
|
||||
clientCertificateRevocationListFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_CRL_FILE',
|
||||
),
|
||||
identityKeysetFile: absoluteFile(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_KEYSET_FILE',
|
||||
),
|
||||
planLifetimeMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_PLAN_LIFETIME_MS',
|
||||
15 * 60_000,
|
||||
1_000,
|
||||
15 * 60_000,
|
||||
),
|
||||
approvalLifetimeMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_APPROVAL_LIFETIME_MS',
|
||||
15 * 60_000,
|
||||
1_000,
|
||||
15 * 60_000,
|
||||
),
|
||||
quota: Object.freeze({
|
||||
windowMs: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_QUOTA_WINDOW_MS',
|
||||
60_000,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
),
|
||||
planLimit: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_PLAN_QUOTA',
|
||||
30,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
proposeLimit: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_PROPOSE_QUOTA',
|
||||
30,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
decideLimit: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_DECIDE_QUOTA',
|
||||
60,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
inspectLimit: integerValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_MANAGEMENT_INSPECT_QUOTA',
|
||||
600,
|
||||
1,
|
||||
1_000,
|
||||
),
|
||||
}),
|
||||
http,
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startClusterWorkerCredentialManagementProcess(
|
||||
options: StartClusterWorkerCredentialManagementProcessOptions,
|
||||
): Promise<Readonly<ClusterWorkerCredentialManagementProcessRuntime>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'environment',
|
||||
'openDatabase',
|
||||
'identities',
|
||||
'assertReady',
|
||||
'startHttp',
|
||||
'now',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
!options.environment ||
|
||||
typeof options.environment !== 'object' ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.identities !== undefined &&
|
||||
(typeof options.identities.reload !== 'function' ||
|
||||
typeof options.identities.bind !== 'function')) ||
|
||||
(options.assertReady !== undefined &&
|
||||
typeof options.assertReady !== 'function') ||
|
||||
(options.startHttp !== undefined &&
|
||||
typeof options.startHttp !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw configFailure('options are invalid');
|
||||
}
|
||||
const config = loadClusterWorkerCredentialManagementProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) {
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
let http:
|
||||
| Readonly<ClusterWorkerCredentialManagementHttpApplication>
|
||||
| undefined;
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let unavailableError: unknown;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics do not own availability or cleanup.
|
||||
}
|
||||
};
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'worker-credential-manager',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError(error) {
|
||||
const firstAvailabilityError = unavailableError === undefined;
|
||||
unavailableError ??= error;
|
||||
http?.withdraw(error);
|
||||
if (firstAvailabilityError) report(error);
|
||||
},
|
||||
});
|
||||
try {
|
||||
database = await openDatabase();
|
||||
const evidence = await (
|
||||
options.assertReady ?? assertPostgresWorkerCredentialManagerSchemaReady
|
||||
)(database.pool);
|
||||
if (unavailableError !== undefined) throw unavailableError;
|
||||
const identities =
|
||||
options.identities ??
|
||||
createClusterWorkerCredentialIdentityKeysetFile({
|
||||
filePath: config.identityKeysetFile,
|
||||
now,
|
||||
ledger:
|
||||
new PostgresWorkerCredentialManagementIdentityKeysetLedgerRepository(
|
||||
database.pool,
|
||||
'worker-credential-management',
|
||||
),
|
||||
});
|
||||
const identity = await identities.reload();
|
||||
const quota = new PostgresWorkerCredentialManagementQuotaRepository(
|
||||
database.pool,
|
||||
{
|
||||
windowMs: config.quota.windowMs,
|
||||
limits: {
|
||||
'worker-credential.plan': config.quota.planLimit,
|
||||
'worker-credential.propose': config.quota.proposeLimit,
|
||||
'worker-credential.decide': config.quota.decideLimit,
|
||||
'worker-credential.inspect': config.quota.inspectLimit,
|
||||
},
|
||||
},
|
||||
);
|
||||
const service = createClusterWorkerCredentialManagementService({
|
||||
pool: database.pool,
|
||||
planLifetimeMs: config.planLifetimeMs,
|
||||
approvalLifetimeMs: config.approvalLifetimeMs,
|
||||
quota,
|
||||
now,
|
||||
});
|
||||
const transport = createClusterWorkerCredentialManagementTransport({
|
||||
service,
|
||||
now,
|
||||
});
|
||||
const privateKey = readManagementTlsFile(
|
||||
config.privateKeyFile,
|
||||
true,
|
||||
configFailure,
|
||||
);
|
||||
try {
|
||||
const certificate = readManagementTlsFile(
|
||||
config.certificateFile,
|
||||
false,
|
||||
configFailure,
|
||||
);
|
||||
const clientCertificateAuthority = readManagementTlsFile(
|
||||
config.clientCertificateAuthorityFile,
|
||||
false,
|
||||
configFailure,
|
||||
);
|
||||
const clientCertificateRevocationList = readManagementTlsFile(
|
||||
config.clientCertificateRevocationListFile,
|
||||
false,
|
||||
configFailure,
|
||||
);
|
||||
try {
|
||||
validateWorkerCredentialManagementClientTrust(
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
now(),
|
||||
configFailure,
|
||||
);
|
||||
http = await (
|
||||
options.startHttp ?? startClusterWorkerCredentialManagementHttp
|
||||
)({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: {
|
||||
privateKey,
|
||||
certificate,
|
||||
clientCertificateAuthority,
|
||||
clientCertificateRevocationList,
|
||||
},
|
||||
transport,
|
||||
identities,
|
||||
limits: config.http,
|
||||
now,
|
||||
onError: report,
|
||||
});
|
||||
} finally {
|
||||
clientCertificateAuthority.fill(0);
|
||||
clientCertificateRevocationList.fill(0);
|
||||
}
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
if (unavailableError !== undefined) {
|
||||
http.withdraw(unavailableError);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: http.address,
|
||||
database: evidence,
|
||||
identity,
|
||||
availabilityStatus: () => http?.availabilityStatus() ?? 'stopped',
|
||||
close(): Promise<void> {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = (async () => {
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
await http?.close();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch (error) {
|
||||
primaryError ??= error;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await http?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
try {
|
||||
await database?.close();
|
||||
} catch {
|
||||
// Preserve startup failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
/** Authenticated Worker credential management transport boundary. */
|
||||
import type { ApprovalRequestRecord } from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type {
|
||||
CreateWorkerCredentialManagementPlanResult,
|
||||
WorkerCredentialManagementPlan,
|
||||
} from '@qinglong/runtime-core/worker-credential-management-plan';
|
||||
import type {
|
||||
ClusterWorkerCredentialManagementService,
|
||||
ProposeClusterWorkerCredentialResult,
|
||||
} from './workerCredentialManagement';
|
||||
|
||||
const STRONG_CLUSTER_ASSURANCES = new Set(['multi_factor', 'hardware']);
|
||||
|
||||
export interface ClusterWorkerCredentialManagementAuthentication {
|
||||
authenticate(): Promise<Readonly<SecurityPrincipal> | null>;
|
||||
}
|
||||
|
||||
export interface PlanClusterWorkerCredentialCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'worker-credential.plan';
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly action: 'issue' | 'rotate';
|
||||
readonly deliveryId: string;
|
||||
readonly workerId: string;
|
||||
readonly credentialId: string;
|
||||
readonly previousCredentialId: string | null;
|
||||
readonly credentialNotBeforeAtMs: number;
|
||||
readonly credentialExpiresAtMs: number;
|
||||
readonly deploymentTargetDigest: string;
|
||||
readonly deploymentGeneration: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProposeClusterWorkerCredentialCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'worker-credential.propose';
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DecideClusterWorkerCredentialCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'worker-credential.decide';
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly decisionId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InspectClusterWorkerCredentialCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'worker-credential.inspect';
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly inspectionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ClusterWorkerCredentialManagementCommand =
|
||||
| PlanClusterWorkerCredentialCommand
|
||||
| ProposeClusterWorkerCredentialCommand
|
||||
| DecideClusterWorkerCredentialCommand
|
||||
| InspectClusterWorkerCredentialCommand;
|
||||
|
||||
type PlanSummary = ReturnType<typeof planSummary>;
|
||||
type ApprovalSummary = ReturnType<typeof approvalSummary>;
|
||||
|
||||
export type ClusterWorkerCredentialManagementTransportResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'worker-credential.plan';
|
||||
status: 'created' | 'existing';
|
||||
plan: PlanSummary;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'worker-credential.propose';
|
||||
approvalStatus: 'created' | 'existing';
|
||||
plan: PlanSummary;
|
||||
approval: ApprovalSummary;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'worker-credential.decide';
|
||||
status: 'decided' | 'existing';
|
||||
approval: ApprovalSummary;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'worker-credential.inspect';
|
||||
plan: PlanSummary | null;
|
||||
approval: ApprovalSummary | null;
|
||||
stale: boolean;
|
||||
}>;
|
||||
|
||||
export interface ClusterWorkerCredentialManagementTransport {
|
||||
execute(
|
||||
command: unknown,
|
||||
authentication: ClusterWorkerCredentialManagementAuthentication,
|
||||
): Promise<Readonly<ClusterWorkerCredentialManagementTransportResult>>;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerCredentialManagementTransportOptions {
|
||||
readonly service: ClusterWorkerCredentialManagementService;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterWorkerCredentialManagementTransportConfigurationError extends TypeError {
|
||||
readonly code = 'CLUSTER_WORKER_CREDENTIAL_TRANSPORT_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster Worker credential transport configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterWorkerCredentialManagementTransportConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterWorkerCredentialManagementTransportRequestError extends TypeError {
|
||||
readonly code = 'CLUSTER_WORKER_CREDENTIAL_TRANSPORT_REQUEST_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Cluster Worker credential transport request is invalid: ${message}`);
|
||||
this.name = 'ClusterWorkerCredentialManagementTransportRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterWorkerCredentialManagementTransportAuthenticationError extends Error {
|
||||
readonly code = 'CLUSTER_WORKER_CREDENTIAL_TRANSPORT_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Cluster Worker credential transport requires a strong User principal',
|
||||
);
|
||||
this.name = 'ClusterWorkerCredentialManagementTransportAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterWorkerCredentialManagementTransportUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_WORKER_CREDENTIAL_TRANSPORT_UNAVAILABLE';
|
||||
|
||||
constructor(readonly cause?: unknown) {
|
||||
super('Cluster Worker credential transport is unavailable');
|
||||
this.name = 'ClusterWorkerCredentialManagementTransportUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterWorkerCredentialManagementTransportRequestError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new ClusterWorkerCredentialManagementTransportRequestError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeClusterWorkerCredentialManagementCommand(
|
||||
value: unknown,
|
||||
): Readonly<ClusterWorkerCredentialManagementCommand> {
|
||||
exactObject(value, ['schemaVersion', 'operation', 'request'], 'command');
|
||||
if (value.schemaVersion !== 1) {
|
||||
throw new ClusterWorkerCredentialManagementTransportRequestError(
|
||||
'schemaVersion is invalid',
|
||||
);
|
||||
}
|
||||
switch (value.operation) {
|
||||
case 'worker-credential.plan':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'action',
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'credentialExpiresAtMs',
|
||||
'credentialId',
|
||||
'credentialNotBeforeAtMs',
|
||||
'deliveryId',
|
||||
'deploymentGeneration',
|
||||
'deploymentTargetDigest',
|
||||
'previousCredentialId',
|
||||
'workerId',
|
||||
],
|
||||
'plan request',
|
||||
);
|
||||
break;
|
||||
case 'worker-credential.propose':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'approvalRequestId',
|
||||
'approvalAuditEventId',
|
||||
],
|
||||
'proposal request',
|
||||
);
|
||||
break;
|
||||
case 'worker-credential.decide':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'approvalRequestId',
|
||||
'expectedVersion',
|
||||
'decisionId',
|
||||
'auditEventId',
|
||||
'decision',
|
||||
'reasonCode',
|
||||
],
|
||||
'decision request',
|
||||
);
|
||||
break;
|
||||
case 'worker-credential.inspect':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'approvalRequestId',
|
||||
'inspectionId',
|
||||
],
|
||||
'inspection request',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new ClusterWorkerCredentialManagementTransportRequestError(
|
||||
'operation is not publicly available',
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
value as unknown as ClusterWorkerCredentialManagementCommand,
|
||||
);
|
||||
}
|
||||
|
||||
function planSummary(plan: Readonly<WorkerCredentialManagementPlan>) {
|
||||
return Object.freeze({
|
||||
actionRef: plan.actionRef,
|
||||
authorityProjectId: plan.authorityProjectId,
|
||||
action: plan.action,
|
||||
target: Object.freeze({ ...plan.target }),
|
||||
requestedBy: Object.freeze({ ...plan.requestedBy }),
|
||||
plannedAtMs: plan.plannedAtMs,
|
||||
expiresAtMs: plan.expiresAtMs,
|
||||
previewDigest: plan.previewDigest,
|
||||
planDigest: plan.planDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function approvalSummary(approval: Readonly<ApprovalRequestRecord>) {
|
||||
return Object.freeze({
|
||||
id: approval.id,
|
||||
projectId: approval.projectId,
|
||||
version: approval.version,
|
||||
state: approval.state,
|
||||
risk: approval.risk,
|
||||
decisionMode: approval.decisionMode,
|
||||
requestedBy: Object.freeze({ ...approval.requestedBy }),
|
||||
requestedAtMs: approval.requestedAtMs,
|
||||
expiresAtMs: approval.expiresAtMs,
|
||||
decision: approval.decision,
|
||||
decisionReasonCode: approval.decisionReasonCode,
|
||||
decidedBy: approval.decidedBy
|
||||
? Object.freeze({ ...approval.decidedBy })
|
||||
: null,
|
||||
decidedAtMs: approval.decidedAtMs,
|
||||
dispatchId: approval.dispatchId,
|
||||
consumedAtMs: approval.consumedAtMs,
|
||||
actionType: approval.action.actionType,
|
||||
actionRef: approval.action.actionRef,
|
||||
actionDigest: approval.action.actionDigest,
|
||||
previewDigest: approval.action.previewDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterWorkerCredentialManagementTransport(
|
||||
options: ClusterWorkerCredentialManagementTransportOptions,
|
||||
): Readonly<ClusterWorkerCredentialManagementTransport> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'service' && key !== 'now') ||
|
||||
!options.service ||
|
||||
typeof options.service.plan !== 'function' ||
|
||||
typeof options.service.propose !== 'function' ||
|
||||
typeof options.service.decide !== 'function' ||
|
||||
typeof options.service.inspectAuthorized !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new ClusterWorkerCredentialManagementTransportConfigurationError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return Object.freeze({
|
||||
async execute(
|
||||
commandValue: unknown,
|
||||
authentication: ClusterWorkerCredentialManagementAuthentication,
|
||||
): Promise<Readonly<ClusterWorkerCredentialManagementTransportResult>> {
|
||||
const command =
|
||||
normalizeClusterWorkerCredentialManagementCommand(commandValue);
|
||||
if (
|
||||
!authentication ||
|
||||
typeof authentication !== 'object' ||
|
||||
Array.isArray(authentication) ||
|
||||
Object.keys(authentication).some((key) => key !== 'authenticate') ||
|
||||
typeof authentication.authenticate !== 'function'
|
||||
) {
|
||||
throw new ClusterWorkerCredentialManagementTransportConfigurationError(
|
||||
'authentication authority is invalid',
|
||||
);
|
||||
}
|
||||
let candidate: Readonly<SecurityPrincipal> | null;
|
||||
try {
|
||||
candidate = await authentication.authenticate();
|
||||
} catch (error) {
|
||||
throw new ClusterWorkerCredentialManagementTransportUnavailableError(
|
||||
error,
|
||||
);
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new ClusterWorkerCredentialManagementTransportUnavailableError();
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(
|
||||
candidate as SecurityPrincipal,
|
||||
observedAtMs,
|
||||
);
|
||||
} catch {
|
||||
throw new ClusterWorkerCredentialManagementTransportAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_CLUSTER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new ClusterWorkerCredentialManagementTransportAuthenticationError();
|
||||
}
|
||||
|
||||
switch (command.operation) {
|
||||
case 'worker-credential.plan': {
|
||||
const result: Readonly<CreateWorkerCredentialManagementPlanResult> =
|
||||
await options.service.plan({ ...command.request, principal });
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
plan: planSummary(result.plan),
|
||||
});
|
||||
}
|
||||
case 'worker-credential.propose': {
|
||||
const result: Readonly<ProposeClusterWorkerCredentialResult> =
|
||||
await options.service.propose({ ...command.request, principal });
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
approvalStatus: result.approvalStatus,
|
||||
plan: planSummary(result.plan),
|
||||
approval: approvalSummary(result.approvalRequest),
|
||||
});
|
||||
}
|
||||
case 'worker-credential.decide': {
|
||||
const result = await options.service.decide({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
approval: approvalSummary(result.request),
|
||||
});
|
||||
}
|
||||
case 'worker-credential.inspect': {
|
||||
const result = await options.service.inspectAuthorized({
|
||||
...command.request,
|
||||
principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
plan: result.plan ? planSummary(result.plan) : null,
|
||||
approval: result.approvalRequest
|
||||
? approvalSummary(result.approvalRequest)
|
||||
: null,
|
||||
stale: result.stale,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/** Worker credential administration application boundary. */
|
||||
import { randomBytes as nodeRandomBytes } from 'node:crypto';
|
||||
import {
|
||||
WorkerCredentialMutationConflictError,
|
||||
normalizeWorkerCredentialId,
|
||||
normalizeWorkerCredentialMutationId,
|
||||
type AppendWorkerCredentialResult,
|
||||
type WorkerCredentialAdministrationOperation,
|
||||
type WorkerCredentialAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/worker-credential';
|
||||
import {
|
||||
assertWorkerCredentialPepper,
|
||||
formatWorkerCredentialToken,
|
||||
workerCredentialSecretDigest,
|
||||
} from '@qinglong/runtime-core/worker-credential-token';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
const MAX_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
const REVOKED_DIGEST = '0'.repeat(64);
|
||||
const STRONG = new Set(['multi_factor', 'hardware', 'local_console']);
|
||||
const SAFE_WORKER_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SAFE_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export interface WorkerCredentialAdministrationRequest {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly credentialId: string;
|
||||
readonly workerId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ActiveWorkerCredentialAdministrationRequest
|
||||
extends WorkerCredentialAdministrationRequest {
|
||||
readonly notBeforeAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialAdministrationResult
|
||||
extends AppendWorkerCredentialResult {
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialAdministrationService {
|
||||
issue(
|
||||
request: ActiveWorkerCredentialAdministrationRequest,
|
||||
): Promise<WorkerCredentialAdministrationResult>;
|
||||
rotate(
|
||||
request: ActiveWorkerCredentialAdministrationRequest,
|
||||
): Promise<WorkerCredentialAdministrationResult>;
|
||||
revoke(
|
||||
request: WorkerCredentialAdministrationRequest,
|
||||
): Promise<WorkerCredentialAdministrationResult>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialAdministrationOptions {
|
||||
readonly now?: () => number;
|
||||
readonly randomBytes?: (size: number) => Buffer;
|
||||
readonly returnToken?: boolean;
|
||||
}
|
||||
|
||||
function exactRequest(
|
||||
value: WorkerCredentialAdministrationRequest,
|
||||
active: boolean,
|
||||
): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential administration request is invalid');
|
||||
}
|
||||
const expected = [
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'expectedCurrentVersion',
|
||||
'credentialId',
|
||||
'workerId',
|
||||
'principal',
|
||||
...(active ? ['notBeforeAtMs', 'expiresAtMs'] : []),
|
||||
].sort();
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new TypeError('Worker credential administration request shape is invalid');
|
||||
}
|
||||
normalizeWorkerCredentialMutationId(value.mutationId);
|
||||
normalizeWorkerCredentialId(value.credentialId);
|
||||
if (
|
||||
typeof value.requestId !== 'string' ||
|
||||
!SAFE_REQUEST_ID.test(value.requestId) ||
|
||||
typeof value.workerId !== 'string' ||
|
||||
!SAFE_WORKER_ID.test(value.workerId)
|
||||
) {
|
||||
throw new TypeError('Worker credential administration request identity is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function principal(value: SecurityPrincipal, nowMs: number) {
|
||||
const normalized = normalizeSecurityPrincipal(value, nowMs);
|
||||
if (
|
||||
!(
|
||||
(normalized.subject.type === 'user' && STRONG.has(normalized.assurance)) ||
|
||||
(normalized.subject.type === 'system' && normalized.assurance === 'service')
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Worker credential administration requires a strong principal');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sameReplay(
|
||||
operation: WorkerCredentialAdministrationOperation,
|
||||
existing: Awaited<ReturnType<WorkerCredentialAdministrationRepository['resolveMutation']>>,
|
||||
request: WorkerCredentialAdministrationRequest | ActiveWorkerCredentialAdministrationRequest,
|
||||
): boolean {
|
||||
if (!existing) return false;
|
||||
const active = operation === 'revoke'
|
||||
? null
|
||||
: request as ActiveWorkerCredentialAdministrationRequest;
|
||||
return (
|
||||
existing.mutation.operation === operation &&
|
||||
existing.mutation.credentialId === request.credentialId &&
|
||||
existing.mutation.expectedPreviousVersion === request.expectedCurrentVersion &&
|
||||
existing.credential.workerId === request.workerId &&
|
||||
existing.credential.state === (operation === 'revoke' ? 'revoked' : 'active') &&
|
||||
(active === null ||
|
||||
(existing.credential.notBeforeAtMs === active.notBeforeAtMs &&
|
||||
existing.credential.expiresAtMs === active.expiresAtMs)) &&
|
||||
existing.audit.requestId === request.requestId &&
|
||||
existing.audit.subject?.type === request.principal.subject.type &&
|
||||
existing.audit.subject.id === request.principal.subject.id
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorkerCredentialAdministrationService(
|
||||
repository: WorkerCredentialAdministrationRepository,
|
||||
pepper: string,
|
||||
options: WorkerCredentialAdministrationOptions = {},
|
||||
): WorkerCredentialAdministrationService {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.resolveMutation !== 'function' ||
|
||||
typeof repository.append !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential administration repository is invalid');
|
||||
}
|
||||
assertWorkerCredentialPepper(pepper);
|
||||
const now = options.now ?? Date.now;
|
||||
const randomBytes = options.randomBytes ?? nodeRandomBytes;
|
||||
|
||||
const mutate = async (
|
||||
operation: WorkerCredentialAdministrationOperation,
|
||||
request: WorkerCredentialAdministrationRequest | ActiveWorkerCredentialAdministrationRequest,
|
||||
): Promise<WorkerCredentialAdministrationResult> => {
|
||||
exactRequest(request, operation !== 'revoke');
|
||||
const nowMs = now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new TypeError('Worker credential administration clock is invalid');
|
||||
}
|
||||
const actor = principal(request.principal, nowMs);
|
||||
if (
|
||||
(operation === 'issue' && request.expectedCurrentVersion !== 0) ||
|
||||
(operation !== 'issue' &&
|
||||
(!Number.isSafeInteger(request.expectedCurrentVersion) ||
|
||||
request.expectedCurrentVersion < 1))
|
||||
) {
|
||||
throw new RangeError('Worker credential administration operation fence is invalid');
|
||||
}
|
||||
const existing = await repository.resolveMutation(request.mutationId);
|
||||
if (existing) {
|
||||
if (!sameReplay(operation, existing, request)) {
|
||||
throw new WorkerCredentialMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credential: existing.credential,
|
||||
mutation: existing.mutation,
|
||||
token: null,
|
||||
});
|
||||
}
|
||||
const active = operation === 'revoke'
|
||||
? null
|
||||
: request as ActiveWorkerCredentialAdministrationRequest;
|
||||
const notBeforeAtMs = active?.notBeforeAtMs ?? nowMs;
|
||||
const expiresAtMs = active?.expiresAtMs ?? Math.max(nowMs + 1, nowMs + 1_000);
|
||||
if (
|
||||
!Number.isSafeInteger(request.expectedCurrentVersion) ||
|
||||
request.expectedCurrentVersion < 0 ||
|
||||
!Number.isSafeInteger(notBeforeAtMs) ||
|
||||
!Number.isSafeInteger(expiresAtMs) ||
|
||||
notBeforeAtMs < 0 ||
|
||||
expiresAtMs <= Math.max(nowMs, notBeforeAtMs) ||
|
||||
expiresAtMs - notBeforeAtMs > MAX_LIFETIME_MS
|
||||
) {
|
||||
throw new RangeError('Worker credential administration lifetime or fence is invalid');
|
||||
}
|
||||
let secret: Buffer | undefined;
|
||||
let secretText: string | undefined;
|
||||
try {
|
||||
if (operation !== 'revoke') {
|
||||
secret = randomBytes(32);
|
||||
if (!Buffer.isBuffer(secret) || secret.byteLength !== 32) {
|
||||
throw new TypeError('Worker credential administration entropy is invalid');
|
||||
}
|
||||
secretText = secret.toString('base64url');
|
||||
}
|
||||
const credential = {
|
||||
credentialId: request.credentialId,
|
||||
version: request.expectedCurrentVersion + 1,
|
||||
state: operation === 'revoke' ? 'revoked' as const : 'active' as const,
|
||||
workerId: request.workerId,
|
||||
secretDigest: secretText
|
||||
? workerCredentialSecretDigest(pepper, request.credentialId, secretText)
|
||||
: REVOKED_DIGEST,
|
||||
createdAtMs: nowMs,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
const result = await repository.append({
|
||||
expectedCurrentVersion: request.expectedCurrentVersion,
|
||||
credential,
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation,
|
||||
credentialId: request.credentialId,
|
||||
credentialVersion: request.expectedCurrentVersion + 1,
|
||||
expectedPreviousVersion: request.expectedCurrentVersion,
|
||||
changedBy: actor.subject,
|
||||
createdAtMs: nowMs,
|
||||
},
|
||||
audit: {
|
||||
eventId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
operationId: `worker_credential.${operation}`,
|
||||
projectId: null,
|
||||
subject: actor.subject,
|
||||
authenticationId: actor.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['worker_credential_admin'],
|
||||
fence: null,
|
||||
occurredAtMs: nowMs,
|
||||
},
|
||||
});
|
||||
return Object.freeze({
|
||||
...result,
|
||||
token:
|
||||
options.returnToken !== false &&
|
||||
result.status === 'created' && secretText
|
||||
? formatWorkerCredentialToken(request.credentialId, secretText)
|
||||
: null,
|
||||
});
|
||||
} finally {
|
||||
secret?.fill(0);
|
||||
secretText = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
issue: (request: ActiveWorkerCredentialAdministrationRequest) =>
|
||||
mutate('issue', request),
|
||||
rotate: (request: ActiveWorkerCredentialAdministrationRequest) =>
|
||||
mutate('rotate', request),
|
||||
revoke: (request: WorkerCredentialAdministrationRequest) =>
|
||||
mutate('revoke', request),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
/** Recoverable Worker credential delivery application boundary. */
|
||||
import {
|
||||
createHash,
|
||||
randomBytes as nodeRandomBytes,
|
||||
} from 'node:crypto';
|
||||
import {
|
||||
normalizeWorkerCredentialId,
|
||||
normalizeWorkerCredentialMutationId,
|
||||
type AppendWorkerCredentialCommand,
|
||||
} from '@qinglong/runtime-core/worker-credential';
|
||||
import {
|
||||
WorkerCredentialDeliveryConflictError,
|
||||
WorkerCredentialDeliveryUnavailableError,
|
||||
MAX_WORKER_CREDENTIAL_STAGE_DISCARD_PAGE_SIZE,
|
||||
normalizeCommitWorkerCredentialDeliveryCommand,
|
||||
normalizeRevokePreviousWorkerCredentialDeliveryCommand,
|
||||
normalizeWorkerCredentialDeliveryIntent,
|
||||
normalizeWorkerCredentialDeliveryRecoveryPage,
|
||||
normalizeWorkerCredentialDeliveryRecord,
|
||||
normalizeWorkerCredentialStageDiscardRecord,
|
||||
normalizeWorkerCredentialStageDiscardRecoveryPage,
|
||||
workerCredentialDeliveryTokenDigest,
|
||||
type ResolvedWorkerCredentialDelivery,
|
||||
type WorkerCredentialDeliveryAdministrationRepository,
|
||||
type WorkerCredentialDeliveryIntent,
|
||||
type WorkerCredentialDeliveryRecoveryPage,
|
||||
type WorkerCredentialDeliveryRecord,
|
||||
type WorkerCredentialStageDiscardRecord,
|
||||
type WorkerCredentialStageDiscardRecoveryPage,
|
||||
} from '@qinglong/runtime-core/worker-credential-delivery';
|
||||
import {
|
||||
formatWorkerCredentialToken,
|
||||
} from '@qinglong/runtime-core/worker-credential-token';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
createWorkerCredentialAdministrationService,
|
||||
type ActiveWorkerCredentialAdministrationRequest,
|
||||
} from './workerCredentialAdministration';
|
||||
|
||||
const MAX_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
const STRONG = new Set(['multi_factor', 'hardware', 'local_console']);
|
||||
const SAFE_WORKER_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SAFE_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SAFE_GENERATION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface RecoverableWorkerCredentialIssueRequest
|
||||
extends ActiveWorkerCredentialAdministrationRequest {
|
||||
readonly previousCredentialId: string | null;
|
||||
readonly deploymentTargetDigest: string;
|
||||
readonly deploymentGeneration: string;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialStagedSecretAdapter {
|
||||
inspect(
|
||||
deliveryId: string,
|
||||
): Promise<Readonly<WorkerCredentialDeliveryIntent> | null>;
|
||||
stage(
|
||||
delivery: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
token: Buffer,
|
||||
): Promise<void>;
|
||||
publish(
|
||||
delivery: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
): Promise<Readonly<{ publicationDigest: string }>>;
|
||||
discard(delivery: Readonly<WorkerCredentialDeliveryIntent>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialStagedSecretPage {
|
||||
readonly stages: readonly Readonly<WorkerCredentialDeliveryIntent>[];
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialStagedSecretInventoryAdapter
|
||||
extends WorkerCredentialStagedSecretAdapter {
|
||||
listStaged(options?: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<Readonly<WorkerCredentialStagedSecretPage>>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialStageCleanupPageResult {
|
||||
readonly outcomes: readonly Readonly<{
|
||||
deliveryId: string;
|
||||
result: 'discarded' | 'already_discarded';
|
||||
}>[];
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialStageCleanupRecoveryResult
|
||||
extends WorkerCredentialStageCleanupPageResult {
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialStageCleanupService {
|
||||
cleanupInventoryPage(options?: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<Readonly<WorkerCredentialStageCleanupPageResult>>;
|
||||
recoverAuthorizedPage(options?: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<Readonly<WorkerCredentialStageCleanupRecoveryResult>>;
|
||||
}
|
||||
|
||||
export interface RecoverableWorkerCredentialIssueResult {
|
||||
readonly status:
|
||||
| 'published'
|
||||
| 'existing'
|
||||
| 'orphaned_stage_discarded';
|
||||
readonly delivery: Readonly<WorkerCredentialDeliveryRecord> | null;
|
||||
}
|
||||
|
||||
export interface RecoverableWorkerCredentialIssuer {
|
||||
issue(
|
||||
request: RecoverableWorkerCredentialIssueRequest,
|
||||
): Promise<RecoverableWorkerCredentialIssueResult>;
|
||||
}
|
||||
|
||||
export interface RecoverableWorkerCredentialIssuerOptions {
|
||||
readonly now?: () => number;
|
||||
readonly randomBytes?: (size: number) => Buffer;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialDeliveryRecoveryResult {
|
||||
readonly observedAtMs: number;
|
||||
readonly outcomes: readonly Readonly<{
|
||||
deliveryId: string;
|
||||
state: WorkerCredentialDeliveryRecord['state'];
|
||||
result: 'published' | 'waiting_observation' | 'previous_revoked';
|
||||
}>[];
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialDeliveryRecoveryService {
|
||||
recoverPage(options?: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<Readonly<WorkerCredentialDeliveryRecoveryResult>>;
|
||||
}
|
||||
|
||||
function exactRequest(value: RecoverableWorkerCredentialIssueRequest): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Recoverable Worker credential issue request is invalid');
|
||||
}
|
||||
const expected = [
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'expectedCurrentVersion',
|
||||
'credentialId',
|
||||
'workerId',
|
||||
'principal',
|
||||
'notBeforeAtMs',
|
||||
'expiresAtMs',
|
||||
'previousCredentialId',
|
||||
'deploymentTargetDigest',
|
||||
'deploymentGeneration',
|
||||
].sort();
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Recoverable Worker credential issue request shape is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRequest(
|
||||
request: RecoverableWorkerCredentialIssueRequest,
|
||||
nowMs: number,
|
||||
): void {
|
||||
exactRequest(request);
|
||||
normalizeWorkerCredentialMutationId(request.mutationId);
|
||||
normalizeWorkerCredentialId(request.credentialId);
|
||||
if (request.previousCredentialId !== null) {
|
||||
normalizeWorkerCredentialId(request.previousCredentialId);
|
||||
}
|
||||
if (
|
||||
request.expectedCurrentVersion !== 0 ||
|
||||
request.previousCredentialId === request.credentialId ||
|
||||
typeof request.workerId !== 'string' ||
|
||||
!SAFE_WORKER_ID.test(request.workerId) ||
|
||||
typeof request.requestId !== 'string' ||
|
||||
!SAFE_REQUEST_ID.test(request.requestId) ||
|
||||
typeof request.deploymentTargetDigest !== 'string' ||
|
||||
!SHA256.test(request.deploymentTargetDigest) ||
|
||||
typeof request.deploymentGeneration !== 'string' ||
|
||||
!SAFE_GENERATION.test(request.deploymentGeneration)
|
||||
) {
|
||||
throw new TypeError('Recoverable Worker credential issue identity is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(nowMs) ||
|
||||
nowMs < 0 ||
|
||||
!Number.isSafeInteger(request.notBeforeAtMs) ||
|
||||
request.notBeforeAtMs < 0 ||
|
||||
!Number.isSafeInteger(request.expiresAtMs) ||
|
||||
request.expiresAtMs <= Math.max(nowMs, request.notBeforeAtMs) ||
|
||||
request.expiresAtMs - request.notBeforeAtMs > MAX_LIFETIME_MS
|
||||
) {
|
||||
throw new RangeError('Recoverable Worker credential issue lifetime is invalid');
|
||||
}
|
||||
const actor = normalizeSecurityPrincipal(request.principal, nowMs);
|
||||
if (
|
||||
!(
|
||||
(actor.subject.type === 'user' && STRONG.has(actor.assurance)) ||
|
||||
(actor.subject.type === 'system' && actor.assurance === 'service')
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Recoverable Worker credential issue requires a strong principal',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestForAdministration(
|
||||
request: RecoverableWorkerCredentialIssueRequest,
|
||||
): ActiveWorkerCredentialAdministrationRequest {
|
||||
return {
|
||||
mutationId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
expectedCurrentVersion: request.expectedCurrentVersion,
|
||||
credentialId: request.credentialId,
|
||||
workerId: request.workerId,
|
||||
principal: request.principal,
|
||||
notBeforeAtMs: request.notBeforeAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function sameRequest(
|
||||
resolved: ResolvedWorkerCredentialDelivery,
|
||||
request: RecoverableWorkerCredentialIssueRequest,
|
||||
): boolean {
|
||||
const { delivery, credential, mutation, audit } = resolved;
|
||||
return (
|
||||
delivery.deliveryId === request.mutationId &&
|
||||
delivery.credentialId === request.credentialId &&
|
||||
delivery.previousCredentialId === request.previousCredentialId &&
|
||||
delivery.workerId === request.workerId &&
|
||||
delivery.deploymentTargetDigest === request.deploymentTargetDigest &&
|
||||
delivery.deploymentGeneration === request.deploymentGeneration &&
|
||||
credential.notBeforeAtMs === request.notBeforeAtMs &&
|
||||
credential.expiresAtMs === request.expiresAtMs &&
|
||||
mutation.operation === 'issue' &&
|
||||
mutation.expectedPreviousVersion === 0 &&
|
||||
audit.requestId === request.requestId &&
|
||||
audit.subject?.type === request.principal.subject.type &&
|
||||
audit.subject.id === request.principal.subject.id
|
||||
);
|
||||
}
|
||||
|
||||
function sameStage(
|
||||
delivery: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
staged: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
): boolean {
|
||||
return (
|
||||
delivery.deliveryId === staged.deliveryId &&
|
||||
delivery.workerId === staged.workerId &&
|
||||
delivery.credentialId === staged.credentialId &&
|
||||
delivery.credentialVersion === staged.credentialVersion &&
|
||||
delivery.previousCredentialId === staged.previousCredentialId &&
|
||||
delivery.secretDigest === staged.secretDigest &&
|
||||
delivery.tokenDigest === staged.tokenDigest &&
|
||||
delivery.deploymentTargetDigest === staged.deploymentTargetDigest &&
|
||||
delivery.deploymentGeneration === staged.deploymentGeneration &&
|
||||
delivery.stagedAtMs === staged.stagedAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function mapDeliveryAdapterError(error: unknown): never {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
|
||||
function deliveryIntent(
|
||||
command: Readonly<AppendWorkerCredentialCommand>,
|
||||
request: RecoverableWorkerCredentialIssueRequest,
|
||||
digest: string,
|
||||
): Readonly<WorkerCredentialDeliveryIntent> {
|
||||
return normalizeWorkerCredentialDeliveryIntent({
|
||||
deliveryId: command.mutation.mutationId,
|
||||
workerId: command.credential.workerId,
|
||||
credentialId: command.credential.credentialId,
|
||||
credentialVersion: command.credential.version,
|
||||
previousCredentialId: request.previousCredentialId,
|
||||
secretDigest: command.credential.secretDigest,
|
||||
tokenDigest: digest,
|
||||
deploymentTargetDigest: request.deploymentTargetDigest,
|
||||
deploymentGeneration: request.deploymentGeneration,
|
||||
stagedAtMs: command.credential.createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function committedDelivery(
|
||||
intent: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
credentialCommittedAtMs: number,
|
||||
): Readonly<WorkerCredentialDeliveryRecord> {
|
||||
return normalizeWorkerCredentialDeliveryRecord({
|
||||
...intent,
|
||||
version: 1,
|
||||
state: 'credential_committed',
|
||||
credentialCommittedAtMs,
|
||||
publishedAtMs: null,
|
||||
publicationDigest: null,
|
||||
observedAtMs: null,
|
||||
observedSessionId: null,
|
||||
observedSessionVersion: null,
|
||||
previousRevokedAtMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function createRecoverableWorkerCredentialIssuer(
|
||||
authority: WorkerCredentialDeliveryAdministrationRepository,
|
||||
deliveryAdapter: WorkerCredentialStagedSecretAdapter,
|
||||
pepper: string,
|
||||
options: RecoverableWorkerCredentialIssuerOptions = {},
|
||||
): RecoverableWorkerCredentialIssuer {
|
||||
if (
|
||||
!authority ||
|
||||
typeof authority.resolveMutation !== 'function' ||
|
||||
typeof authority.resolveDelivered !== 'function' ||
|
||||
typeof authority.commitDelivered !== 'function' ||
|
||||
typeof authority.markPublished !== 'function' ||
|
||||
typeof authority.authorizeStageDiscard !== 'function' ||
|
||||
typeof authority.markStageDiscarded !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery authority is invalid');
|
||||
}
|
||||
if (
|
||||
!deliveryAdapter ||
|
||||
typeof deliveryAdapter.inspect !== 'function' ||
|
||||
typeof deliveryAdapter.stage !== 'function' ||
|
||||
typeof deliveryAdapter.publish !== 'function' ||
|
||||
typeof deliveryAdapter.discard !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery adapter is invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const randomBytes = options.randomBytes ?? nodeRandomBytes;
|
||||
|
||||
const publish = async (
|
||||
delivery: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
): Promise<Readonly<WorkerCredentialDeliveryRecord>> => {
|
||||
if (delivery.state !== 'credential_committed') return delivery;
|
||||
let publication: Readonly<{ publicationDigest: string }>;
|
||||
try {
|
||||
publication = await deliveryAdapter.publish(delivery);
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
if (
|
||||
!publication ||
|
||||
typeof publication !== 'object' ||
|
||||
Array.isArray(publication) ||
|
||||
Object.keys(publication).length !== 1 ||
|
||||
typeof publication.publicationDigest !== 'string' ||
|
||||
!SHA256.test(publication.publicationDigest)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const publishedAtMs = now();
|
||||
if (
|
||||
!Number.isSafeInteger(publishedAtMs) ||
|
||||
publishedAtMs < delivery.credentialCommittedAtMs
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
try {
|
||||
return normalizeWorkerCredentialDeliveryRecord(
|
||||
await authority.markPublished({
|
||||
deliveryId: delivery.deliveryId,
|
||||
expectedVersion: delivery.version,
|
||||
publicationDigest: publication.publicationDigest,
|
||||
publishedAtMs,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async issue(request: RecoverableWorkerCredentialIssueRequest) {
|
||||
const operationNowMs = now();
|
||||
validateRequest(request, operationNowMs);
|
||||
let capturedSecret: Buffer | undefined;
|
||||
try {
|
||||
let resolved: ResolvedWorkerCredentialDelivery | null;
|
||||
try {
|
||||
resolved = await authority.resolveDelivered(request.mutationId);
|
||||
} catch {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (resolved) {
|
||||
if (!sameRequest(resolved, request)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
let staged: Readonly<WorkerCredentialDeliveryIntent> | null;
|
||||
try {
|
||||
const inspected = await deliveryAdapter.inspect(request.mutationId);
|
||||
staged = inspected
|
||||
? normalizeWorkerCredentialDeliveryIntent(inspected)
|
||||
: null;
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
if (!staged || !sameStage(resolved.delivery, staged)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
const delivery = await publish(resolved.delivery);
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
delivery,
|
||||
});
|
||||
}
|
||||
|
||||
let rawMutation;
|
||||
let orphanedStage: Readonly<WorkerCredentialDeliveryIntent> | null;
|
||||
try {
|
||||
rawMutation = await authority.resolveMutation(request.mutationId);
|
||||
const inspected = await deliveryAdapter.inspect(request.mutationId);
|
||||
orphanedStage = inspected
|
||||
? normalizeWorkerCredentialDeliveryIntent(inspected)
|
||||
: null;
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
if (rawMutation) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
if (orphanedStage) {
|
||||
let authorized: Readonly<WorkerCredentialStageDiscardRecord>;
|
||||
try {
|
||||
authorized = normalizeWorkerCredentialStageDiscardRecord(
|
||||
await authority.authorizeStageDiscard(orphanedStage),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (!sameStage(authorized, orphanedStage)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
try {
|
||||
await deliveryAdapter.discard(orphanedStage);
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
if (authorized.state === 'discard_authorized') {
|
||||
let completed: Readonly<WorkerCredentialStageDiscardRecord>;
|
||||
try {
|
||||
completed = normalizeWorkerCredentialStageDiscardRecord(
|
||||
await authority.markStageDiscarded({
|
||||
deliveryId: authorized.deliveryId,
|
||||
expectedVersion: authorized.version,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (
|
||||
completed.state !== 'discarded' ||
|
||||
completed.authorizedAtMs !== authorized.authorizedAtMs ||
|
||||
!sameStage(completed, authorized)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'orphaned_stage_discarded' as const,
|
||||
delivery: null,
|
||||
});
|
||||
}
|
||||
|
||||
const repository = {
|
||||
resolveMutation: authority.resolveMutation.bind(authority),
|
||||
async append(command: AppendWorkerCredentialCommand) {
|
||||
if (!capturedSecret) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
let token: Buffer | undefined;
|
||||
try {
|
||||
const secretText = capturedSecret.toString('base64url');
|
||||
token = Buffer.from(
|
||||
formatWorkerCredentialToken(
|
||||
command.credential.credentialId,
|
||||
secretText,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const intent = deliveryIntent(
|
||||
command,
|
||||
request,
|
||||
workerCredentialDeliveryTokenDigest(token),
|
||||
);
|
||||
try {
|
||||
await deliveryAdapter.stage(intent, token);
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
const delivery = committedDelivery(
|
||||
intent,
|
||||
command.credential.createdAtMs,
|
||||
);
|
||||
return await authority.commitDelivered(
|
||||
normalizeCommitWorkerCredentialDeliveryCommand({
|
||||
credential: command,
|
||||
delivery,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
token?.fill(0);
|
||||
capturedSecret.fill(0);
|
||||
capturedSecret = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
const administration = createWorkerCredentialAdministrationService(
|
||||
repository,
|
||||
pepper,
|
||||
{
|
||||
now: () => operationNowMs,
|
||||
randomBytes(size) {
|
||||
const secret = randomBytes(size);
|
||||
if (Buffer.isBuffer(secret)) capturedSecret = Buffer.from(secret);
|
||||
return secret;
|
||||
},
|
||||
returnToken: false,
|
||||
},
|
||||
);
|
||||
await administration.issue(requestForAdministration(request));
|
||||
let committed: ResolvedWorkerCredentialDelivery | null;
|
||||
try {
|
||||
committed = await authority.resolveDelivered(request.mutationId);
|
||||
} catch {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (!committed || !sameRequest(committed, request)) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const delivery = await publish(committed.delivery);
|
||||
return Object.freeze({ status: 'published' as const, delivery });
|
||||
} finally {
|
||||
capturedSecret?.fill(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const REVOKE_MUTATION_DOMAIN = Buffer.from(
|
||||
'qinglong/worker-credential-delivery-revoke@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function revokeMutationId(deliveryId: string): string {
|
||||
const value = createHash('sha256')
|
||||
.update(REVOKE_MUTATION_DOMAIN)
|
||||
.update(deliveryId, 'utf8')
|
||||
.digest();
|
||||
value[6] = (value[6]! & 0x0f) | 0x40;
|
||||
value[8] = (value[8]! & 0x3f) | 0x80;
|
||||
const hex = value.subarray(0, 16).toString('hex');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
export function createWorkerCredentialDeliveryRecoveryService(
|
||||
authority: WorkerCredentialDeliveryAdministrationRepository,
|
||||
deliveryAdapter: WorkerCredentialStagedSecretAdapter,
|
||||
pepper: string,
|
||||
principal: SecurityPrincipal,
|
||||
): WorkerCredentialDeliveryRecoveryService {
|
||||
if (
|
||||
!authority ||
|
||||
typeof authority.resolveMutation !== 'function' ||
|
||||
typeof authority.resolveDelivery !== 'function' ||
|
||||
typeof authority.markPublished !== 'function' ||
|
||||
typeof authority.listRecoveryPage !== 'function' ||
|
||||
typeof authority.revokePreviousDelivered !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential recovery authority is invalid');
|
||||
}
|
||||
if (
|
||||
!deliveryAdapter ||
|
||||
typeof deliveryAdapter.inspect !== 'function' ||
|
||||
typeof deliveryAdapter.publish !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential recovery adapter is invalid');
|
||||
}
|
||||
const publish = async (
|
||||
delivery: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
operationNowMs: number,
|
||||
): Promise<Readonly<WorkerCredentialDeliveryRecord>> => {
|
||||
let staged: Readonly<WorkerCredentialDeliveryIntent> | null;
|
||||
try {
|
||||
const inspected = await deliveryAdapter.inspect(delivery.deliveryId);
|
||||
staged = inspected
|
||||
? normalizeWorkerCredentialDeliveryIntent(inspected)
|
||||
: null;
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
if (!staged || !sameStage(delivery, staged)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
let published: Readonly<{ publicationDigest: string }>;
|
||||
try {
|
||||
published = await deliveryAdapter.publish(delivery);
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
if (
|
||||
!published ||
|
||||
typeof published !== 'object' ||
|
||||
Array.isArray(published) ||
|
||||
Object.keys(published).length !== 1 ||
|
||||
typeof published.publicationDigest !== 'string' ||
|
||||
!SHA256.test(published.publicationDigest)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (operationNowMs < delivery.credentialCommittedAtMs) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
try {
|
||||
return normalizeWorkerCredentialDeliveryRecord(
|
||||
await authority.markPublished({
|
||||
deliveryId: delivery.deliveryId,
|
||||
expectedVersion: 1,
|
||||
publicationDigest: published.publicationDigest,
|
||||
publishedAtMs: operationNowMs,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (
|
||||
delivery: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
operationNowMs: number,
|
||||
): Promise<Readonly<WorkerCredentialDeliveryRecord>> => {
|
||||
if (delivery.state !== 'observed' || !delivery.previousCredentialId) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
if (operationNowMs < (delivery.observedAtMs ?? 0)) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const repository = {
|
||||
resolveMutation: authority.resolveMutation.bind(authority),
|
||||
append(command: AppendWorkerCredentialCommand) {
|
||||
return authority.revokePreviousDelivered(
|
||||
normalizeRevokePreviousWorkerCredentialDeliveryCommand({
|
||||
credential: command,
|
||||
delivery: normalizeWorkerCredentialDeliveryRecord({
|
||||
...delivery,
|
||||
version: 4,
|
||||
state: 'previous_revoked',
|
||||
previousRevokedAtMs: command.credential.createdAtMs,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
const administration = createWorkerCredentialAdministrationService(
|
||||
repository,
|
||||
pepper,
|
||||
{ now: () => operationNowMs, returnToken: false },
|
||||
);
|
||||
await administration.revoke({
|
||||
mutationId: revokeMutationId(delivery.deliveryId),
|
||||
requestId: `worker-delivery-revoke:${delivery.deliveryId}`,
|
||||
expectedCurrentVersion: 1,
|
||||
credentialId: delivery.previousCredentialId,
|
||||
workerId: delivery.workerId,
|
||||
principal,
|
||||
});
|
||||
let resolved: Readonly<WorkerCredentialDeliveryRecord> | null;
|
||||
try {
|
||||
resolved = await authority.resolveDelivery(delivery.deliveryId);
|
||||
} catch {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (
|
||||
!resolved ||
|
||||
resolved.state !== 'previous_revoked' ||
|
||||
!sameStage(resolved, delivery)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async recoverPage(
|
||||
requested: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}> = {},
|
||||
) {
|
||||
let page: Readonly<WorkerCredentialDeliveryRecoveryPage>;
|
||||
try {
|
||||
page = normalizeWorkerCredentialDeliveryRecoveryPage(
|
||||
await authority.listRecoveryPage(requested),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const outcomes = [];
|
||||
for (const candidate of page.deliveries) {
|
||||
const delivery = candidate.state === 'credential_committed'
|
||||
? await publish(candidate, page.observedAtMs)
|
||||
: candidate.state === 'observed'
|
||||
? await revoke(candidate, page.observedAtMs)
|
||||
: candidate;
|
||||
outcomes.push(Object.freeze({
|
||||
deliveryId: delivery.deliveryId,
|
||||
state: delivery.state,
|
||||
result: candidate.state === 'credential_committed'
|
||||
? 'published' as const
|
||||
: candidate.state === 'observed'
|
||||
? 'previous_revoked' as const
|
||||
: 'waiting_observation' as const,
|
||||
}));
|
||||
}
|
||||
return Object.freeze({
|
||||
observedAtMs: page.observedAtMs,
|
||||
outcomes: Object.freeze(outcomes),
|
||||
truncated: page.truncated,
|
||||
...(page.nextCursor === undefined
|
||||
? {}
|
||||
: { nextCursor: page.nextCursor }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStagedSecretPage(
|
||||
value: WorkerCredentialStagedSecretPage,
|
||||
): Readonly<WorkerCredentialStagedSecretPage> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const expected = [
|
||||
'stages',
|
||||
'truncated',
|
||||
...(value.nextCursor === undefined ? [] : ['nextCursor']),
|
||||
].sort();
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index]) ||
|
||||
!Array.isArray(value.stages) ||
|
||||
value.stages.length > MAX_WORKER_CREDENTIAL_STAGE_DISCARD_PAGE_SIZE ||
|
||||
typeof value.truncated !== 'boolean'
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
let stages: Readonly<WorkerCredentialDeliveryIntent>[];
|
||||
try {
|
||||
stages = value.stages.map((stage) =>
|
||||
normalizeWorkerCredentialDeliveryIntent(stage));
|
||||
} catch {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
for (let index = 1; index < stages.length; index += 1) {
|
||||
if (stages[index - 1]!.deliveryId >= stages[index]!.deliveryId) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
}
|
||||
const last = stages[stages.length - 1];
|
||||
if (
|
||||
value.truncated !== (value.nextCursor !== undefined) ||
|
||||
(value.nextCursor !== undefined &&
|
||||
(!last || value.nextCursor !== last.deliveryId))
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
stages: Object.freeze(stages),
|
||||
truncated: value.truncated,
|
||||
...(value.nextCursor === undefined
|
||||
? {}
|
||||
: { nextCursor: value.nextCursor }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerCredentialStageCleanupService(
|
||||
authority: WorkerCredentialDeliveryAdministrationRepository,
|
||||
deliveryAdapter: WorkerCredentialStagedSecretInventoryAdapter,
|
||||
): WorkerCredentialStageCleanupService {
|
||||
if (
|
||||
!authority ||
|
||||
typeof authority.authorizeStageDiscard !== 'function' ||
|
||||
typeof authority.markStageDiscarded !== 'function' ||
|
||||
typeof authority.listStageDiscardRecoveryPage !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential stage cleanup authority is invalid');
|
||||
}
|
||||
if (
|
||||
!deliveryAdapter ||
|
||||
typeof deliveryAdapter.inspect !== 'function' ||
|
||||
typeof deliveryAdapter.discard !== 'function' ||
|
||||
typeof deliveryAdapter.listStaged !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential stage cleanup adapter is invalid');
|
||||
}
|
||||
|
||||
const discard = async (
|
||||
record: Readonly<WorkerCredentialStageDiscardRecord>,
|
||||
staged: Readonly<WorkerCredentialDeliveryIntent> | null,
|
||||
): Promise<'discarded' | 'already_discarded'> => {
|
||||
if (!sameStage(record, staged ?? record)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
if (staged) {
|
||||
try {
|
||||
await deliveryAdapter.discard(staged);
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
}
|
||||
if (record.state === 'discarded') return 'already_discarded';
|
||||
let completed: Readonly<WorkerCredentialStageDiscardRecord>;
|
||||
try {
|
||||
completed = normalizeWorkerCredentialStageDiscardRecord(
|
||||
await authority.markStageDiscarded({
|
||||
deliveryId: record.deliveryId,
|
||||
expectedVersion: record.version,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (
|
||||
completed.state !== 'discarded' ||
|
||||
completed.authorizedAtMs !== record.authorizedAtMs ||
|
||||
!sameStage(completed, record)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
return 'discarded';
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async cleanupInventoryPage(
|
||||
options: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}> = {},
|
||||
) {
|
||||
let page: Readonly<WorkerCredentialStagedSecretPage>;
|
||||
try {
|
||||
page = normalizeStagedSecretPage(
|
||||
await deliveryAdapter.listStaged(options),
|
||||
);
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
const outcomes = [];
|
||||
for (const staged of page.stages) {
|
||||
let authorized: Readonly<WorkerCredentialStageDiscardRecord>;
|
||||
try {
|
||||
authorized = normalizeWorkerCredentialStageDiscardRecord(
|
||||
await authority.authorizeStageDiscard(staged),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
if (!sameStage(authorized, staged)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
outcomes.push(Object.freeze({
|
||||
deliveryId: staged.deliveryId,
|
||||
result: await discard(authorized, staged),
|
||||
}));
|
||||
}
|
||||
return Object.freeze({
|
||||
outcomes: Object.freeze(outcomes),
|
||||
truncated: page.truncated,
|
||||
...(page.nextCursor === undefined
|
||||
? {}
|
||||
: { nextCursor: page.nextCursor }),
|
||||
});
|
||||
},
|
||||
|
||||
async recoverAuthorizedPage(
|
||||
options: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}> = {},
|
||||
) {
|
||||
let page: Readonly<WorkerCredentialStageDiscardRecoveryPage>;
|
||||
try {
|
||||
page = normalizeWorkerCredentialStageDiscardRecoveryPage(
|
||||
await authority.listStageDiscardRecoveryPage(options),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialDeliveryConflictError) throw error;
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const outcomes = [];
|
||||
for (const authorized of page.discards) {
|
||||
let staged: Readonly<WorkerCredentialDeliveryIntent> | null;
|
||||
try {
|
||||
const inspected = await deliveryAdapter.inspect(authorized.deliveryId);
|
||||
staged = inspected
|
||||
? normalizeWorkerCredentialDeliveryIntent(inspected)
|
||||
: null;
|
||||
} catch (error) {
|
||||
mapDeliveryAdapterError(error);
|
||||
}
|
||||
if (staged && !sameStage(authorized, staged)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
outcomes.push(Object.freeze({
|
||||
deliveryId: authorized.deliveryId,
|
||||
result: await discard(authorized, staged),
|
||||
}));
|
||||
}
|
||||
return Object.freeze({
|
||||
observedAtMs: page.observedAtMs,
|
||||
outcomes: Object.freeze(outcomes),
|
||||
truncated: page.truncated,
|
||||
...(page.nextCursor === undefined
|
||||
? {}
|
||||
: { nextCursor: page.nextCursor }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** One-shot Worker credential executor CLI boundary. */
|
||||
import { runClusterWorkerCredentialExecutorProcess } from './workerCredentialExecutorProcess';
|
||||
|
||||
const USAGE = 'Usage: ql3-worker-credential-execute';
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-executor',
|
||||
event: 'execution_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 0) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_WORKER_CREDENTIAL_EXECUTOR_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runClusterWorkerCredentialExecutorProcess({
|
||||
environment: process.env,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
result.status === 'disabled'
|
||||
? {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-executor',
|
||||
event: 'execution_disabled',
|
||||
}
|
||||
: {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-executor',
|
||||
event: 'execution_completed',
|
||||
actionRef: result.command.actionRef,
|
||||
dispatchId: result.command.dispatchId,
|
||||
executionStatus: result.run.execution.status,
|
||||
deliveryStatus: result.run.result.status,
|
||||
tokenRequestUsed: result.run.tokenRequest !== null,
|
||||
},
|
||||
)}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
@@ -0,0 +1,640 @@
|
||||
/** One-shot Worker credential executor process composition boundary. */
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
|
||||
import type { OpenPostgresDatabase } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
} from '@qinglong/cluster-postgres/worker-credential-executor';
|
||||
|
||||
import {
|
||||
absoluteManagementEnvironmentFile,
|
||||
booleanManagementEnvironmentValue,
|
||||
boundedManagementEnvironmentValue,
|
||||
integerManagementEnvironmentValue,
|
||||
} from '../management-support/managementProcessSupport';
|
||||
import {
|
||||
runClusterWorkerCredentialExecution,
|
||||
type ClusterWorkerCredentialExecutionRun,
|
||||
type RunClusterWorkerCredentialExecutionOptions,
|
||||
} from './workerCredentialManagementExecutor';
|
||||
import type { WorkerCredentialKubernetesDeliveryAdapterOptions } from './workerCredentialKubernetesDelivery';
|
||||
import {
|
||||
createWorkerCredentialKubernetesKubeConfigTokenRequestSession,
|
||||
type WorkerCredentialKubernetesAuthorizationApi,
|
||||
type WorkerCredentialKubernetesTokenRequestSession,
|
||||
} from './workerCredentialKubernetesTokenRequest';
|
||||
|
||||
const COMMAND_MAX_BYTES = 16 * 1024;
|
||||
const PEPPER_MAX_BYTES = 256;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const SAFE_APPLICATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
|
||||
type KubernetesModule = typeof import('@kubernetes/client-node', {
|
||||
with: { 'resolution-mode': 'import' }
|
||||
});
|
||||
|
||||
export interface ClusterWorkerCredentialExecutorCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly auditEventId: string;
|
||||
}
|
||||
|
||||
export type ClusterWorkerCredentialExecutorProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterWorkerCredentialExecutorProcessConfig =
|
||||
| Readonly<{ enabled: false }>
|
||||
| Readonly<{
|
||||
enabled: true;
|
||||
profile: 'cluster-admin';
|
||||
commandFile: string;
|
||||
pepperFile: string;
|
||||
serviceAccountName: string;
|
||||
identitySecretName: string;
|
||||
delivery: WorkerCredentialKubernetesDeliveryAdapterOptions;
|
||||
database: Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ClusterWorkerCredentialExecutorProcessResult =
|
||||
| Readonly<{ status: 'disabled' }>
|
||||
| Readonly<{
|
||||
status: 'completed';
|
||||
command: Readonly<ClusterWorkerCredentialExecutorCommand>;
|
||||
run: Readonly<ClusterWorkerCredentialExecutionRun>;
|
||||
}>;
|
||||
|
||||
interface KubernetesExecutionAuthority {
|
||||
readonly session: WorkerCredentialKubernetesTokenRequestSession;
|
||||
confirmAuthorization(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface RunClusterWorkerCredentialExecutorProcessOptions {
|
||||
readonly environment: ClusterWorkerCredentialExecutorProcessEnvironment;
|
||||
readonly command?: Readonly<ClusterWorkerCredentialExecutorCommand>;
|
||||
readonly openDatabase?: OpenPostgresDatabase;
|
||||
readonly kubernetesAuthority?: KubernetesExecutionAuthority;
|
||||
readonly createKubernetesAuthority?: (
|
||||
config: Readonly<ClusterWorkerCredentialExecutorProcessConfig & { enabled: true }>,
|
||||
) => Promise<KubernetesExecutionAuthority>;
|
||||
readonly execute?: (
|
||||
options: RunClusterWorkerCredentialExecutionOptions,
|
||||
) => Promise<Readonly<ClusterWorkerCredentialExecutionRun>>;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class ClusterWorkerCredentialExecutorProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_WORKER_CREDENTIAL_EXECUTOR_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Worker credential executor process configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterWorkerCredentialExecutorProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function configFailure(
|
||||
message: string,
|
||||
): ClusterWorkerCredentialExecutorProcessConfigError {
|
||||
return new ClusterWorkerCredentialExecutorProcessConfigError(message);
|
||||
}
|
||||
|
||||
function boundedValue(
|
||||
environment: ClusterWorkerCredentialExecutorProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
return boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
maximumLength,
|
||||
configFailure,
|
||||
required,
|
||||
);
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
environment: ClusterWorkerCredentialExecutorProcessEnvironment,
|
||||
name: string,
|
||||
): boolean {
|
||||
return booleanManagementEnvironmentValue(environment, name, configFailure);
|
||||
}
|
||||
|
||||
function integerValue(
|
||||
environment: ClusterWorkerCredentialExecutorProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
return integerManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
fallback,
|
||||
minimum,
|
||||
maximum,
|
||||
configFailure,
|
||||
);
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string, pattern = ID): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
throw configFailure(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadConnection(
|
||||
environment: ClusterWorkerCredentialExecutorProcessEnvironment,
|
||||
): Readonly<{
|
||||
connection: PostgresConnectionOptions;
|
||||
pool: PostgresPoolOptions;
|
||||
}> {
|
||||
let connection: PostgresConnectionOptions;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_URL',
|
||||
host: 'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_HOST',
|
||||
port: 'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_PORT',
|
||||
database: 'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_DATABASE',
|
||||
user: 'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_USER',
|
||||
password: 'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw configFailure(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PostgreSQL Worker credential executor connection is invalid',
|
||||
);
|
||||
}
|
||||
const mode =
|
||||
environment.QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_TLS_MODE ??
|
||||
'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_ALLOW_INSECURE',
|
||||
)
|
||||
) {
|
||||
throw configFailure(
|
||||
'disabling Worker credential executor PostgreSQL TLS requires explicit opt-in',
|
||||
);
|
||||
}
|
||||
const servername = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_TLS_SERVERNAME',
|
||||
253,
|
||||
);
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw configFailure(
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_TLS_CA_FILE',
|
||||
4_096,
|
||||
);
|
||||
if (mode === 'disable' && caFile !== undefined) {
|
||||
throw configFailure('PostgreSQL CA file cannot be used when TLS is disabled');
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile !== undefined) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(caFile);
|
||||
} catch {
|
||||
throw configFailure('PostgreSQL CA file is invalid');
|
||||
}
|
||||
}
|
||||
const applicationName =
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_APPLICATION_NAME',
|
||||
63,
|
||||
) ?? 'qinglong3-worker-credential-executor';
|
||||
if (!SAFE_APPLICATION_NAME.test(applicationName)) {
|
||||
throw configFailure('PostgreSQL application name is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? { mode: 'disable' as const }
|
||||
: {
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
},
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName,
|
||||
maxConnections: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_MAX_CONNECTIONS',
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
),
|
||||
connectionTimeoutMs: integerValue(
|
||||
environment,
|
||||
'QL3_POSTGRES_WORKER_CREDENTIAL_EXECUTOR_CONNECTION_TIMEOUT_MS',
|
||||
5_000,
|
||||
100,
|
||||
60_000,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadClusterWorkerCredentialExecutorProcessConfig(
|
||||
environment: ClusterWorkerCredentialExecutorProcessEnvironment,
|
||||
): Readonly<ClusterWorkerCredentialExecutorProcessConfig> {
|
||||
if (!environment || typeof environment !== 'object') {
|
||||
throw configFailure('environment is invalid');
|
||||
}
|
||||
if (!booleanValue(environment, 'QL3_WORKER_CREDENTIAL_EXECUTOR_ENABLED')) {
|
||||
return Object.freeze({ enabled: false as const });
|
||||
}
|
||||
if (environment.QL3_PROFILE !== 'cluster-admin') {
|
||||
throw configFailure('QL3_PROFILE must be cluster-admin when executor is enabled');
|
||||
}
|
||||
const delivery = Object.freeze({
|
||||
clusterIdentity: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_CLUSTER_IDENTITY',
|
||||
128,
|
||||
true,
|
||||
),
|
||||
'cluster identity',
|
||||
),
|
||||
stageNamespace: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_STAGE_NAMESPACE',
|
||||
63,
|
||||
true,
|
||||
),
|
||||
'stage namespace',
|
||||
/^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/,
|
||||
),
|
||||
namespace: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_TARGET_NAMESPACE',
|
||||
63,
|
||||
true,
|
||||
),
|
||||
'target namespace',
|
||||
/^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/,
|
||||
),
|
||||
targetSecretName: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_TARGET_SECRET',
|
||||
253,
|
||||
true,
|
||||
),
|
||||
'target Secret',
|
||||
/^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/,
|
||||
),
|
||||
targetDeploymentName: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_TARGET_DEPLOYMENT',
|
||||
253,
|
||||
true,
|
||||
),
|
||||
'target Deployment',
|
||||
/^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/,
|
||||
),
|
||||
targetDataKey: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_TARGET_DATA_KEY',
|
||||
253,
|
||||
true,
|
||||
),
|
||||
'target data key',
|
||||
/^[A-Za-z0-9._-]{1,253}$/,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
enabled: true as const,
|
||||
profile: 'cluster-admin' as const,
|
||||
commandFile: absoluteManagementEnvironmentFile(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_COMMAND_FILE',
|
||||
configFailure,
|
||||
),
|
||||
pepperFile: absoluteManagementEnvironmentFile(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_PEPPER_FILE',
|
||||
configFailure,
|
||||
),
|
||||
serviceAccountName: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_DELIVERY_SERVICE_ACCOUNT',
|
||||
63,
|
||||
true,
|
||||
),
|
||||
'delivery ServiceAccount',
|
||||
/^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/,
|
||||
),
|
||||
identitySecretName: identifier(
|
||||
boundedValue(
|
||||
environment,
|
||||
'QL3_WORKER_CREDENTIAL_EXECUTOR_IDENTITY_SECRET',
|
||||
253,
|
||||
true,
|
||||
),
|
||||
'identity Secret',
|
||||
/^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/,
|
||||
),
|
||||
delivery,
|
||||
database: loadConnection(environment),
|
||||
});
|
||||
}
|
||||
|
||||
async function readBoundedFile(
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
privateMaterial: boolean,
|
||||
): Promise<Buffer> {
|
||||
const handle = await open(filePath, constants.O_RDONLY);
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1 ||
|
||||
before.size > maximumBytes ||
|
||||
(before.mode & 0o022) !== 0 ||
|
||||
(privateMaterial && (before.mode & 0o007) !== 0)
|
||||
) {
|
||||
throw configFailure('authority file permissions or size are invalid');
|
||||
}
|
||||
const bytes = Buffer.alloc(before.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const result = await handle.read(
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (result.bytesRead === 0) break;
|
||||
offset += result.bytesRead;
|
||||
}
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
offset !== before.size ||
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size ||
|
||||
before.mtimeMs !== after.mtimeMs ||
|
||||
before.ctimeMs !== after.ctimeMs
|
||||
) {
|
||||
throw configFailure('authority file changed while being read');
|
||||
}
|
||||
return bytes.subarray(0, offset);
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<ClusterWorkerCredentialExecutorCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configFailure('command must be an object');
|
||||
}
|
||||
const command = value as Record<string, unknown>;
|
||||
const keys = [
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'auditEventId',
|
||||
'consumptionId',
|
||||
'dispatchId',
|
||||
'schemaVersion',
|
||||
];
|
||||
if (
|
||||
Object.keys(command).sort().join('\0') !== keys.sort().join('\0') ||
|
||||
command.schemaVersion !== 1
|
||||
) {
|
||||
throw configFailure('command shape is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
actionRef: identifier(command.actionRef, 'actionRef', ACTION_REF),
|
||||
approvalRequestId: identifier(command.approvalRequestId, 'approvalRequestId'),
|
||||
consumptionId: identifier(command.consumptionId, 'consumptionId'),
|
||||
dispatchId: identifier(command.dispatchId, 'dispatchId'),
|
||||
auditEventId: identifier(command.auditEventId, 'auditEventId'),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCommand(
|
||||
filePath: string,
|
||||
): Promise<Readonly<ClusterWorkerCredentialExecutorCommand>> {
|
||||
const bytes = await readBoundedFile(filePath, COMMAND_MAX_BYTES, false);
|
||||
try {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
return normalizeCommand(JSON.parse(text));
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterWorkerCredentialExecutorProcessConfigError) {
|
||||
throw error;
|
||||
}
|
||||
throw configFailure('command file is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPepper(filePath: string): Promise<string> {
|
||||
const bytes = await readBoundedFile(filePath, PEPPER_MAX_BYTES, true);
|
||||
try {
|
||||
const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
if (
|
||||
CONTROL_PATTERN.test(value) ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(value) ||
|
||||
Buffer.from(value, 'base64url').length !== 32 ||
|
||||
Buffer.from(value, 'base64url').toString('base64url') !== value
|
||||
) {
|
||||
throw configFailure('Worker credential pepper is invalid');
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDefaultKubernetesAuthority(
|
||||
config: Readonly<ClusterWorkerCredentialExecutorProcessConfig & { enabled: true }>,
|
||||
): Promise<KubernetesExecutionAuthority> {
|
||||
const kubernetes = (await import('@kubernetes/client-node')) as KubernetesModule;
|
||||
const issuer = new kubernetes.KubeConfig();
|
||||
issuer.loadFromCluster();
|
||||
const authorization = issuer.makeApiClient(
|
||||
kubernetes.AuthorizationV1Api,
|
||||
) as unknown as WorkerCredentialKubernetesAuthorizationApi;
|
||||
const confirmAuthorization = async (): Promise<void> => {
|
||||
const result = await authorization.createSelfSubjectAccessReview({
|
||||
body: {
|
||||
apiVersion: 'authorization.k8s.io/v1',
|
||||
kind: 'SelfSubjectAccessReview',
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
namespace: config.delivery.stageNamespace,
|
||||
verb: 'create',
|
||||
resource: 'serviceaccounts',
|
||||
subresource: 'token',
|
||||
name: config.serviceAccountName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (result?.status?.allowed !== true || result.status.denied === true) {
|
||||
throw configFailure('executor Kubernetes authorization is unavailable');
|
||||
}
|
||||
};
|
||||
return Object.freeze({
|
||||
session: createWorkerCredentialKubernetesKubeConfigTokenRequestSession(
|
||||
issuer,
|
||||
kubernetes,
|
||||
{
|
||||
serviceAccountName: config.serviceAccountName,
|
||||
identitySecretName: config.identitySecretName,
|
||||
delivery: config.delivery,
|
||||
},
|
||||
),
|
||||
confirmAuthorization,
|
||||
dispose() {
|
||||
for (const user of issuer.getUsers()) {
|
||||
const mutable = user as {
|
||||
token?: string;
|
||||
certData?: string;
|
||||
keyData?: string;
|
||||
};
|
||||
mutable.token = '';
|
||||
mutable.certData = '';
|
||||
mutable.keyData = '';
|
||||
}
|
||||
issuer.setCurrentContext('disposed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClusterWorkerCredentialExecutorProcess(
|
||||
options: RunClusterWorkerCredentialExecutorProcessOptions,
|
||||
): Promise<Readonly<ClusterWorkerCredentialExecutorProcessResult>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'environment',
|
||||
'command',
|
||||
'openDatabase',
|
||||
'kubernetesAuthority',
|
||||
'createKubernetesAuthority',
|
||||
'execute',
|
||||
'now',
|
||||
].includes(key),
|
||||
) ||
|
||||
!options.environment ||
|
||||
typeof options.environment !== 'object' ||
|
||||
(options.openDatabase !== undefined &&
|
||||
typeof options.openDatabase !== 'function') ||
|
||||
(options.createKubernetesAuthority !== undefined &&
|
||||
typeof options.createKubernetesAuthority !== 'function') ||
|
||||
(options.execute !== undefined && typeof options.execute !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw configFailure('options are invalid');
|
||||
}
|
||||
const config = loadClusterWorkerCredentialExecutorProcessConfig(
|
||||
options.environment,
|
||||
);
|
||||
if (!config.enabled) return Object.freeze({ status: 'disabled' as const });
|
||||
const command = normalizeCommand(
|
||||
options.command ?? (await loadCommand(config.commandFile)),
|
||||
);
|
||||
const pepper = await loadPepper(config.pepperFile);
|
||||
const openDatabase =
|
||||
options.openDatabase ??
|
||||
createPostgresDatabaseOpener({
|
||||
role: 'worker-credential-executor',
|
||||
connection: config.database.connection,
|
||||
pool: config.database.pool,
|
||||
onPoolError() {},
|
||||
});
|
||||
const authority =
|
||||
options.kubernetesAuthority ??
|
||||
(await (
|
||||
options.createKubernetesAuthority ?? createDefaultKubernetesAuthority
|
||||
)(config));
|
||||
if (
|
||||
!authority ||
|
||||
typeof authority !== 'object' ||
|
||||
!authority.session ||
|
||||
typeof authority.session.withDelivery !== 'function' ||
|
||||
typeof authority.confirmAuthorization !== 'function' ||
|
||||
typeof authority.dispose !== 'function'
|
||||
) {
|
||||
throw configFailure('Kubernetes execution authority is invalid');
|
||||
}
|
||||
let failure: unknown;
|
||||
try {
|
||||
const run = await (options.execute ?? runClusterWorkerCredentialExecution)({
|
||||
openDatabase,
|
||||
tokenRequestSession: authority.session,
|
||||
workerCredentialPepper: pepper,
|
||||
actionRef: command.actionRef,
|
||||
approvalRequestId: command.approvalRequestId,
|
||||
consumptionId: command.consumptionId,
|
||||
dispatchId: command.dispatchId,
|
||||
auditEventId: command.auditEventId,
|
||||
confirmAuthorization: authority.confirmAuthorization,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
return Object.freeze({ status: 'completed' as const, command, run });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
authority.dispose();
|
||||
} catch (disposeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, disposeError],
|
||||
'Worker credential executor failed and Kubernetes authority did not dispose',
|
||||
);
|
||||
}
|
||||
throw disposeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
/** POSIX file-backed Worker credential delivery adapter boundary. */
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
WorkerCredentialDeliveryConflictError,
|
||||
WorkerCredentialDeliveryUnavailableError,
|
||||
normalizeWorkerCredentialDeliveryIntent,
|
||||
normalizeWorkerCredentialDeliveryRecord,
|
||||
workerCredentialDeliveryTokenDigest,
|
||||
type WorkerCredentialDeliveryIntent,
|
||||
type WorkerCredentialDeliveryRecord,
|
||||
} from '@qinglong/runtime-core/worker-credential-delivery';
|
||||
import type {
|
||||
WorkerCredentialStagedSecretInventoryAdapter,
|
||||
WorkerCredentialStagedSecretPage,
|
||||
} from './workerCredentialDelivery';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_STAGE_BYTES = 8192;
|
||||
const MAX_STAGE_HEADER_BYTES = 4096;
|
||||
const MAX_TOKEN_BYTES = 256;
|
||||
export const MAX_WORKER_CREDENTIAL_FILE_STAGES = 128;
|
||||
export const MAX_WORKER_CREDENTIAL_FILE_STAGE_PAGE_SIZE = 64;
|
||||
const STAGE_MAGIC = Buffer.from(
|
||||
'qinglong/worker-credential-file-stage@v1\n',
|
||||
'ascii',
|
||||
);
|
||||
const TARGET_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/worker-credential-file-target@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const PUBLICATION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/worker-credential-file-publication@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const STAGE_NAME =
|
||||
/^([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.stage$/;
|
||||
const STAGE_TEMP_NAME =
|
||||
/^\.([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.[0-9a-f-]{36}\.tmp$/;
|
||||
const TOKEN =
|
||||
/^ql3w_([A-Za-z0-9][A-Za-z0-9._:-]{0,63})_([A-Za-z0-9_-]{43})$/;
|
||||
const TARGET_LOCK_NAME = '.ql3-worker-credential-delivery.lock';
|
||||
|
||||
export interface WorkerCredentialFileDeliveryAdapterOptions {
|
||||
/** Dedicated private 0700 directory containing bounded durable stages. */
|
||||
readonly stageDirectory: string;
|
||||
/** Atomically replaceable 0600 ql3w token file read by worker-runtime. */
|
||||
readonly targetTokenFile: string;
|
||||
}
|
||||
|
||||
export type WorkerCredentialFileStagePage = WorkerCredentialStagedSecretPage;
|
||||
|
||||
interface ParsedToken {
|
||||
readonly credentialId: string;
|
||||
readonly tokenDigest: string;
|
||||
}
|
||||
|
||||
interface StagedSecret {
|
||||
readonly intent: Readonly<WorkerCredentialDeliveryIntent>;
|
||||
readonly token: Buffer;
|
||||
}
|
||||
|
||||
interface OwnedTargetLock {
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT',
|
||||
);
|
||||
}
|
||||
|
||||
function isExists(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST',
|
||||
);
|
||||
}
|
||||
|
||||
function boundedAbsolutePath(value: string, name: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new TypeError(`${name} must be a bounded canonical absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class PrivateDirectoryAuthority {
|
||||
readonly directory: string;
|
||||
readonly uid: number;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
|
||||
constructor(directory: string, name: string) {
|
||||
this.directory = boundedAbsolutePath(directory, name);
|
||||
if (typeof process.getuid !== 'function') {
|
||||
throw new TypeError(`${name} requires a POSIX process identity`);
|
||||
}
|
||||
this.uid = process.getuid();
|
||||
const stat = fs.lstatSync(this.directory, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== this.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new TypeError(`${name} must be a private owned real directory`);
|
||||
}
|
||||
this.device = stat.dev;
|
||||
this.inode = stat.ino;
|
||||
}
|
||||
|
||||
verify(): void {
|
||||
const stat = fs.lstatSync(this.directory, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== this.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
stat.dev !== this.device ||
|
||||
stat.ino !== this.inode
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
sync(): void {
|
||||
this.verify();
|
||||
const descriptor = fs.openSync(this.directory, 'r');
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPrivateFile(
|
||||
authority: PrivateDirectoryAuthority,
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
): Buffer {
|
||||
authority.verify();
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== authority.uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(maximumBytes)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const bytes = fs.readFileSync(descriptor);
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > maximumBytes) {
|
||||
bytes.fill(0);
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
return bytes;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function parseToken(bytes: Buffer): ParsedToken {
|
||||
let visible = bytes;
|
||||
if (visible[visible.byteLength - 1] === 0x0a) {
|
||||
visible = visible.subarray(0, -1);
|
||||
}
|
||||
if (
|
||||
visible.byteLength < 1 ||
|
||||
visible.byteLength > MAX_TOKEN_BYTES ||
|
||||
visible.includes(0x0a) ||
|
||||
visible.some((byte) => byte > 0x7f)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const match = TOKEN.exec(visible.toString('ascii'));
|
||||
if (!match) throw new WorkerCredentialDeliveryUnavailableError();
|
||||
return Object.freeze({
|
||||
credentialId: match[1]!,
|
||||
tokenDigest: workerCredentialDeliveryTokenDigest(visible),
|
||||
});
|
||||
}
|
||||
|
||||
function sameIntent(
|
||||
left: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
right: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
): boolean {
|
||||
return (
|
||||
left.deliveryId === right.deliveryId &&
|
||||
left.workerId === right.workerId &&
|
||||
left.credentialId === right.credentialId &&
|
||||
left.credentialVersion === right.credentialVersion &&
|
||||
left.previousCredentialId === right.previousCredentialId &&
|
||||
left.secretDigest === right.secretDigest &&
|
||||
left.tokenDigest === right.tokenDigest &&
|
||||
left.deploymentTargetDigest === right.deploymentTargetDigest &&
|
||||
left.deploymentGeneration === right.deploymentGeneration &&
|
||||
left.stagedAtMs === right.stagedAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function recordMatchesIntent(
|
||||
record: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
intent: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
): boolean {
|
||||
return sameIntent(record, intent);
|
||||
}
|
||||
|
||||
function preserveDomainError(error: unknown): never {
|
||||
if (
|
||||
error instanceof WorkerCredentialDeliveryConflictError ||
|
||||
error instanceof WorkerCredentialDeliveryUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete short-lived POSIX adapter for Docker bind mounts, systemd services,
|
||||
* and controlled shared volumes. It owns no timer, socket, database or cache.
|
||||
*/
|
||||
export class WorkerCredentialFileDeliveryAdapter
|
||||
implements WorkerCredentialStagedSecretInventoryAdapter {
|
||||
readonly deploymentTargetDigest: string;
|
||||
private readonly stages: PrivateDirectoryAuthority;
|
||||
private readonly targetParent: PrivateDirectoryAuthority;
|
||||
private readonly targetTokenFile: string;
|
||||
private readonly targetLockFile: string;
|
||||
|
||||
constructor(options: WorkerCredentialFileDeliveryAdapterOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).length !== 2 ||
|
||||
!Object.prototype.hasOwnProperty.call(options, 'stageDirectory') ||
|
||||
!Object.prototype.hasOwnProperty.call(options, 'targetTokenFile')
|
||||
) {
|
||||
throw new TypeError('Worker credential file delivery options are invalid');
|
||||
}
|
||||
this.stages = new PrivateDirectoryAuthority(
|
||||
options.stageDirectory,
|
||||
'Worker credential stage directory',
|
||||
);
|
||||
this.targetTokenFile = boundedAbsolutePath(
|
||||
options.targetTokenFile,
|
||||
'Worker credential target token file',
|
||||
);
|
||||
const targetName = path.basename(this.targetTokenFile);
|
||||
if (targetName === '.' || targetName === '..' || targetName.startsWith('.ql3w-')) {
|
||||
throw new TypeError('Worker credential target token name is invalid');
|
||||
}
|
||||
this.targetParent = new PrivateDirectoryAuthority(
|
||||
path.dirname(this.targetTokenFile),
|
||||
'Worker credential target directory',
|
||||
);
|
||||
if (
|
||||
this.stages.device === this.targetParent.device &&
|
||||
this.stages.inode === this.targetParent.inode
|
||||
) {
|
||||
throw new TypeError('Worker credential stage and target directories must differ');
|
||||
}
|
||||
this.targetLockFile = path.join(
|
||||
this.targetParent.directory,
|
||||
TARGET_LOCK_NAME,
|
||||
);
|
||||
this.deploymentTargetDigest = createHash('sha256')
|
||||
.update(TARGET_DIGEST_DOMAIN)
|
||||
.update(this.targetTokenFile, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(String(this.targetParent.uid), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(this.targetParent.device.toString(), 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(this.targetParent.inode.toString(), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private stagePath(deliveryId: string): string {
|
||||
if (!UUID_V4.test(deliveryId)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
return path.join(this.stages.directory, `${deliveryId}.stage`);
|
||||
}
|
||||
|
||||
private verifyStageCapacity(): void {
|
||||
this.stages.verify();
|
||||
const directory = fs.opendirSync(this.stages.directory);
|
||||
let count = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = directory.readSync();
|
||||
if (!entry) break;
|
||||
count += 1;
|
||||
if (
|
||||
count >= MAX_WORKER_CREDENTIAL_FILE_STAGES ||
|
||||
!STAGE_NAME.test(entry.name)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
directory.closeSync();
|
||||
}
|
||||
}
|
||||
|
||||
private stableStageNames(): readonly string[] {
|
||||
this.stages.verify();
|
||||
const directory = fs.opendirSync(this.stages.directory);
|
||||
const names: string[] = [];
|
||||
let entries = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = directory.readSync();
|
||||
if (!entry) break;
|
||||
entries += 1;
|
||||
if (entries > MAX_WORKER_CREDENTIAL_FILE_STAGES) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const match = STAGE_NAME.exec(entry.name);
|
||||
if (match) {
|
||||
names.push(match[1]!);
|
||||
continue;
|
||||
}
|
||||
if (STAGE_TEMP_NAME.test(entry.name)) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
} finally {
|
||||
directory.closeSync();
|
||||
}
|
||||
return Object.freeze(names.sort());
|
||||
}
|
||||
|
||||
private normalizeIntent(
|
||||
value: WorkerCredentialDeliveryIntent,
|
||||
): Readonly<WorkerCredentialDeliveryIntent> {
|
||||
const intent = normalizeWorkerCredentialDeliveryIntent(value);
|
||||
if (intent.deploymentTargetDigest !== this.deploymentTargetDigest) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
private readStage(deliveryId: string): StagedSecret {
|
||||
const material = readPrivateFile(
|
||||
this.stages,
|
||||
this.stagePath(deliveryId),
|
||||
MAX_STAGE_BYTES,
|
||||
);
|
||||
let token: Buffer | undefined;
|
||||
try {
|
||||
if (!material.subarray(0, STAGE_MAGIC.byteLength).equals(STAGE_MAGIC)) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const headerEnd = material.indexOf(0x0a, STAGE_MAGIC.byteLength);
|
||||
const headerBytes = headerEnd - STAGE_MAGIC.byteLength;
|
||||
if (
|
||||
headerEnd < STAGE_MAGIC.byteLength ||
|
||||
headerBytes < 2 ||
|
||||
headerBytes > MAX_STAGE_HEADER_BYTES ||
|
||||
headerEnd + 1 >= material.byteLength
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
const intent = this.normalizeIntent(
|
||||
JSON.parse(
|
||||
material
|
||||
.subarray(STAGE_MAGIC.byteLength, headerEnd)
|
||||
.toString('utf8'),
|
||||
),
|
||||
);
|
||||
if (intent.deliveryId !== deliveryId) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
token = Buffer.from(material.subarray(headerEnd + 1));
|
||||
const parsed = parseToken(token);
|
||||
if (
|
||||
parsed.credentialId !== intent.credentialId ||
|
||||
parsed.tokenDigest !== intent.tokenDigest
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
const result = Object.freeze({ intent, token });
|
||||
token = undefined;
|
||||
return result;
|
||||
} finally {
|
||||
token?.fill(0);
|
||||
material.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private optionalStage(deliveryId: string): StagedSecret | null {
|
||||
try {
|
||||
return this.readStage(deliveryId);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private optionalTarget(): ParsedToken | null {
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
material = readPrivateFile(
|
||||
this.targetParent,
|
||||
this.targetTokenFile,
|
||||
MAX_TOKEN_BYTES,
|
||||
);
|
||||
return parseToken(material);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private publicationDigest(
|
||||
delivery: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(PUBLICATION_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify({
|
||||
deliveryId: delivery.deliveryId,
|
||||
workerId: delivery.workerId,
|
||||
credentialId: delivery.credentialId,
|
||||
credentialVersion: delivery.credentialVersion,
|
||||
previousCredentialId: delivery.previousCredentialId,
|
||||
tokenDigest: delivery.tokenDigest,
|
||||
deploymentTargetDigest: delivery.deploymentTargetDigest,
|
||||
deploymentGeneration: delivery.deploymentGeneration,
|
||||
}), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private assertTargetFence(
|
||||
delivery: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
target: ParsedToken | null,
|
||||
): 'published' | 'replace' {
|
||||
if (
|
||||
target?.credentialId === delivery.credentialId &&
|
||||
target.tokenDigest === delivery.tokenDigest
|
||||
) {
|
||||
return 'published';
|
||||
}
|
||||
if (
|
||||
target?.credentialId === delivery.credentialId ||
|
||||
(delivery.previousCredentialId === null && target !== null) ||
|
||||
(delivery.previousCredentialId !== null &&
|
||||
target?.credentialId !== delivery.previousCredentialId)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
return 'replace';
|
||||
}
|
||||
|
||||
private acquireTargetLock(deliveryId: string): OwnedTargetLock {
|
||||
this.targetParent.verify();
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
this.targetLockFile,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify({ deliveryId })}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
const stat = fs.fstatSync(descriptor, { bigint: true });
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
this.targetParent.sync();
|
||||
return Object.freeze({ device: stat.dev, inode: stat.ino });
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
if (isExists(error)) throw new WorkerCredentialDeliveryUnavailableError();
|
||||
preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseTargetLock(lock: OwnedTargetLock): void {
|
||||
try {
|
||||
const stat = fs.lstatSync(this.targetLockFile, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== this.targetParent.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o600 ||
|
||||
stat.dev !== lock.device ||
|
||||
stat.ino !== lock.inode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(this.targetLockFile);
|
||||
this.targetParent.sync();
|
||||
} catch {
|
||||
// A stale lock fails future rotations closed and requires explicit repair.
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
deliveryId: string,
|
||||
): Promise<Readonly<WorkerCredentialDeliveryIntent> | null> {
|
||||
let staged: StagedSecret | null = null;
|
||||
try {
|
||||
staged = this.optionalStage(deliveryId);
|
||||
return staged?.intent ?? null;
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
} finally {
|
||||
staged?.token.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async listStaged(
|
||||
options: Readonly<{ afterDeliveryId?: string; limit?: number }> = {},
|
||||
): Promise<Readonly<WorkerCredentialFileStagePage>> {
|
||||
try {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => key !== 'afterDeliveryId' && key !== 'limit',
|
||||
)
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
const limit = options.limit ?? 16;
|
||||
if (
|
||||
!Number.isInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_WORKER_CREDENTIAL_FILE_STAGE_PAGE_SIZE ||
|
||||
(options.afterDeliveryId !== undefined &&
|
||||
!UUID_V4.test(options.afterDeliveryId))
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
const names = this.stableStageNames().filter(
|
||||
(name) =>
|
||||
options.afterDeliveryId === undefined ||
|
||||
name > options.afterDeliveryId,
|
||||
);
|
||||
const selected = names.slice(0, limit + 1);
|
||||
const stages: Readonly<WorkerCredentialDeliveryIntent>[] = [];
|
||||
for (const deliveryId of selected.slice(0, limit)) {
|
||||
const staged = this.readStage(deliveryId);
|
||||
try {
|
||||
stages.push(staged.intent);
|
||||
} finally {
|
||||
staged.token.fill(0);
|
||||
}
|
||||
}
|
||||
const truncated = selected.length > limit;
|
||||
return Object.freeze({
|
||||
stages: Object.freeze(stages),
|
||||
truncated,
|
||||
...(truncated
|
||||
? { nextCursor: stages[stages.length - 1]!.deliveryId }
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async stage(
|
||||
value: Readonly<WorkerCredentialDeliveryIntent>,
|
||||
token: Buffer,
|
||||
): Promise<void> {
|
||||
let serialized: Buffer | undefined;
|
||||
let descriptor: number | undefined;
|
||||
const intent = this.normalizeIntent(value);
|
||||
const parsed = Buffer.isBuffer(token) ? parseToken(token) : null;
|
||||
if (
|
||||
!parsed ||
|
||||
parsed.credentialId !== intent.credentialId ||
|
||||
parsed.tokenDigest !== intent.tokenDigest
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
const targetPath = this.stagePath(intent.deliveryId);
|
||||
const temporaryPath = path.join(
|
||||
this.stages.directory,
|
||||
`.${intent.deliveryId}.${randomUUID()}.tmp`,
|
||||
);
|
||||
try {
|
||||
const existing = this.optionalStage(intent.deliveryId);
|
||||
if (existing) {
|
||||
try {
|
||||
if (!sameIntent(existing.intent, intent)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
existing.token.fill(0);
|
||||
}
|
||||
}
|
||||
this.verifyStageCapacity();
|
||||
const header = Buffer.from(JSON.stringify(intent), 'utf8');
|
||||
if (header.byteLength > MAX_STAGE_HEADER_BYTES) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
serialized = Buffer.concat([
|
||||
STAGE_MAGIC,
|
||||
header,
|
||||
Buffer.from('\n', 'ascii'),
|
||||
token,
|
||||
]);
|
||||
header.fill(0);
|
||||
if (serialized.byteLength > MAX_STAGE_BYTES) {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, serialized);
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
try {
|
||||
fs.linkSync(temporaryPath, targetPath);
|
||||
this.stages.sync();
|
||||
} catch (error) {
|
||||
if (!isExists(error)) throw error;
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
this.stages.sync();
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
const winner = this.readStage(intent.deliveryId);
|
||||
try {
|
||||
if (!sameIntent(winner.intent, intent)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
} finally {
|
||||
winner.token.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
} finally {
|
||||
serialized?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
this.stages.sync();
|
||||
} catch {
|
||||
// The no-replace stage, if published, remains authoritative.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<WorkerCredentialDeliveryRecord>,
|
||||
): Promise<Readonly<{ publicationDigest: string }>> {
|
||||
const delivery = normalizeWorkerCredentialDeliveryRecord(value);
|
||||
if (
|
||||
delivery.state !== 'credential_committed' ||
|
||||
delivery.version !== 1 ||
|
||||
delivery.deploymentTargetDigest !== this.deploymentTargetDigest
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
let staged: StagedSecret | null = null;
|
||||
let temporaryPath: string | undefined;
|
||||
let descriptor: number | undefined;
|
||||
let targetLock: OwnedTargetLock | undefined;
|
||||
try {
|
||||
staged = this.optionalStage(delivery.deliveryId);
|
||||
if (!staged || !recordMatchesIntent(delivery, staged.intent)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
if (this.assertTargetFence(delivery, this.optionalTarget()) === 'published') {
|
||||
return Object.freeze({
|
||||
publicationDigest: this.publicationDigest(delivery),
|
||||
});
|
||||
}
|
||||
targetLock = this.acquireTargetLock(delivery.deliveryId);
|
||||
if (this.assertTargetFence(delivery, this.optionalTarget()) === 'published') {
|
||||
return Object.freeze({
|
||||
publicationDigest: this.publicationDigest(delivery),
|
||||
});
|
||||
}
|
||||
temporaryPath = path.join(
|
||||
this.targetParent.directory,
|
||||
`.ql3w-${delivery.deliveryId}-${randomUUID()}.tmp`,
|
||||
);
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, staged.token);
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
fs.renameSync(temporaryPath, this.targetTokenFile);
|
||||
temporaryPath = undefined;
|
||||
this.targetParent.sync();
|
||||
if (this.assertTargetFence(delivery, this.optionalTarget()) !== 'published') {
|
||||
throw new WorkerCredentialDeliveryUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
publicationDigest: this.publicationDigest(delivery),
|
||||
});
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
} finally {
|
||||
staged?.token.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
if (temporaryPath) {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
this.targetParent.sync();
|
||||
} catch {
|
||||
// The target was never published from this temporary path.
|
||||
}
|
||||
}
|
||||
if (targetLock) this.releaseTargetLock(targetLock);
|
||||
}
|
||||
}
|
||||
|
||||
async discard(value: Readonly<WorkerCredentialDeliveryIntent>): Promise<void> {
|
||||
const intent = this.normalizeIntent(value);
|
||||
let staged: StagedSecret | null = null;
|
||||
try {
|
||||
staged = this.optionalStage(intent.deliveryId);
|
||||
if (!staged) return;
|
||||
if (!sameIntent(staged.intent, intent)) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
const target = this.optionalTarget();
|
||||
if (
|
||||
target?.credentialId === intent.credentialId &&
|
||||
target.tokenDigest === intent.tokenDigest
|
||||
) {
|
||||
throw new WorkerCredentialDeliveryConflictError();
|
||||
}
|
||||
fs.unlinkSync(this.stagePath(intent.deliveryId));
|
||||
this.stages.sync();
|
||||
} catch (error) {
|
||||
preserveDomainError(error);
|
||||
} finally {
|
||||
staged?.token.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1112
File diff suppressed because it is too large
Load Diff
+549
@@ -0,0 +1,549 @@
|
||||
/** Short-lived Kubernetes TokenRequest delivery session boundary. */
|
||||
import {
|
||||
WorkerCredentialKubernetesDeliveryAdapter,
|
||||
type WorkerCredentialKubernetesDeliveryAdapterOptions,
|
||||
type WorkerCredentialKubernetesDeploymentApi,
|
||||
type WorkerCredentialKubernetesSecretApi,
|
||||
} from './workerCredentialKubernetesDelivery';
|
||||
|
||||
export const WORKER_CREDENTIAL_KUBERNETES_TOKEN_REQUEST_SECONDS = 600;
|
||||
|
||||
const MIN_USEFUL_TOKEN_SECONDS = 30;
|
||||
const MAX_TOKEN_BYTES = 16 * 1024;
|
||||
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||
const DNS_SUBDOMAIN =
|
||||
/^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$/;
|
||||
const SAFE_JWT_ALGORITHM = /^[A-Za-z0-9_-]{2,32}$/;
|
||||
const JWT = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type KubernetesModule = typeof import('@kubernetes/client-node', {
|
||||
with: { 'resolution-mode': 'import' }
|
||||
});
|
||||
type KubernetesConfig = InstanceType<KubernetesModule['KubeConfig']>;
|
||||
|
||||
interface TokenRequestResponse {
|
||||
apiVersion?: string;
|
||||
kind?: string;
|
||||
status?: {
|
||||
token?: string;
|
||||
expirationTimestamp?: Date | string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AccessReviewAttributes {
|
||||
readonly namespace?: string;
|
||||
readonly verb: string;
|
||||
readonly group?: string;
|
||||
readonly resource: string;
|
||||
readonly subresource?: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialKubernetesTokenRequestApi {
|
||||
createNamespacedServiceAccountToken(request: Readonly<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
body: Readonly<{
|
||||
apiVersion: 'authentication.k8s.io/v1';
|
||||
kind: 'TokenRequest';
|
||||
spec: Readonly<{ expirationSeconds: 600 }>;
|
||||
}>;
|
||||
}>): Promise<TokenRequestResponse>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialKubernetesAuthorizationApi {
|
||||
createSelfSubjectAccessReview(request: Readonly<{
|
||||
body: Readonly<{
|
||||
apiVersion: 'authorization.k8s.io/v1';
|
||||
kind: 'SelfSubjectAccessReview';
|
||||
spec: Readonly<{
|
||||
resourceAttributes: AccessReviewAttributes;
|
||||
}>;
|
||||
}>;
|
||||
}>): Promise<Readonly<{
|
||||
status?: Readonly<{
|
||||
allowed?: boolean;
|
||||
denied?: boolean;
|
||||
reason?: string;
|
||||
}>;
|
||||
}>>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialKubernetesRestrictedClients {
|
||||
readonly secrets: WorkerCredentialKubernetesSecretApi;
|
||||
readonly deployments: WorkerCredentialKubernetesDeploymentApi;
|
||||
readonly authorization: WorkerCredentialKubernetesAuthorizationApi;
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialKubernetesTokenRequestSessionOptions {
|
||||
readonly serviceAccountName: string;
|
||||
readonly identitySecretName: string;
|
||||
readonly delivery: WorkerCredentialKubernetesDeliveryAdapterOptions;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialKubernetesTokenRequestEvidence {
|
||||
readonly tokenLifetimeSeconds: number;
|
||||
readonly issuerAllowedChecks: number;
|
||||
readonly issuerDeniedChecks: number;
|
||||
readonly allowedChecks: number;
|
||||
readonly deniedChecks: number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialKubernetesTokenRequestContext {
|
||||
readonly delivery: WorkerCredentialKubernetesDeliveryAdapter;
|
||||
readonly evidence: WorkerCredentialKubernetesTokenRequestEvidence;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialKubernetesTokenRequestSession {
|
||||
withDelivery<T>(
|
||||
operation: (
|
||||
context: Readonly<WorkerCredentialKubernetesTokenRequestContext>,
|
||||
) => Promise<T>,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export class WorkerCredentialKubernetesTokenRequestUnavailableError
|
||||
extends Error {
|
||||
readonly code = 'QL3_WORKER_CREDENTIAL_KUBERNETES_TOKEN_REQUEST_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Worker credential Kubernetes TokenRequest session is unavailable');
|
||||
this.name = 'WorkerCredentialKubernetesTokenRequestUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const VALIDATION_SECRET_API: WorkerCredentialKubernetesSecretApi = {
|
||||
async readNamespacedSecret() { throw new Error('validation only'); },
|
||||
async createNamespacedSecret() { throw new Error('validation only'); },
|
||||
async replaceNamespacedSecret() { throw new Error('validation only'); },
|
||||
async deleteNamespacedSecret() { throw new Error('validation only'); },
|
||||
async listNamespacedSecret() { throw new Error('validation only'); },
|
||||
};
|
||||
|
||||
const VALIDATION_DEPLOYMENT_API: WorkerCredentialKubernetesDeploymentApi = {
|
||||
async readNamespacedDeployment() { throw new Error('validation only'); },
|
||||
async replaceNamespacedDeployment() { throw new Error('validation only'); },
|
||||
};
|
||||
|
||||
function jsonObject(value: unknown): JsonObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function decodeJwtSegment(value: string): JsonObject {
|
||||
try {
|
||||
const bytes = Buffer.from(value, 'base64url');
|
||||
if (bytes.toString('base64url') !== value) {
|
||||
throw new Error('non-canonical base64url');
|
||||
}
|
||||
return jsonObject(JSON.parse(bytes.toString('utf8')));
|
||||
} catch (error) {
|
||||
if (error instanceof WorkerCredentialKubernetesTokenRequestUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function tokenEvidence(
|
||||
response: TokenRequestResponse,
|
||||
namespace: string,
|
||||
serviceAccountName: string,
|
||||
observedAtMs: number,
|
||||
): Readonly<{ token: string; lifetimeSeconds: number }> {
|
||||
if (
|
||||
response?.apiVersion !== 'authentication.k8s.io/v1' ||
|
||||
response.kind !== 'TokenRequest' ||
|
||||
typeof response.status?.token !== 'string' ||
|
||||
response.status.token.length < 1 ||
|
||||
Buffer.byteLength(response.status.token, 'utf8') > MAX_TOKEN_BYTES
|
||||
) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
const match = JWT.exec(response.status.token);
|
||||
if (!match) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
const header = decodeJwtSegment(match[1]!);
|
||||
const claims = decodeJwtSegment(match[2]!);
|
||||
if (
|
||||
typeof header.alg !== 'string' ||
|
||||
!SAFE_JWT_ALGORITHM.test(header.alg) ||
|
||||
header.alg.toLowerCase() === 'none' ||
|
||||
claims.sub !== `system:serviceaccount:${namespace}:${serviceAccountName}` ||
|
||||
!Number.isSafeInteger(claims.iat) ||
|
||||
!Number.isSafeInteger(claims.exp)
|
||||
) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
const issuedAtSeconds = claims.iat as number;
|
||||
const expiresAtSeconds = claims.exp as number;
|
||||
const lifetimeSeconds = expiresAtSeconds - issuedAtSeconds;
|
||||
const expiration = response.status.expirationTimestamp;
|
||||
const expirationMs = expiration instanceof Date
|
||||
? expiration.getTime()
|
||||
: typeof expiration === 'string'
|
||||
? Date.parse(expiration)
|
||||
: Number.NaN;
|
||||
if (
|
||||
lifetimeSeconds < MIN_USEFUL_TOKEN_SECONDS ||
|
||||
lifetimeSeconds > WORKER_CREDENTIAL_KUBERNETES_TOKEN_REQUEST_SECONDS ||
|
||||
!Number.isSafeInteger(expirationMs) ||
|
||||
expirationMs !== expiresAtSeconds * 1_000 ||
|
||||
expirationMs - observedAtMs < MIN_USEFUL_TOKEN_SECONDS * 1_000
|
||||
) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
token: response.status.token,
|
||||
lifetimeSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
function accessMatrix(
|
||||
delivery: WorkerCredentialKubernetesDeliveryAdapterOptions,
|
||||
identitySecretName: string,
|
||||
serviceAccountName: string,
|
||||
): Readonly<{
|
||||
allowed: readonly AccessReviewAttributes[];
|
||||
denied: readonly AccessReviewAttributes[];
|
||||
}> {
|
||||
const allowed: readonly AccessReviewAttributes[] = [
|
||||
{ namespace: delivery.stageNamespace, verb: 'get', resource: 'secrets', name: 'stage' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'list', resource: 'secrets' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'create', resource: 'secrets' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'delete', resource: 'secrets', name: 'stage' },
|
||||
{ namespace: delivery.namespace, verb: 'get', resource: 'secrets', name: delivery.targetSecretName },
|
||||
{ namespace: delivery.namespace, verb: 'update', resource: 'secrets', name: delivery.targetSecretName },
|
||||
{ namespace: delivery.namespace, verb: 'get', group: 'apps', resource: 'deployments', name: delivery.targetDeploymentName },
|
||||
{ namespace: delivery.namespace, verb: 'update', group: 'apps', resource: 'deployments', name: delivery.targetDeploymentName },
|
||||
];
|
||||
const denied: readonly AccessReviewAttributes[] = [
|
||||
{ namespace: delivery.stageNamespace, verb: 'update', resource: 'secrets', name: 'stage' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'patch', resource: 'secrets', name: 'stage' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'watch', resource: 'secrets' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'get', resource: 'configmaps', name: 'any' },
|
||||
{ namespace: delivery.namespace, verb: 'list', resource: 'secrets' },
|
||||
{ namespace: delivery.namespace, verb: 'get', resource: 'secrets', name: identitySecretName },
|
||||
{ namespace: delivery.namespace, verb: 'create', resource: 'secrets' },
|
||||
{ namespace: delivery.namespace, verb: 'delete', resource: 'secrets', name: delivery.targetSecretName },
|
||||
{ namespace: delivery.namespace, verb: 'patch', resource: 'secrets', name: delivery.targetSecretName },
|
||||
{ namespace: delivery.namespace, verb: 'watch', resource: 'secrets' },
|
||||
{ namespace: delivery.namespace, verb: 'list', group: 'apps', resource: 'deployments' },
|
||||
{ namespace: delivery.namespace, verb: 'get', group: 'apps', resource: 'deployments', name: 'other' },
|
||||
{ namespace: delivery.namespace, verb: 'patch', group: 'apps', resource: 'deployments', name: delivery.targetDeploymentName },
|
||||
{ namespace: delivery.namespace, verb: 'delete', group: 'apps', resource: 'deployments', name: delivery.targetDeploymentName },
|
||||
{ namespace: delivery.namespace, verb: 'get', resource: 'pods' },
|
||||
{ namespace: delivery.namespace, verb: 'list', resource: 'pods' },
|
||||
{ namespace: delivery.namespace, verb: 'create', resource: 'pods', subresource: 'exec' },
|
||||
{ namespace: delivery.namespace, verb: 'delete', resource: 'pods', name: 'any' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'create', resource: 'serviceaccounts', subresource: 'token', name: serviceAccountName },
|
||||
{ verb: 'get', resource: 'namespaces', name: delivery.namespace },
|
||||
];
|
||||
return Object.freeze({ allowed, denied });
|
||||
}
|
||||
|
||||
function issuerAccessMatrix(
|
||||
delivery: WorkerCredentialKubernetesDeliveryAdapterOptions,
|
||||
serviceAccountName: string,
|
||||
): Readonly<{
|
||||
allowed: readonly AccessReviewAttributes[];
|
||||
denied: readonly AccessReviewAttributes[];
|
||||
}> {
|
||||
return Object.freeze({
|
||||
allowed: [{
|
||||
namespace: delivery.stageNamespace,
|
||||
verb: 'create',
|
||||
resource: 'serviceaccounts',
|
||||
subresource: 'token',
|
||||
name: serviceAccountName,
|
||||
}],
|
||||
denied: [
|
||||
{
|
||||
namespace: delivery.stageNamespace,
|
||||
verb: 'create',
|
||||
resource: 'serviceaccounts',
|
||||
subresource: 'token',
|
||||
name: 'other',
|
||||
},
|
||||
{ namespace: delivery.stageNamespace, verb: 'get', resource: 'secrets', name: 'any' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'list', resource: 'secrets' },
|
||||
{ namespace: delivery.stageNamespace, verb: 'create', resource: 'secrets' },
|
||||
{ namespace: delivery.namespace, verb: 'get', resource: 'secrets', name: delivery.targetSecretName },
|
||||
{ namespace: delivery.namespace, verb: 'get', group: 'apps', resource: 'deployments', name: delivery.targetDeploymentName },
|
||||
{ namespace: delivery.namespace, verb: 'get', resource: 'pods' },
|
||||
{ verb: 'get', resource: 'namespaces', name: delivery.namespace },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAccess(
|
||||
authorization: WorkerCredentialKubernetesAuthorizationApi,
|
||||
expected: boolean,
|
||||
checks: readonly AccessReviewAttributes[],
|
||||
): Promise<void> {
|
||||
for (const attributes of checks) {
|
||||
let result;
|
||||
try {
|
||||
result = await authorization.createSelfSubjectAccessReview({
|
||||
body: {
|
||||
apiVersion: 'authorization.k8s.io/v1',
|
||||
kind: 'SelfSubjectAccessReview',
|
||||
spec: { resourceAttributes: attributes },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
if (result?.status?.allowed !== expected) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkerCredentialKubernetesTokenRequestSession(
|
||||
tokenRequests: WorkerCredentialKubernetesTokenRequestApi,
|
||||
issuerAuthorization: WorkerCredentialKubernetesAuthorizationApi,
|
||||
createRestrictedClients: (
|
||||
token: string,
|
||||
) => WorkerCredentialKubernetesRestrictedClients,
|
||||
options: WorkerCredentialKubernetesTokenRequestSessionOptions,
|
||||
): WorkerCredentialKubernetesTokenRequestSession {
|
||||
if (
|
||||
!tokenRequests ||
|
||||
typeof tokenRequests.createNamespacedServiceAccountToken !== 'function' ||
|
||||
!issuerAuthorization ||
|
||||
typeof issuerAuthorization.createSelfSubjectAccessReview !== 'function' ||
|
||||
typeof createRestrictedClients !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) =>
|
||||
!['serviceAccountName', 'identitySecretName', 'delivery', 'now'].includes(key)) ||
|
||||
typeof options.serviceAccountName !== 'string' ||
|
||||
!DNS_LABEL.test(options.serviceAccountName) ||
|
||||
typeof options.identitySecretName !== 'string' ||
|
||||
!DNS_SUBDOMAIN.test(options.identitySecretName) ||
|
||||
Buffer.byteLength(options.identitySecretName, 'utf8') > 253 ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Worker credential Kubernetes TokenRequest options are invalid');
|
||||
}
|
||||
const validation = new WorkerCredentialKubernetesDeliveryAdapter(
|
||||
VALIDATION_SECRET_API,
|
||||
VALIDATION_DEPLOYMENT_API,
|
||||
options.delivery,
|
||||
);
|
||||
const now = options.now ?? Date.now;
|
||||
const matrix = accessMatrix(
|
||||
options.delivery,
|
||||
options.identitySecretName,
|
||||
options.serviceAccountName,
|
||||
);
|
||||
const issuerMatrix = issuerAccessMatrix(
|
||||
options.delivery,
|
||||
options.serviceAccountName,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
async withDelivery<T>(
|
||||
operation: (
|
||||
context: Readonly<WorkerCredentialKubernetesTokenRequestContext>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (typeof operation !== 'function') {
|
||||
throw new TypeError('Worker credential Kubernetes operation is invalid');
|
||||
}
|
||||
const requestedAtMs = now();
|
||||
if (!Number.isSafeInteger(requestedAtMs) || requestedAtMs < 0) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
let response: TokenRequestResponse | undefined;
|
||||
let issuedToken = '';
|
||||
let clients: WorkerCredentialKubernetesRestrictedClients | undefined;
|
||||
try {
|
||||
await assertAccess(issuerAuthorization, true, issuerMatrix.allowed);
|
||||
await assertAccess(issuerAuthorization, false, issuerMatrix.denied);
|
||||
try {
|
||||
response = await tokenRequests.createNamespacedServiceAccountToken({
|
||||
name: options.serviceAccountName,
|
||||
namespace: options.delivery.stageNamespace,
|
||||
body: {
|
||||
apiVersion: 'authentication.k8s.io/v1',
|
||||
kind: 'TokenRequest',
|
||||
spec: {
|
||||
expirationSeconds:
|
||||
WORKER_CREDENTIAL_KUBERNETES_TOKEN_REQUEST_SECONDS,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
const observedAtMs = now();
|
||||
if (
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < requestedAtMs
|
||||
) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
const evidence = tokenEvidence(
|
||||
response,
|
||||
options.delivery.stageNamespace,
|
||||
options.serviceAccountName,
|
||||
observedAtMs,
|
||||
);
|
||||
issuedToken = evidence.token;
|
||||
try {
|
||||
clients = createRestrictedClients(issuedToken);
|
||||
} catch {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
} finally {
|
||||
if (response.status) response.status.token = '';
|
||||
issuedToken = '';
|
||||
}
|
||||
if (
|
||||
!clients ||
|
||||
!clients.authorization ||
|
||||
typeof clients.authorization.createSelfSubjectAccessReview !== 'function' ||
|
||||
typeof clients.dispose !== 'function'
|
||||
) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
const delivery = new WorkerCredentialKubernetesDeliveryAdapter(
|
||||
clients.secrets,
|
||||
clients.deployments,
|
||||
options.delivery,
|
||||
);
|
||||
if (delivery.deploymentTargetDigest !== validation.deploymentTargetDigest) {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
}
|
||||
await assertAccess(clients.authorization, true, matrix.allowed);
|
||||
await assertAccess(clients.authorization, false, matrix.denied);
|
||||
return await operation(Object.freeze({
|
||||
delivery,
|
||||
evidence: Object.freeze({
|
||||
tokenLifetimeSeconds: evidence.lifetimeSeconds,
|
||||
issuerAllowedChecks: issuerMatrix.allowed.length,
|
||||
issuerDeniedChecks: issuerMatrix.denied.length,
|
||||
allowedChecks: matrix.allowed.length,
|
||||
deniedChecks: matrix.denied.length,
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
if (response?.status) response.status.token = '';
|
||||
issuedToken = '';
|
||||
try {
|
||||
await clients?.dispose();
|
||||
} catch {
|
||||
throw new WorkerCredentialKubernetesTokenRequestUnavailableError();
|
||||
} finally {
|
||||
clients = undefined;
|
||||
response = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerCredentialKubernetesKubeConfigTokenRequestSession(
|
||||
issuerKubeConfig: KubernetesConfig,
|
||||
kubernetes: KubernetesModule,
|
||||
options: WorkerCredentialKubernetesTokenRequestSessionOptions,
|
||||
): WorkerCredentialKubernetesTokenRequestSession {
|
||||
if (
|
||||
!issuerKubeConfig ||
|
||||
!kubernetes ||
|
||||
typeof kubernetes !== 'object' ||
|
||||
typeof kubernetes.KubeConfig !== 'function' ||
|
||||
typeof kubernetes.CoreV1Api !== 'function' ||
|
||||
typeof kubernetes.AppsV1Api !== 'function' ||
|
||||
typeof kubernetes.AuthorizationV1Api !== 'function' ||
|
||||
typeof issuerKubeConfig.getCurrentCluster !== 'function' ||
|
||||
typeof issuerKubeConfig.makeApiClient !== 'function'
|
||||
) {
|
||||
throw new TypeError('Worker credential Kubernetes issuer kubeconfig is invalid');
|
||||
}
|
||||
const cluster = issuerKubeConfig.getCurrentCluster();
|
||||
let server: URL;
|
||||
try {
|
||||
server = new URL(cluster?.server ?? '');
|
||||
} catch {
|
||||
throw new TypeError('Worker credential Kubernetes issuer cluster is invalid');
|
||||
}
|
||||
if (
|
||||
!cluster ||
|
||||
server.protocol !== 'https:' ||
|
||||
server.username !== '' ||
|
||||
server.password !== '' ||
|
||||
server.hash !== '' ||
|
||||
cluster.skipTLSVerify === true ||
|
||||
(typeof cluster.caData !== 'string' && typeof cluster.caFile !== 'string')
|
||||
) {
|
||||
throw new TypeError('Worker credential Kubernetes issuer cluster is invalid');
|
||||
}
|
||||
const tokenRequestClient = issuerKubeConfig.makeApiClient(
|
||||
kubernetes.CoreV1Api,
|
||||
);
|
||||
const issuerAuthorization = issuerKubeConfig.makeApiClient(
|
||||
kubernetes.AuthorizationV1Api,
|
||||
) as unknown as WorkerCredentialKubernetesAuthorizationApi;
|
||||
const tokenRequests: WorkerCredentialKubernetesTokenRequestApi = {
|
||||
async createNamespacedServiceAccountToken(request) {
|
||||
return await tokenRequestClient.createNamespacedServiceAccountToken({
|
||||
...request,
|
||||
body: {
|
||||
...request.body,
|
||||
spec: {
|
||||
audiences: [],
|
||||
expirationSeconds: request.body.spec.expirationSeconds,
|
||||
},
|
||||
},
|
||||
}) as unknown as TokenRequestResponse;
|
||||
},
|
||||
};
|
||||
return createWorkerCredentialKubernetesTokenRequestSession(
|
||||
tokenRequests,
|
||||
issuerAuthorization,
|
||||
(token) => {
|
||||
const restricted = new kubernetes.KubeConfig();
|
||||
restricted.loadFromOptions({
|
||||
clusters: [{ ...cluster, name: 'ql3-worker-credential-delivery' }],
|
||||
users: [{ name: 'ql3-worker-credential-delivery', token }],
|
||||
contexts: [{
|
||||
name: 'ql3-worker-credential-delivery',
|
||||
cluster: 'ql3-worker-credential-delivery',
|
||||
user: 'ql3-worker-credential-delivery',
|
||||
namespace: options.delivery.stageNamespace,
|
||||
}],
|
||||
currentContext: 'ql3-worker-credential-delivery',
|
||||
});
|
||||
let active = true;
|
||||
return {
|
||||
secrets: restricted.makeApiClient(
|
||||
kubernetes.CoreV1Api,
|
||||
) as unknown as WorkerCredentialKubernetesSecretApi,
|
||||
deployments: restricted.makeApiClient(
|
||||
kubernetes.AppsV1Api,
|
||||
) as unknown as WorkerCredentialKubernetesDeploymentApi,
|
||||
authorization: restricted.makeApiClient(
|
||||
kubernetes.AuthorizationV1Api,
|
||||
) as unknown as WorkerCredentialKubernetesAuthorizationApi,
|
||||
dispose() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
for (const user of restricted.getUsers()) {
|
||||
(user as { token?: string }).token = '';
|
||||
}
|
||||
restricted.setCurrentContext('disposed');
|
||||
},
|
||||
};
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/** Worker credential management client boundary. */
|
||||
import {
|
||||
executeClusterAuthenticatedManagementClient,
|
||||
type ClusterAuthenticatedManagementClientResult,
|
||||
type ClusterPluginPackageManagementClientConnectionOptions,
|
||||
type ClusterPluginPackageManagementClientPaths,
|
||||
} from '../management-support/pluginPackageManagementClient';
|
||||
import {
|
||||
normalizeClusterWorkerCredentialManagementCommand,
|
||||
type ClusterWorkerCredentialManagementCommand,
|
||||
type ClusterWorkerCredentialManagementTransportResult,
|
||||
} from './management-server/workerCredentialManagementTransport';
|
||||
|
||||
const MANAGEMENT_PATH = '/api/v3/worker-credentials/management';
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export type ClusterWorkerCredentialManagementClientPaths =
|
||||
ClusterPluginPackageManagementClientPaths;
|
||||
export type ClusterWorkerCredentialManagementClientConnectionOptions =
|
||||
ClusterPluginPackageManagementClientConnectionOptions;
|
||||
export type ClusterWorkerCredentialManagementClientResult =
|
||||
ClusterAuthenticatedManagementClientResult<ClusterWorkerCredentialManagementTransportResult>;
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('Worker credential management response is invalid');
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function boundedScalar(value: unknown): void {
|
||||
if (
|
||||
value !== null &&
|
||||
!(typeof value === 'boolean') &&
|
||||
!(typeof value === 'number' && Number.isSafeInteger(value)) &&
|
||||
!(
|
||||
typeof value === 'string' &&
|
||||
value.length <= 2_048 &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function subject(value: unknown): void {
|
||||
const record = exactRecord(value, ['type', 'id']);
|
||||
if (record.type !== 'user') invalid();
|
||||
boundedScalar(record.id);
|
||||
}
|
||||
|
||||
function plan(value: unknown): void {
|
||||
const record = exactRecord(value, [
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'action',
|
||||
'target',
|
||||
'requestedBy',
|
||||
'plannedAtMs',
|
||||
'expiresAtMs',
|
||||
'previewDigest',
|
||||
'planDigest',
|
||||
]);
|
||||
const target = exactRecord(record.target, [
|
||||
'deliveryId',
|
||||
'workerId',
|
||||
'credentialId',
|
||||
'previousCredentialId',
|
||||
'credentialNotBeforeAtMs',
|
||||
'credentialExpiresAtMs',
|
||||
'deploymentTargetDigest',
|
||||
'deploymentGeneration',
|
||||
]);
|
||||
for (const entry of Object.values(record)) {
|
||||
if (entry !== record.target && entry !== record.requestedBy)
|
||||
boundedScalar(entry);
|
||||
}
|
||||
for (const entry of Object.values(target)) boundedScalar(entry);
|
||||
subject(record.requestedBy);
|
||||
if (!['issue', 'rotate'].includes(String(record.action))) invalid();
|
||||
}
|
||||
|
||||
function approval(value: unknown): void {
|
||||
const record = exactRecord(value, [
|
||||
'id',
|
||||
'projectId',
|
||||
'version',
|
||||
'state',
|
||||
'risk',
|
||||
'decisionMode',
|
||||
'requestedBy',
|
||||
'requestedAtMs',
|
||||
'expiresAtMs',
|
||||
'decision',
|
||||
'decisionReasonCode',
|
||||
'decidedBy',
|
||||
'decidedAtMs',
|
||||
'dispatchId',
|
||||
'consumedAtMs',
|
||||
'actionType',
|
||||
'actionRef',
|
||||
'actionDigest',
|
||||
'previewDigest',
|
||||
]);
|
||||
for (const entry of Object.values(record)) {
|
||||
if (entry !== record.requestedBy && entry !== record.decidedBy)
|
||||
boundedScalar(entry);
|
||||
}
|
||||
subject(record.requestedBy);
|
||||
if (record.decidedBy !== null) subject(record.decidedBy);
|
||||
if (
|
||||
!/^worker_credential\.delivery\.(?:issue|rotate)$/.test(
|
||||
String(record.actionType),
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
export function validateClusterWorkerCredentialManagementClientResult(
|
||||
value: unknown,
|
||||
command: Readonly<ClusterWorkerCredentialManagementCommand>,
|
||||
): Readonly<ClusterWorkerCredentialManagementTransportResult> {
|
||||
const operation = command.operation;
|
||||
const keys =
|
||||
operation === 'worker-credential.plan'
|
||||
? ['schemaVersion', 'operation', 'status', 'plan']
|
||||
: operation === 'worker-credential.propose'
|
||||
? ['schemaVersion', 'operation', 'approvalStatus', 'plan', 'approval']
|
||||
: operation === 'worker-credential.decide'
|
||||
? ['schemaVersion', 'operation', 'status', 'approval']
|
||||
: ['schemaVersion', 'operation', 'plan', 'approval', 'stale'];
|
||||
const record = exactRecord(value, keys);
|
||||
if (record.schemaVersion !== 1 || record.operation !== operation) invalid();
|
||||
if (operation === 'worker-credential.plan') {
|
||||
if (!['created', 'existing'].includes(String(record.status))) invalid();
|
||||
plan(record.plan);
|
||||
} else if (operation === 'worker-credential.propose') {
|
||||
if (!['created', 'existing'].includes(String(record.approvalStatus)))
|
||||
invalid();
|
||||
plan(record.plan);
|
||||
approval(record.approval);
|
||||
} else if (operation === 'worker-credential.decide') {
|
||||
if (!['decided', 'existing'].includes(String(record.status))) invalid();
|
||||
approval(record.approval);
|
||||
} else {
|
||||
if (typeof record.stale !== 'boolean') invalid();
|
||||
if (record.plan !== null) plan(record.plan);
|
||||
if (record.approval !== null) approval(record.approval);
|
||||
}
|
||||
return Object.freeze(
|
||||
record as unknown as ClusterWorkerCredentialManagementTransportResult,
|
||||
);
|
||||
}
|
||||
|
||||
const PROTOCOL = Object.freeze({
|
||||
managementPath: MANAGEMENT_PATH,
|
||||
clientCertificate: 'required' as const,
|
||||
normalizeCommand: normalizeClusterWorkerCredentialManagementCommand,
|
||||
validateResult: validateClusterWorkerCredentialManagementClientResult,
|
||||
});
|
||||
|
||||
export async function executeClusterWorkerCredentialManagementClient(
|
||||
paths: ClusterWorkerCredentialManagementClientPaths,
|
||||
connectionOptions?: ClusterWorkerCredentialManagementClientConnectionOptions,
|
||||
): Promise<Readonly<ClusterWorkerCredentialManagementClientResult>> {
|
||||
return executeClusterAuthenticatedManagementClient(
|
||||
paths,
|
||||
PROTOCOL,
|
||||
connectionOptions,
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** One-shot Worker credential management client CLI boundary. */
|
||||
import { ClusterPluginPackageManagementClientRemoteError } from '../management-support/pluginPackageManagementClient';
|
||||
import { executeClusterWorkerCredentialManagementClient } from './workerCredentialManagementClient';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-worker-credential-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
|
||||
|
||||
function parseArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<{
|
||||
configFile: string;
|
||||
commandFile: string;
|
||||
assertionFile: string;
|
||||
}> | null {
|
||||
if (argv.length !== 3) return null;
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(config|command|assertion)=(\/.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) return null;
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('config') ||
|
||||
!values.has('command') ||
|
||||
!values.has('assertion')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
configFile: values.get('config')!,
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
});
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management-client',
|
||||
event: 'command_failed',
|
||||
code:
|
||||
typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? candidate.code
|
||||
: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_FAILED',
|
||||
...(error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||
? {
|
||||
statusCode: error.statusCode,
|
||||
responseCode: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
...(error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { retryAfterSeconds: error.retryAfterSeconds }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const paths = parseArguments(argv);
|
||||
if (!paths) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management-client',
|
||||
event: 'usage_invalid',
|
||||
code: 'QL3_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_USAGE_INVALID',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await executeClusterWorkerCredentialManagementClient(paths);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-worker-credential-management-client',
|
||||
event: 'command_completed',
|
||||
requestId: result.requestId,
|
||||
result: result.result,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void run(process.argv.slice(2));
|
||||
+546
@@ -0,0 +1,546 @@
|
||||
/** Approved Worker credential management execution boundary. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
PostgresApprovedActionExecutionRepository,
|
||||
PostgresApprovalRequestRepository,
|
||||
PostgresProjectPolicyRepository,
|
||||
PostgresWorkerCredentialAdministrationRepository,
|
||||
PostgresWorkerCredentialManagementPlanReader,
|
||||
assertPostgresWorkerCredentialExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/worker-credential-executor';
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovedActionBinding,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import type { ApprovedActionExecutionRecord } from '@qinglong/runtime-core/approved-action-execution';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecurityPrincipal,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
normalizeWorkerCredentialManagementPlan,
|
||||
type WorkerCredentialManagementPlan,
|
||||
} from '@qinglong/runtime-core/worker-credential-management-plan';
|
||||
import {
|
||||
createRecoverableWorkerCredentialIssuer,
|
||||
type RecoverableWorkerCredentialIssueResult,
|
||||
} from './workerCredentialDelivery';
|
||||
import type {
|
||||
WorkerCredentialKubernetesTokenRequestEvidence,
|
||||
WorkerCredentialKubernetesTokenRequestSession,
|
||||
} from './workerCredentialKubernetesTokenRequest';
|
||||
import {
|
||||
WorkerCredentialManagementConflictError,
|
||||
WorkerCredentialManagementRequestError,
|
||||
WorkerCredentialManagementUnavailableError,
|
||||
} from './management-server/workerCredentialManagement';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const EXECUTOR_SUBJECT = Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'cluster_worker_credential_executor',
|
||||
});
|
||||
const EXECUTOR_AUTHENTICATION_ID = 'cluster_worker_credential_executor_v1';
|
||||
const EXECUTOR_PRINCIPAL_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const EXECUTION_LEASE_DURATION_MS = 10 * 60 * 1000;
|
||||
const EXECUTION_OWNER = 'cluster_worker_credential_executor';
|
||||
const EXECUTION_RESULT_CODE = 'worker_credential_published';
|
||||
|
||||
export interface RunClusterWorkerCredentialExecutionOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly tokenRequestSession: WorkerCredentialKubernetesTokenRequestSession;
|
||||
readonly workerCredentialPepper: string;
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
readonly now?: () => number;
|
||||
readonly randomBytes?: (size: number) => Buffer;
|
||||
}
|
||||
|
||||
export interface ClusterWorkerCredentialExecutionRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly approval: Readonly<ApprovedActionDispatchRecord>;
|
||||
readonly execution: Readonly<ApprovedActionExecutionRecord>;
|
||||
readonly result: Readonly<RecoverableWorkerCredentialIssueResult>;
|
||||
readonly tokenRequest: Readonly<WorkerCredentialKubernetesTokenRequestEvidence> | null;
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function exactOptions(value: RunClusterWorkerCredentialExecutionOptions): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerCredentialManagementRequestError(
|
||||
'execution options must be an object',
|
||||
);
|
||||
}
|
||||
const allowed = new Set([
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'auditEventId',
|
||||
'confirmAuthorization',
|
||||
'consumptionId',
|
||||
'dispatchId',
|
||||
'now',
|
||||
'openDatabase',
|
||||
'randomBytes',
|
||||
'tokenRequestSession',
|
||||
'workerCredentialPepper',
|
||||
]);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new WorkerCredentialManagementRequestError(
|
||||
'execution options shape is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new WorkerCredentialManagementRequestError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new WorkerCredentialManagementRequestError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerCredentialManagementUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function binding(
|
||||
plan: Readonly<WorkerCredentialManagementPlan>,
|
||||
): Readonly<ApprovedActionBinding> {
|
||||
return Object.freeze({
|
||||
permission: 'worker.manage',
|
||||
actionType: `worker_credential.delivery.${plan.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: plan.planDigest,
|
||||
previewDigest: plan.previewDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
requestId: string,
|
||||
projectId: string,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId: 'approval.consume',
|
||||
projectId,
|
||||
subject: EXECUTOR_SUBJECT,
|
||||
authenticationId: EXECUTOR_AUTHENTICATION_ID,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['worker_credential_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function executorPrincipal(nowMs: number): Readonly<SecurityPrincipal> {
|
||||
return Object.freeze({
|
||||
subject: EXECUTOR_SUBJECT,
|
||||
authenticationId: EXECUTOR_AUTHENTICATION_ID,
|
||||
authenticatedAtMs: nowMs,
|
||||
expiresAtMs: nowMs + EXECUTOR_PRINCIPAL_LIFETIME_MS,
|
||||
assurance: 'service' as const,
|
||||
});
|
||||
}
|
||||
|
||||
function executionResultDigest(
|
||||
plan: Readonly<WorkerCredentialManagementPlan>,
|
||||
result: Readonly<RecoverableWorkerCredentialIssueResult>,
|
||||
): string {
|
||||
const delivery = result.delivery;
|
||||
if (
|
||||
!delivery ||
|
||||
(delivery.state !== 'published' &&
|
||||
delivery.state !== 'observed' &&
|
||||
delivery.state !== 'previous_revoked') ||
|
||||
delivery.deliveryId !== plan.target.deliveryId ||
|
||||
delivery.workerId !== plan.target.workerId ||
|
||||
delivery.credentialId !== plan.target.credentialId ||
|
||||
delivery.previousCredentialId !== plan.target.previousCredentialId ||
|
||||
delivery.deploymentTargetDigest !== plan.target.deploymentTargetDigest ||
|
||||
delivery.deploymentGeneration !== plan.target.deploymentGeneration ||
|
||||
typeof delivery.publicationDigest !== 'string'
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'credential delivery does not match approved execution',
|
||||
);
|
||||
}
|
||||
return createHash('sha256')
|
||||
.update('qinglong/worker-credential-execution-result@v1\0', 'utf8')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
deliveryId: delivery.deliveryId,
|
||||
credentialId: delivery.credentialId,
|
||||
workerId: delivery.workerId,
|
||||
deploymentGeneration: delivery.deploymentGeneration,
|
||||
publicationDigest: delivery.publicationDigest,
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async function assertPredecessor(
|
||||
pool: PostgresPool,
|
||||
plan: Readonly<WorkerCredentialManagementPlan>,
|
||||
observedAtMs: number,
|
||||
): Promise<void> {
|
||||
if (plan.action === 'issue') return;
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT state, worker_id AS "workerId", expires_at_ms AS "expiresAtMs"
|
||||
FROM "ql3"."worker_credentials"
|
||||
WHERE credential_id = $1
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[plan.target.previousCredentialId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
const expiresAtMs =
|
||||
typeof row?.expiresAtMs === 'number'
|
||||
? row.expiresAtMs
|
||||
: typeof row?.expiresAtMs === 'string' && /^\d+$/.test(row.expiresAtMs)
|
||||
? Number(row.expiresAtMs)
|
||||
: Number.NaN;
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
row?.state !== 'active' ||
|
||||
row.workerId !== plan.target.workerId ||
|
||||
!Number.isSafeInteger(expiresAtMs) ||
|
||||
expiresAtMs <= observedAtMs
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'rotation predecessor is not active for the target Worker',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDatabase(
|
||||
database: PostgresDatabaseResource | undefined,
|
||||
failure: unknown,
|
||||
): Promise<void> {
|
||||
if (!database) {
|
||||
if (failure !== undefined) throw failure;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Worker credential execution failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
}
|
||||
|
||||
export async function runClusterWorkerCredentialExecution(
|
||||
options: RunClusterWorkerCredentialExecutionOptions,
|
||||
): Promise<Readonly<ClusterWorkerCredentialExecutionRun>> {
|
||||
exactOptions(options);
|
||||
if (
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
!options.tokenRequestSession ||
|
||||
typeof options.tokenRequestSession.withDelivery !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function' ||
|
||||
typeof options.workerCredentialPepper !== 'string' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomBytes !== undefined &&
|
||||
typeof options.randomBytes !== 'function')
|
||||
) {
|
||||
throw new WorkerCredentialManagementRequestError(
|
||||
'execution dependency is invalid',
|
||||
);
|
||||
}
|
||||
const requestedActionRef = actionRef(options.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
options.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const consumptionId = identifier(options.consumptionId, 'consumptionId');
|
||||
const dispatchId = identifier(options.dispatchId, 'dispatchId');
|
||||
const auditEventId = identifier(options.auditEventId, 'auditEventId');
|
||||
const now = options.now ?? Date.now;
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let failure: unknown;
|
||||
let run: Readonly<ClusterWorkerCredentialExecutionRun> | undefined;
|
||||
try {
|
||||
await options.confirmAuthorization();
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresWorkerCredentialExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const plans = new PostgresWorkerCredentialManagementPlanReader(
|
||||
database.pool,
|
||||
);
|
||||
const planValue = await plans.findByActionRef(requestedActionRef);
|
||||
if (!planValue) {
|
||||
throw new WorkerCredentialManagementConflictError('plan does not exist');
|
||||
}
|
||||
const plan = normalizeWorkerCredentialManagementPlan(planValue);
|
||||
const approvals = new PostgresApprovalRequestRepository(database.pool);
|
||||
const approvalValue = await approvals.findById(approvalRequestId);
|
||||
if (!approvalValue) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approval does not exist',
|
||||
);
|
||||
}
|
||||
let approval = normalizeApprovalRequestRecord(approvalValue);
|
||||
const approvedAction = binding(plan);
|
||||
if (
|
||||
approval.projectId !== plan.authorityProjectId ||
|
||||
approval.decisionMode !== 'separation_of_duty' ||
|
||||
!same(approval.action, approvedAction) ||
|
||||
!same(approval.requestedBy, plan.requestedBy)
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approval does not match durable plan',
|
||||
);
|
||||
}
|
||||
let dispatch: Readonly<ApprovedActionDispatchRecord> | null = null;
|
||||
if (approval.version === 2 && approval.state === 'approved') {
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(database.pool),
|
||||
);
|
||||
const decision = await policy.decide({
|
||||
subject: plan.requestedBy,
|
||||
projectId: plan.authorityProjectId,
|
||||
permission: 'worker.manage',
|
||||
});
|
||||
if (
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval') ||
|
||||
decision.fence === null
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'requester is no longer authorized',
|
||||
);
|
||||
}
|
||||
const consumedAtMs = currentTime(now);
|
||||
const consumed = await approvals.consume({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: approvedAction,
|
||||
requestedBy: plan.requestedBy,
|
||||
consumedBy: EXECUTOR_SUBJECT,
|
||||
consumedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: audit(
|
||||
auditEventId,
|
||||
approvalRequestId,
|
||||
plan.authorityProjectId,
|
||||
decision.fence,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
approval = consumed.request;
|
||||
dispatch = consumed.dispatch;
|
||||
} else if (approval.version === 3 && approval.state === 'consumed') {
|
||||
dispatch = await approvals.findDispatchById(dispatchId);
|
||||
}
|
||||
if (
|
||||
approval.version !== 3 ||
|
||||
approval.state !== 'consumed' ||
|
||||
approval.consumptionId !== consumptionId ||
|
||||
approval.dispatchId !== dispatchId ||
|
||||
!dispatch ||
|
||||
dispatch.id !== dispatchId ||
|
||||
!same(dispatch.action, approvedAction) ||
|
||||
!same(dispatch.requestedBy, plan.requestedBy) ||
|
||||
!same(dispatch.approvedBy, approval.decidedBy) ||
|
||||
!same(dispatch.consumedBy, EXECUTOR_SUBJECT)
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approval consumption does not match execution',
|
||||
);
|
||||
}
|
||||
const authority = new PostgresWorkerCredentialAdministrationRepository(
|
||||
database.pool,
|
||||
);
|
||||
const executions = new PostgresApprovedActionExecutionRepository(
|
||||
database.pool,
|
||||
);
|
||||
let executionSnapshot = await executions.findExecutionByDispatchId(
|
||||
dispatchId,
|
||||
);
|
||||
if (!executionSnapshot || !same(executionSnapshot.dispatch, dispatch)) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'durable execution baseline does not match dispatch',
|
||||
);
|
||||
}
|
||||
if (executionSnapshot.execution.status === 'succeeded') {
|
||||
const resolved = await authority.resolveDelivered(plan.target.deliveryId);
|
||||
const result = Object.freeze({
|
||||
status: 'existing' as const,
|
||||
delivery: resolved?.delivery ?? null,
|
||||
});
|
||||
const resultDigest = executionResultDigest(plan, result);
|
||||
if (
|
||||
executionSnapshot.execution.resultMutationId !==
|
||||
plan.target.deliveryId ||
|
||||
executionSnapshot.execution.resultCode !== EXECUTION_RESULT_CODE ||
|
||||
executionSnapshot.execution.resultDigest !== resultDigest
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'durable execution result does not match credential delivery',
|
||||
);
|
||||
}
|
||||
run = Object.freeze({
|
||||
database: evidence,
|
||||
approval: dispatch,
|
||||
execution: executionSnapshot.execution,
|
||||
result,
|
||||
tokenRequest: null,
|
||||
});
|
||||
await closeDatabase(database, undefined);
|
||||
return run;
|
||||
}
|
||||
const executionNowMs = currentTime(now);
|
||||
if (
|
||||
executionNowMs > plan.expiresAtMs ||
|
||||
executionNowMs >= dispatch.expiresAtMs ||
|
||||
executionNowMs >= plan.target.credentialExpiresAtMs
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approved execution window expired',
|
||||
);
|
||||
}
|
||||
await assertPredecessor(database.pool, plan, executionNowMs);
|
||||
await options.confirmAuthorization();
|
||||
if (
|
||||
executionSnapshot.execution.status === 'pending' ||
|
||||
executionSnapshot.execution.status === 'retry_wait' ||
|
||||
(executionSnapshot.execution.status === 'leased' &&
|
||||
executionSnapshot.execution.leaseExpiresAtMs !== null &&
|
||||
executionSnapshot.execution.leaseExpiresAtMs <= executionNowMs)
|
||||
) {
|
||||
const claimed = await executions.claimExecution({
|
||||
dispatchId,
|
||||
owner: EXECUTION_OWNER,
|
||||
leaseToken: consumptionId,
|
||||
nowMs: executionNowMs,
|
||||
leaseDurationMs: EXECUTION_LEASE_DURATION_MS,
|
||||
});
|
||||
if (claimed.status !== 'claimed') {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approved execution could not be claimed',
|
||||
);
|
||||
}
|
||||
executionSnapshot = claimed.snapshot;
|
||||
}
|
||||
if (
|
||||
(executionSnapshot.execution.status !== 'leased' &&
|
||||
executionSnapshot.execution.status !== 'executing') ||
|
||||
executionSnapshot.execution.leaseOwner !== EXECUTION_OWNER ||
|
||||
executionSnapshot.execution.leaseToken !== consumptionId ||
|
||||
executionSnapshot.execution.leaseExpiresAtMs === null ||
|
||||
executionSnapshot.execution.leaseExpiresAtMs <= executionNowMs
|
||||
) {
|
||||
throw new WorkerCredentialManagementConflictError(
|
||||
'approved execution lease does not match caller',
|
||||
);
|
||||
}
|
||||
if (executionSnapshot.execution.status === 'leased') {
|
||||
executionSnapshot = await executions.startExecution({
|
||||
dispatchId,
|
||||
approvalRequestId,
|
||||
actionDigest: approvedAction.actionDigest,
|
||||
owner: EXECUTION_OWNER,
|
||||
leaseToken: consumptionId,
|
||||
expectedVersion: executionSnapshot.execution.version,
|
||||
startedAtMs: executionNowMs,
|
||||
});
|
||||
}
|
||||
const sessionResult = await options.tokenRequestSession.withDelivery(
|
||||
async ({ delivery, evidence: tokenRequest }) => {
|
||||
const issuer = createRecoverableWorkerCredentialIssuer(
|
||||
authority,
|
||||
delivery,
|
||||
options.workerCredentialPepper,
|
||||
{
|
||||
now,
|
||||
...(options.randomBytes
|
||||
? { randomBytes: options.randomBytes }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const result = await issuer.issue({
|
||||
mutationId: plan.target.deliveryId,
|
||||
requestId: approvalRequestId,
|
||||
expectedCurrentVersion: 0,
|
||||
credentialId: plan.target.credentialId,
|
||||
workerId: plan.target.workerId,
|
||||
principal: executorPrincipal(currentTime(now)),
|
||||
notBeforeAtMs: plan.target.credentialNotBeforeAtMs,
|
||||
expiresAtMs: plan.target.credentialExpiresAtMs,
|
||||
previousCredentialId: plan.target.previousCredentialId,
|
||||
deploymentTargetDigest: plan.target.deploymentTargetDigest,
|
||||
deploymentGeneration: plan.target.deploymentGeneration,
|
||||
});
|
||||
return Object.freeze({ result, tokenRequest });
|
||||
},
|
||||
);
|
||||
const completed = await executions.completeExecution({
|
||||
dispatchId,
|
||||
owner: EXECUTION_OWNER,
|
||||
leaseToken: consumptionId,
|
||||
expectedVersion: executionSnapshot.execution.version,
|
||||
resultMutationId: plan.target.deliveryId,
|
||||
outcome: 'succeeded',
|
||||
resultCode: EXECUTION_RESULT_CODE,
|
||||
resultDigest: executionResultDigest(plan, sessionResult.result),
|
||||
completedAtMs: currentTime(now),
|
||||
});
|
||||
run = Object.freeze({
|
||||
database: evidence,
|
||||
approval: dispatch,
|
||||
execution: completed.execution,
|
||||
result: sessionResult.result,
|
||||
tokenRequest: sessionResult.tokenRequest,
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
await closeDatabase(database, failure);
|
||||
if (!run) {
|
||||
throw new WorkerCredentialManagementUnavailableError();
|
||||
}
|
||||
return run;
|
||||
}
|
||||
Reference in New Issue
Block a user