mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+450
@@ -0,0 +1,450 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep execution authority explicit.
|
||||
import {
|
||||
PostgresPluginPackageLifecyclePlanRepository,
|
||||
PostgresPluginPackageLifecycleRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovedActionBinding,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
createPluginPackageLifecycleEvent,
|
||||
pluginPackageLifecycleActionDigest,
|
||||
PluginPackageLifecycleConflictError,
|
||||
type PluginPackageLifecycleAction,
|
||||
type PluginPackageLifecycleReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS,
|
||||
PluginPackageLifecyclePlanConflictError,
|
||||
createPluginPackageLifecyclePlan,
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
type PluginPackageLifecyclePlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CLUSTER_LIFECYCLE_CONSUMER = Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'cluster_plugin_package_lifecycle_executor',
|
||||
}),
|
||||
authenticationId: 'cluster_plugin_package_lifecycle_executor_v1',
|
||||
});
|
||||
|
||||
export interface RunClusterPluginPackageLifecyclePlanOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly actionRef: string;
|
||||
readonly action: PluginPackageLifecycleAction;
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly requestedBy: SecuritySubject;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
readonly lifetimeMs?: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecyclePlanRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageLifecycleExecutionOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecycleExecutionRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly receipt: Readonly<PluginPackageLifecycleReceipt>;
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new TypeError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function databaseNowMs(pool: PostgresPool): Promise<number> {
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS "nowMs"`,
|
||||
);
|
||||
const value = result.rows[0]?.nowMs;
|
||||
const parsed =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
!Number.isSafeInteger(parsed) ||
|
||||
parsed < 0
|
||||
) {
|
||||
throw new Error('PostgreSQL lifecycle clock is unavailable');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function binding(
|
||||
plan: Readonly<PluginPackageLifecyclePlan>,
|
||||
): Readonly<ApprovedActionBinding> {
|
||||
return Object.freeze({
|
||||
permission: 'package.manage',
|
||||
actionType: `plugin_package.lifecycle.${plan.impact.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(plan.impact),
|
||||
previewDigest: plan.impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
approvalRequestId: string,
|
||||
projectId: string,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: approvalRequestId,
|
||||
operationId: 'approval.consume',
|
||||
projectId,
|
||||
subject: CLUSTER_LIFECYCLE_CONSUMER.subject,
|
||||
authenticationId: CLUSTER_LIFECYCLE_CONSUMER.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['package_lifecycle_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeDatabase(
|
||||
database: PostgresDatabaseResource | undefined,
|
||||
failure: unknown,
|
||||
): Promise<void> {
|
||||
if (!database) {
|
||||
if (failure !== undefined) throw failure;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await database.close();
|
||||
} catch (closeError) {
|
||||
if (failure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[failure, closeError],
|
||||
'Cluster Plugin Package lifecycle failed and PostgreSQL did not close',
|
||||
);
|
||||
}
|
||||
throw closeError;
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageLifecyclePlan(
|
||||
options: RunClusterPluginPackageLifecyclePlanOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageLifecyclePlanRun>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function' ||
|
||||
typeof options.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(options.projectId) ||
|
||||
typeof options.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(options.packageName)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle plan options are invalid',
|
||||
);
|
||||
}
|
||||
const requestedActionRef = actionRef(options.actionRef);
|
||||
const lifetimeMs =
|
||||
options.lifetimeMs ?? MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(lifetimeMs) ||
|
||||
lifetimeMs < 1_000 ||
|
||||
lifetimeMs > MAX_PLUGIN_PACKAGE_LIFECYCLE_PLAN_LIFETIME_MS
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle plan lifetime is invalid',
|
||||
);
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let failure: unknown;
|
||||
let result: Readonly<ClusterPluginPackageLifecyclePlanRun> | undefined;
|
||||
try {
|
||||
await options.confirmAuthorization();
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const lifecycles = new PostgresPluginPackageLifecycleRepository(
|
||||
database.pool,
|
||||
);
|
||||
const plans = new PostgresPluginPackageLifecyclePlanRepository(
|
||||
database.pool,
|
||||
);
|
||||
const existingValue = await plans.findByActionRef(requestedActionRef);
|
||||
if (existingValue) {
|
||||
const existing = normalizePluginPackageLifecyclePlan(existingValue);
|
||||
if (
|
||||
existing.impact.action !== options.action ||
|
||||
existing.impact.target.projectId !== options.projectId ||
|
||||
existing.impact.target.packageName !== options.packageName ||
|
||||
!same(existing.requestedBy, options.requestedBy) ||
|
||||
existing.expiresAtMs - existing.plannedAtMs !== lifetimeMs
|
||||
) {
|
||||
throw new PluginPackageLifecyclePlanConflictError(
|
||||
'actionRef is bound to another lifecycle request',
|
||||
);
|
||||
}
|
||||
await options.confirmAuthorization();
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: 'existing' as const,
|
||||
plan: existing,
|
||||
});
|
||||
} else {
|
||||
const impact = await lifecycles.plan(
|
||||
options.action,
|
||||
options.projectId,
|
||||
options.packageName,
|
||||
);
|
||||
const plannedAtMs = await databaseNowMs(database.pool);
|
||||
const plan = createPluginPackageLifecyclePlan({
|
||||
actionRef: requestedActionRef,
|
||||
impact,
|
||||
requestedBy: options.requestedBy,
|
||||
plannedAtMs,
|
||||
expiresAtMs: plannedAtMs + lifetimeMs,
|
||||
});
|
||||
await options.confirmAuthorization();
|
||||
const created = await plans.create(plan);
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: created.status,
|
||||
plan: created.plan,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
await closeDatabase(database, failure);
|
||||
if (!result) {
|
||||
throw new Error('Cluster Plugin Package lifecycle plan produced no result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageLifecycleExecution(
|
||||
options: RunClusterPluginPackageLifecycleExecutionOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageLifecycleExecutionRun>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.openDatabase !== 'function' ||
|
||||
typeof options.confirmAuthorization !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle execution options are invalid',
|
||||
);
|
||||
}
|
||||
const requestedActionRef = actionRef(options.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
options.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const consumptionId = identifier(options.consumptionId, 'consumptionId');
|
||||
const dispatchId = identifier(options.dispatchId, 'dispatchId');
|
||||
const auditEventId = identifier(options.auditEventId, 'auditEventId');
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
let failure: unknown;
|
||||
let result: Readonly<ClusterPluginPackageLifecycleExecutionRun> | undefined;
|
||||
try {
|
||||
await options.confirmAuthorization();
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const plans = new PostgresPluginPackageLifecyclePlanRepository(
|
||||
database.pool,
|
||||
);
|
||||
const planValue = await plans.findByActionRef(requestedActionRef);
|
||||
if (!planValue) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'durable lifecycle plan is absent',
|
||||
);
|
||||
}
|
||||
const plan = normalizePluginPackageLifecyclePlan(planValue);
|
||||
const approvals = new PostgresApprovalRequestRepository(database.pool);
|
||||
let approvalValue = await approvals.findById(approvalRequestId);
|
||||
if (!approvalValue) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval is absent',
|
||||
);
|
||||
}
|
||||
let approval = normalizeApprovalRequestRecord(approvalValue);
|
||||
const approvedAction = binding(plan);
|
||||
if (
|
||||
approval.projectId !== plan.impact.target.projectId ||
|
||||
approval.decisionMode !== 'separation_of_duty' ||
|
||||
!same(approval.action, approvedAction) ||
|
||||
!same(approval.requestedBy, plan.requestedBy)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval does not match durable plan',
|
||||
);
|
||||
}
|
||||
let dispatch: Readonly<ApprovedActionDispatchRecord> | null = null;
|
||||
if (approval.version === 2 && approval.state === 'approved') {
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(database.pool),
|
||||
);
|
||||
const decision = await policy.decide({
|
||||
subject: plan.requestedBy,
|
||||
projectId: plan.impact.target.projectId,
|
||||
permission: 'package.manage',
|
||||
});
|
||||
if (
|
||||
(decision.effect !== 'allow' &&
|
||||
decision.effect !== 'require_approval') ||
|
||||
decision.fence === null
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle requester is no longer authorized',
|
||||
);
|
||||
}
|
||||
const consumedAtMs = await databaseNowMs(database.pool);
|
||||
const consumed = await approvals.consume({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action: approvedAction,
|
||||
requestedBy: plan.requestedBy,
|
||||
consumedBy: CLUSTER_LIFECYCLE_CONSUMER.subject,
|
||||
consumedAtMs,
|
||||
authorizationFence: decision.fence,
|
||||
audit: audit(
|
||||
auditEventId,
|
||||
approvalRequestId,
|
||||
plan.impact.target.projectId,
|
||||
decision.fence,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
approval = consumed.request;
|
||||
dispatch = consumed.dispatch;
|
||||
} else if (approval.version === 3 && approval.state === 'consumed') {
|
||||
dispatch = await approvals.findDispatchById(dispatchId);
|
||||
}
|
||||
if (
|
||||
approval.version !== 3 ||
|
||||
approval.state !== 'consumed' ||
|
||||
approval.consumptionId !== consumptionId ||
|
||||
approval.dispatchId !== dispatchId ||
|
||||
!dispatch ||
|
||||
!same(dispatch.action, approvedAction) ||
|
||||
!same(dispatch.requestedBy, plan.requestedBy) ||
|
||||
!same(dispatch.approvedBy, approval.decidedBy) ||
|
||||
!same(dispatch.consumedBy, CLUSTER_LIFECYCLE_CONSUMER.subject)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle dispatch does not match durable approval',
|
||||
);
|
||||
}
|
||||
const lifecycles = new PostgresPluginPackageLifecycleRepository(
|
||||
database.pool,
|
||||
);
|
||||
const event = createPluginPackageLifecycleEvent({
|
||||
dispatchId: dispatch.id,
|
||||
impact: plan.impact,
|
||||
requestedBy: dispatch.requestedBy,
|
||||
approvedBy: dispatch.approvedBy,
|
||||
authorizationMode: 'separation_of_duty',
|
||||
occurredAtMs: dispatch.createdAtMs,
|
||||
});
|
||||
const existingReceipt = await lifecycles.findByEventDigest(
|
||||
event.eventDigest,
|
||||
);
|
||||
if (existingReceipt) {
|
||||
await options.confirmAuthorization();
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: 'existing' as const,
|
||||
receipt: existingReceipt,
|
||||
});
|
||||
} else {
|
||||
const currentImpact = await lifecycles.plan(
|
||||
plan.impact.action,
|
||||
plan.impact.target.projectId,
|
||||
plan.impact.target.packageName,
|
||||
);
|
||||
if (!same(currentImpact, plan.impact)) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'approved lifecycle impact is stale',
|
||||
);
|
||||
}
|
||||
const transitioned = await lifecycles.transition(
|
||||
event,
|
||||
options.confirmAuthorization,
|
||||
);
|
||||
result = Object.freeze({
|
||||
database: evidence,
|
||||
status: transitioned.status,
|
||||
receipt: transitioned.receipt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
await closeDatabase(database, failure);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
'Cluster Plugin Package lifecycle execution produced no result',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep approval management authority explicit.
|
||||
import { PostgresApprovalRequestRepository } from '@qinglong/cluster-postgres/approved-action';
|
||||
import { PostgresPluginPackageLifecyclePlanReader } from '@qinglong/cluster-postgres/package-manager';
|
||||
import { PostgresProjectPolicyRepository } from '@qinglong/cluster-postgres/project-policy';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
type CreateApprovalRequestResult,
|
||||
type DecideApprovalRequestResult,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import { pluginPackageLifecycleActionDigest } from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
type PluginPackageLifecyclePlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
|
||||
import {
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementConflictError,
|
||||
PluginPackageManagementRequestError,
|
||||
PluginPackageManagementUnavailableError,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const DEFAULT_APPROVAL_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
|
||||
export interface ClusterPluginPackageLifecycleManagementOptions {
|
||||
readonly pool: PostgresPool;
|
||||
readonly now?: () => number;
|
||||
readonly approvalLifetimeMs?: number;
|
||||
}
|
||||
|
||||
export interface ProposeClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ProposeClusterPluginPackageLifecycleResult {
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan>;
|
||||
readonly approvalStatus: CreateApprovalRequestResult['status'];
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord>;
|
||||
}
|
||||
|
||||
export interface DecideClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly decisionId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterPluginPackageLifecycleRequest {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly inspectionId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface InspectClusterPluginPackageLifecycleResult {
|
||||
readonly plan: Readonly<PluginPackageLifecyclePlan> | null;
|
||||
readonly approvalRequest: Readonly<ApprovalRequestRecord> | null;
|
||||
readonly stale: boolean;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageLifecycleManagementService {
|
||||
propose(
|
||||
request: ProposeClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<ProposeClusterPluginPackageLifecycleResult>>;
|
||||
decide(
|
||||
request: DecideClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<DecideApprovalRequestResult>>;
|
||||
inspectAuthorized(
|
||||
request: InspectClusterPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<InspectClusterPluginPackageLifecycleResult>>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new PluginPackageManagementRequestError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new PluginPackageManagementRequestError('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function action(plan: Readonly<PluginPackageLifecyclePlan>) {
|
||||
return Object.freeze({
|
||||
permission: 'package.manage' as const,
|
||||
actionType: `plugin_package.lifecycle.${plan.impact.action}`,
|
||||
actionRef: plan.actionRef,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(plan.impact),
|
||||
previewDigest: plan.impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
requestId: string,
|
||||
operationId: 'approval.request' | 'approval.decide',
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
authenticationId: string,
|
||||
outcome: 'allowed' | 'approval_required',
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: Object.freeze(['package_lifecycle_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageLifecycleManagementService(
|
||||
options: ClusterPluginPackageLifecycleManagementOptions,
|
||||
): Readonly<ClusterPluginPackageLifecycleManagementService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'pool' &&
|
||||
key !== 'now' &&
|
||||
key !== 'approvalLifetimeMs',
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle management options are invalid',
|
||||
);
|
||||
}
|
||||
const approvalLifetimeMs =
|
||||
options.approvalLifetimeMs ?? DEFAULT_APPROVAL_LIFETIME_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(approvalLifetimeMs) ||
|
||||
approvalLifetimeMs < 1_000 ||
|
||||
approvalLifetimeMs > DEFAULT_APPROVAL_LIFETIME_MS
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package lifecycle approval lifetime is invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const plans = new PostgresPluginPackageLifecyclePlanReader(options.pool);
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new PostgresProjectPolicyRepository(options.pool),
|
||||
);
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
permission: 'package.manage' | 'approval.decide',
|
||||
observedAtMs: number,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
}>
|
||||
> => {
|
||||
let principal;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
} catch {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
(principal.assurance !== 'multi_factor' &&
|
||||
principal.assurance !== 'hardware')
|
||||
) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(principal, projectId, permission);
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (decision.effect !== 'allow' || decision.fence === null) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
return Object.freeze({ principal, fence: decision.fence });
|
||||
};
|
||||
|
||||
const loadPlan = async (
|
||||
requestedActionRef: string,
|
||||
): Promise<Readonly<PluginPackageLifecyclePlan>> => {
|
||||
let plan;
|
||||
try {
|
||||
plan = await plans.findByActionRef(actionRef(requestedActionRef));
|
||||
} catch (error) {
|
||||
throw new PluginPackageManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!plan) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan does not exist',
|
||||
);
|
||||
}
|
||||
return normalizePluginPackageLifecyclePlan(plan);
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async propose(request: ProposeClusterPluginPackageLifecycleRequest) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalAuditEventId',
|
||||
'approvalRequestId',
|
||||
'principal',
|
||||
]
|
||||
.sort()
|
||||
.join('\0')
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle proposal request is invalid',
|
||||
);
|
||||
}
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const approvalAuditEventId = identifier(
|
||||
request.approvalAuditEventId,
|
||||
'approvalAuditEventId',
|
||||
);
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
const observedAtMs = currentTime(now);
|
||||
if (observedAtMs > plan.expiresAtMs) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan expired',
|
||||
);
|
||||
}
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
plan.impact.target.projectId,
|
||||
'package.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
if (!sameSubject(plan.requestedBy, authorization.principal.subject)) {
|
||||
throw new PluginPackageManagementAuthorizationError();
|
||||
}
|
||||
const binding = action(plan);
|
||||
const existing = await approvals.findById(approvalRequestId);
|
||||
if (existing) {
|
||||
const normalized = normalizeApprovalRequestRecord(existing);
|
||||
if (
|
||||
normalized.projectId !== plan.impact.target.projectId ||
|
||||
normalized.decisionMode !== 'separation_of_duty' ||
|
||||
!sameSubject(normalized.requestedBy, plan.requestedBy) ||
|
||||
JSON.stringify(normalized.action) !== JSON.stringify(binding)
|
||||
) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request is bound to another lifecycle plan',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: 'existing' as const,
|
||||
approvalRequest: normalized,
|
||||
});
|
||||
}
|
||||
const expiresAtMs = Math.min(
|
||||
observedAtMs + approvalLifetimeMs,
|
||||
plan.expiresAtMs,
|
||||
);
|
||||
if (expiresAtMs <= observedAtMs) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle plan has no approval lifetime',
|
||||
);
|
||||
}
|
||||
const result = await approvals.create({
|
||||
request: createApprovalRequest({
|
||||
id: approvalRequestId,
|
||||
projectId: plan.impact.target.projectId,
|
||||
action: binding,
|
||||
risk: 'high',
|
||||
decisionMode: 'separation_of_duty',
|
||||
requestedBy: authorization.principal.subject,
|
||||
requestedAtMs: observedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: authorization.fence,
|
||||
}),
|
||||
audit: audit(
|
||||
approvalAuditEventId,
|
||||
approvalRequestId,
|
||||
'approval.request',
|
||||
plan.impact.target.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'approval_required',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalStatus: result.status,
|
||||
approvalRequest: result.request,
|
||||
});
|
||||
},
|
||||
|
||||
async decide(request: DecideClusterPluginPackageLifecycleRequest) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'auditEventId',
|
||||
'decision',
|
||||
'decisionId',
|
||||
'expectedVersion',
|
||||
'principal',
|
||||
'reasonCode',
|
||||
]
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
(request.decision !== 'approved' &&
|
||||
request.decision !== 'rejected') ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode) ||
|
||||
!Number.isSafeInteger(request.expectedVersion) ||
|
||||
request.expectedVersion < 1
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle decision request is invalid',
|
||||
);
|
||||
}
|
||||
const plan = await loadPlan(request.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const decisionId = identifier(request.decisionId, 'decisionId');
|
||||
const auditEventId = identifier(request.auditEventId, 'auditEventId');
|
||||
const current = await approvals.findById(approvalRequestId);
|
||||
if (!current) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request does not exist',
|
||||
);
|
||||
}
|
||||
const approval = normalizeApprovalRequestRecord(current);
|
||||
if (
|
||||
approval.action.actionRef !== plan.actionRef ||
|
||||
JSON.stringify(approval.action) !== JSON.stringify(action(plan))
|
||||
) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Approval request does not match lifecycle plan',
|
||||
);
|
||||
}
|
||||
const observedAtMs = currentTime(now);
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
approval.projectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
if (
|
||||
approval.decisionId === decisionId &&
|
||||
approval.decision === request.decision &&
|
||||
approval.decisionReasonCode === request.reasonCode &&
|
||||
approval.decidedBy &&
|
||||
sameSubject(approval.decidedBy, authorization.principal.subject)
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
request: approval,
|
||||
});
|
||||
}
|
||||
return approvals.decide({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: request.expectedVersion,
|
||||
decisionId,
|
||||
decision: request.decision,
|
||||
reasonCode: request.reasonCode,
|
||||
principal: authorization.principal,
|
||||
decidedAtMs: observedAtMs,
|
||||
authorizationFence: authorization.fence,
|
||||
audit: audit(
|
||||
auditEventId,
|
||||
approvalRequestId,
|
||||
'approval.decide',
|
||||
approval.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'allowed',
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
async inspectAuthorized(
|
||||
request: InspectClusterPluginPackageLifecycleRequest,
|
||||
) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'inspectionId',
|
||||
'principal',
|
||||
]
|
||||
.sort()
|
||||
.join('\0')
|
||||
) {
|
||||
throw new PluginPackageManagementRequestError(
|
||||
'lifecycle inspection request is invalid',
|
||||
);
|
||||
}
|
||||
identifier(request.inspectionId, 'inspectionId');
|
||||
const requestedActionRef = actionRef(request.actionRef);
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const [planValue, approvalValue] = await Promise.all([
|
||||
plans.findByActionRef(requestedActionRef),
|
||||
approvals.findById(approvalRequestId),
|
||||
]);
|
||||
if (!planValue && !approvalValue) {
|
||||
throw new PluginPackageManagementConflictError(
|
||||
'Plugin Package lifecycle state does not exist',
|
||||
);
|
||||
}
|
||||
const plan = planValue
|
||||
? normalizePluginPackageLifecyclePlan(planValue)
|
||||
: null;
|
||||
const approval = approvalValue
|
||||
? normalizeApprovalRequestRecord(approvalValue)
|
||||
: null;
|
||||
const projectId = plan?.impact.target.projectId ?? approval?.projectId;
|
||||
if (!projectId) {
|
||||
throw new PluginPackageManagementUnavailableError();
|
||||
}
|
||||
const observedAtMs = currentTime(now);
|
||||
try {
|
||||
await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'package.manage',
|
||||
observedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PluginPackageManagementAuthorizationError)) {
|
||||
throw error;
|
||||
}
|
||||
await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
'approval.decide',
|
||||
observedAtMs,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
plan,
|
||||
approvalRequest: approval,
|
||||
stale:
|
||||
plan === null ||
|
||||
approval === null ||
|
||||
approval.action.actionRef !== plan.actionRef ||
|
||||
JSON.stringify(approval.action) !== JSON.stringify(action(plan)) ||
|
||||
observedAtMs > plan.expiresAtMs,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Cluster Plugin Package lifecycle boundary; keep quarantine authority explicit.
|
||||
import type {
|
||||
OpenPostgresDatabase,
|
||||
PostgresDatabaseResource,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageQuarantineError,
|
||||
normalizePluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineRepository,
|
||||
type PluginPackageWithdrawalReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-quarantine';
|
||||
import {
|
||||
PostgresPluginPackageQuarantineRepository,
|
||||
assertPostgresPackageExecutorSchemaReady,
|
||||
type PostgresSchemaReadinessReport,
|
||||
} from '@qinglong/cluster-postgres/package-executor';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT = 128;
|
||||
|
||||
export interface ClusterPluginPackageQuarantineService {
|
||||
quarantine(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>,
|
||||
): Promise<
|
||||
readonly Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
eventDigest: string;
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>[]
|
||||
>;
|
||||
}
|
||||
|
||||
export interface RunClusterPluginPackageQuarantineOptions {
|
||||
readonly openDatabase: OpenPostgresDatabase;
|
||||
readonly events: readonly Readonly<PluginPackageQuarantineEvent>[];
|
||||
readonly confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageQuarantineRun {
|
||||
readonly database: PostgresSchemaReadinessReport;
|
||||
readonly results: readonly Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
eventDigest: string;
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>[];
|
||||
}
|
||||
|
||||
function targetKey(event: Readonly<PluginPackageQuarantineEvent>): string {
|
||||
return [
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
event.target.installationId,
|
||||
event.target.lockDigest,
|
||||
].join('\0');
|
||||
}
|
||||
|
||||
function normalizedBatch(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
): readonly Readonly<PluginPackageQuarantineEvent>[] {
|
||||
if (
|
||||
!Array.isArray(events) ||
|
||||
events.length < 1 ||
|
||||
events.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT ||
|
||||
Object.keys(events).some((key, index) => key !== String(index))
|
||||
) {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
`events must contain 1-${CLUSTER_PLUGIN_PACKAGE_QUARANTINE_BATCH_LIMIT} dense items`,
|
||||
);
|
||||
}
|
||||
const normalized = events.map(normalizePluginPackageQuarantineEvent);
|
||||
const eventDigests = new Set<string>();
|
||||
const targets = new Set<string>();
|
||||
for (const event of normalized) {
|
||||
const target = targetKey(event);
|
||||
if (eventDigests.has(event.eventDigest) || targets.has(target)) {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'batch event digests and targets must be unique',
|
||||
);
|
||||
}
|
||||
eventDigests.add(event.eventDigest);
|
||||
targets.add(target);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageQuarantineService(
|
||||
repository: PluginPackageQuarantineRepository,
|
||||
): Readonly<ClusterPluginPackageQuarantineService> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.findTargetsByLockDigest !== 'function' ||
|
||||
typeof repository.findByEventDigest !== 'function' ||
|
||||
typeof repository.quarantine !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine repository is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
async quarantine(
|
||||
events: readonly Readonly<PluginPackageQuarantineEvent>[],
|
||||
confirmAuthorization: (
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
) => void | Promise<void>,
|
||||
) {
|
||||
const batch = normalizedBatch(events);
|
||||
if (typeof confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
const results = [];
|
||||
for (const event of batch) {
|
||||
const result = await repository.quarantine(event, () =>
|
||||
confirmAuthorization(event),
|
||||
);
|
||||
results.push(
|
||||
Object.freeze({
|
||||
status: result.status,
|
||||
eventDigest: event.eventDigest,
|
||||
receipt: result.receipt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(results);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClusterPluginPackageQuarantine(
|
||||
options: RunClusterPluginPackageQuarantineOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageQuarantineRun>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine options are invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
!['openDatabase', 'events', 'confirmAuthorization'].includes(key),
|
||||
) ||
|
||||
typeof options.openDatabase !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Cluster Plugin Package quarantine options shape is invalid',
|
||||
);
|
||||
}
|
||||
const events = normalizedBatch(options.events);
|
||||
if (typeof options.confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
let database: PostgresDatabaseResource | undefined;
|
||||
try {
|
||||
database = await options.openDatabase();
|
||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
||||
database.pool,
|
||||
);
|
||||
const results =
|
||||
await createClusterPluginPackageQuarantineService(
|
||||
new PostgresPluginPackageQuarantineRepository(database.pool),
|
||||
).quarantine(events, options.confirmAuthorization);
|
||||
return Object.freeze({ database: evidence, results });
|
||||
} finally {
|
||||
await database?.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user