mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+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