feat(ql3): expose cluster secret binding management

This commit is contained in:
whyour
2026-08-13 15:41:59 +08:00
parent 7016903fba
commit 73eeaed4de
13 changed files with 1127 additions and 48 deletions
@@ -3,6 +3,7 @@ import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
import { Duplex } from 'node:stream';
import { connect as tlsConnect } from 'node:tls';
import { TextDecoder } from 'node:util';
import { parseSecretRef } from '@qinglong/runtime-core/secret-reference';
import {
ClusterPluginPackageManagementClientConfigurationError,
isReviewedClusterAuthenticatedManagementClientProtocol,
@@ -290,6 +291,21 @@ const LIFECYCLE_PLAN_KEYS = Object.freeze([
'blockingReferences',
'impactDigest',
]);
const SECRET_BINDING_PLAN_KEYS = Object.freeze([
'actionRef',
'projectId',
'packageName',
'installationId',
'generation',
'generationDigest',
'lockDigest',
'manifestDigest',
'entries',
'plannedAtMs',
'expiresAtMs',
'planDigest',
'approvalPlanDigest',
]);
function validateScalarSummary(value: unknown, keys: readonly string[]): void {
const record = exactResponseObject(value, keys);
@@ -463,10 +479,211 @@ function validateLifecyclePlanSummary(value: unknown): void {
}
}
function validateSecretBindingPlanSummary(
value: unknown,
command: Readonly<
Extract<
ClusterPluginPackageManagementCommand,
{ readonly operation: `plugin-package.secret-binding.${string}` }
>
>,
): void {
const summary = exactResponseObject(value, SECRET_BINDING_PLAN_KEYS);
if (
typeof summary.actionRef !== 'string' ||
summary.actionRef.length < 1 ||
summary.actionRef.length > 255 ||
typeof summary.projectId !== 'string' ||
summary.projectId.length < 1 ||
summary.projectId.length > 128 ||
typeof summary.packageName !== 'string' ||
!PACKAGE_NAME_PATTERN.test(summary.packageName) ||
typeof summary.installationId !== 'string' ||
summary.installationId.length < 1 ||
summary.installationId.length > 128 ||
!Number.isSafeInteger(summary.generation) ||
(summary.generation as number) < 1 ||
!Number.isSafeInteger(summary.plannedAtMs) ||
!Number.isSafeInteger(summary.expiresAtMs) ||
(summary.expiresAtMs as number) <= (summary.plannedAtMs as number) ||
!Array.isArray(summary.entries) ||
summary.entries.length > 64 ||
new Set(
summary.entries.map((entry) =>
entry && typeof entry === 'object' && !Array.isArray(entry)
? (entry as JsonObject).name
: undefined,
),
).size !== summary.entries.length
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
for (const key of [
'generationDigest',
'lockDigest',
'manifestDigest',
'planDigest',
'approvalPlanDigest',
]) {
if (
typeof summary[key] !== 'string' ||
!DIGEST_PATTERN.test(summary[key] as string)
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
}
for (const entryValue of summary.entries) {
const entry = exactResponseObject(entryValue, [
'name',
'required',
'secretRef',
]);
if (
typeof entry.name !== 'string' ||
!/^[A-Z_][A-Z0-9_]{0,127}$/.test(entry.name) ||
typeof entry.required !== 'boolean' ||
(entry.secretRef !== null &&
(typeof entry.secretRef !== 'string' ||
entry.secretRef.length > 2_048 ||
CONTROL_PATTERN.test(entry.secretRef))) ||
(entry.required === true && entry.secretRef === null)
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
if (entry.secretRef !== null) {
try {
const reference = parseSecretRef(entry.secretRef);
if (
reference.projectId !== summary.projectId ||
typeof reference.version !== 'number' ||
!Number.isSafeInteger(reference.version) ||
reference.version < 1
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
} catch (error) {
if (error instanceof ClusterPluginPackageManagementClientRequestError) {
throw error;
}
throw new ClusterPluginPackageManagementClientRequestError();
}
}
}
if (
summary.actionRef !== command.request.actionRef ||
command.operation === 'plugin-package.secret-binding.plan' &&
(summary.projectId !== command.request.projectId ||
summary.packageName !== command.request.packageName ||
summary.entries.length !== command.request.assignments.length ||
command.request.assignments.some((assignment) => {
const responseEntry = (summary.entries as JsonObject[]).find(
(entry) => entry.name === assignment.name,
);
return !responseEntry || responseEntry.secretRef !== assignment.secretRef;
}))
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
}
function validateResult(
value: unknown,
command: Readonly<ClusterPluginPackageManagementCommand>,
): Readonly<ClusterPluginPackageManagementTransportResult> {
if (command.operation === 'plugin-package.secret-binding.plan') {
const result = exactResponseObject(value, [
'schemaVersion',
'operation',
'status',
'plan',
]);
if (
result.schemaVersion !== 1 ||
result.operation !== command.operation ||
!['created', 'existing'].includes(String(result.status))
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
validateSecretBindingPlanSummary(result.plan, command);
return Object.freeze(
result as unknown as ClusterPluginPackageManagementTransportResult,
);
}
if (command.operation === 'plugin-package.secret-binding.propose') {
const result = exactResponseObject(value, [
'schemaVersion',
'operation',
'approvalStatus',
'plan',
'approval',
]);
if (
result.schemaVersion !== 1 ||
result.operation !== command.operation ||
!['created', 'existing'].includes(String(result.approvalStatus))
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
validateSecretBindingPlanSummary(result.plan, command);
validateScalarSummary(result.approval, APPROVAL_KEYS);
const plan = result.plan as JsonObject;
const approval = result.approval as JsonObject;
if (
approval.id !== command.request.approvalRequestId ||
approval.projectId !== plan.projectId ||
approval.actionDigest !== plan.approvalPlanDigest ||
approval.previewDigest !== plan.planDigest
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
return Object.freeze(
result as unknown as ClusterPluginPackageManagementTransportResult,
);
}
if (command.operation === 'plugin-package.secret-binding.inspect') {
const result = exactResponseObject(value, [
'schemaVersion',
'operation',
'plan',
'approval',
'stale',
]);
if (
result.schemaVersion !== 1 ||
result.operation !== command.operation ||
typeof result.stale !== 'boolean' ||
result.plan === null && result.approval === null
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
if (result.plan !== null) {
validateSecretBindingPlanSummary(result.plan, command);
}
if (result.approval !== null) {
validateScalarSummary(result.approval, APPROVAL_KEYS);
if (
(result.approval as JsonObject).id !==
command.request.approvalRequestId
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
}
if (result.plan !== null && result.approval !== null) {
const plan = result.plan as JsonObject;
const approval = result.approval as JsonObject;
if (
approval.id !== command.request.approvalRequestId ||
approval.projectId !== plan.projectId ||
approval.actionDigest !== plan.approvalPlanDigest ||
approval.previewDigest !== plan.planDigest
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
}
return Object.freeze(
result as unknown as ClusterPluginPackageManagementTransportResult,
);
}
if (command.operation === 'plugin-package.installation.inspect') {
const result = exactResponseObject(value, [
'schemaVersion',
@@ -617,6 +834,12 @@ function validateResult(
}
if (result.approval !== null) {
validateScalarSummary(result.approval, APPROVAL_KEYS);
if (
command.operation === 'plugin-package.secret-binding.decide' &&
(result.approval as JsonObject).id !== command.request.approvalRequestId
) {
throw new ClusterPluginPackageManagementClientRequestError();
}
} else if (kind !== 'inspect') {
throw new ClusterPluginPackageManagementClientRequestError();
}
@@ -28,6 +28,7 @@ import {
} from '../../management-support/pluginPackageIdentityKeyset';
import { createClusterPluginPackageManagementService } from './pluginPackageManagement';
import { createClusterPluginPackageLifecycleManagementService } from '../lifecycle/pluginPackageLifecycleManagement';
import { createClusterPluginPackageSecretBindingManagementService } from '../secret-binding/pluginPackageSecretBindingManagement';
import {
loadClusterPluginPackagePublisherTrustFileEvidence,
type ClusterPluginPackagePublisherTrustFileEvidence,
@@ -635,10 +636,18 @@ export async function startClusterPluginPackageManagementProcess(
now,
quota,
});
const secretBinding =
createClusterPluginPackageSecretBindingManagementService({
pool: database.pool,
approvalLifetimeMs: config.approvalLifetimeMs,
now,
quota,
});
const transport = createClusterPluginPackageManagementTransport({
service,
lifecycle,
publisherTrust,
secretBinding,
now,
});
const privateKey = readTlsFile(config.privateKeyFile, true);
@@ -14,6 +14,8 @@ import type {
} from '@qinglong/runtime-core/plugin-package-management';
import type { PluginPackageInstallProposal } from '@qinglong/runtime-core/plugin-package-proposal';
import type { PluginPackageLifecyclePlan } from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
import type { PluginPackageSecretBindingAssignment } from '@qinglong/runtime-core/plugin-package-secret-binding';
import type { PluginPackageSecretBindingApprovalPlan } from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan';
import {
normalizeSecurityPrincipal,
type SecurityPrincipal,
@@ -25,6 +27,7 @@ import type {
InspectClusterPluginPackagePublisherRevocationResult,
InspectClusterPluginPackagePublisherTrustTransitionResult,
} from '../publisher/pluginPackagePublisherTrustManagement';
import type { ClusterPluginPackageSecretBindingManagementService } from '../secret-binding/pluginPackageSecretBindingManagement';
const STRONG_CLUSTER_ASSURANCES = new Set(['multi_factor', 'hardware']);
@@ -166,6 +169,39 @@ export interface InspectClusterPluginPackagePublisherTrustTransitionCommand {
readonly request: InspectClusterPluginPackageCommand['request'];
}
export interface PlanClusterPluginPackageSecretBindingCommand {
readonly schemaVersion: 1;
readonly operation: 'plugin-package.secret-binding.plan';
readonly request: {
readonly actionRef: string;
readonly projectId: string;
readonly packageName: string;
readonly assignments: readonly Readonly<PluginPackageSecretBindingAssignment>[];
};
}
export interface ProposeClusterPluginPackageSecretBindingCommand {
readonly schemaVersion: 1;
readonly operation: 'plugin-package.secret-binding.propose';
readonly request: {
readonly actionRef: string;
readonly approvalRequestId: string;
readonly approvalAuditEventId: string;
};
}
export interface DecideClusterPluginPackageSecretBindingCommand {
readonly schemaVersion: 1;
readonly operation: 'plugin-package.secret-binding.decide';
readonly request: DecideClusterPluginPackageCommand['request'];
}
export interface InspectClusterPluginPackageSecretBindingCommand {
readonly schemaVersion: 1;
readonly operation: 'plugin-package.secret-binding.inspect';
readonly request: InspectClusterPluginPackageCommand['request'];
}
export type ClusterPluginPackageManagementCommand =
| ProposeClusterPluginPackageCommand
| DecideClusterPluginPackageCommand
@@ -180,7 +216,11 @@ export type ClusterPluginPackageManagementCommand =
| InspectClusterPluginPackagePublisherRevocationCommand
| ProposeClusterPluginPackagePublisherTrustTransitionCommand
| DecideClusterPluginPackagePublisherTrustTransitionCommand
| InspectClusterPluginPackagePublisherTrustTransitionCommand;
| InspectClusterPluginPackagePublisherTrustTransitionCommand
| PlanClusterPluginPackageSecretBindingCommand
| ProposeClusterPluginPackageSecretBindingCommand
| DecideClusterPluginPackageSecretBindingCommand
| InspectClusterPluginPackageSecretBindingCommand;
export type ClusterPluginPackageManagementTransportResult =
| Readonly<{
@@ -276,6 +316,32 @@ export type ClusterPluginPackageManagementTransportResult =
typeof publisherTrustTransitionProposalSummary
> | null;
approval: ReturnType<typeof approvalSummary> | null;
}>
| Readonly<{
schemaVersion: 1;
operation: 'plugin-package.secret-binding.plan';
status: 'created' | 'existing';
plan: ReturnType<typeof secretBindingPlanSummary>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'plugin-package.secret-binding.propose';
approvalStatus: 'created' | 'existing';
plan: ReturnType<typeof secretBindingPlanSummary>;
approval: ReturnType<typeof approvalSummary>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'plugin-package.secret-binding.decide';
status: 'decided' | 'existing';
approval: ReturnType<typeof approvalSummary>;
}>
| Readonly<{
schemaVersion: 1;
operation: 'plugin-package.secret-binding.inspect';
plan: ReturnType<typeof secretBindingPlanSummary> | null;
approval: ReturnType<typeof approvalSummary> | null;
stale: boolean;
}>;
export interface ClusterPluginPackageManagementTransport {
@@ -289,6 +355,7 @@ export interface ClusterPluginPackageManagementTransportOptions {
readonly service: ClusterPluginPackageManagementService;
readonly lifecycle?: ClusterPluginPackageLifecycleManagementService;
readonly publisherTrust?: ClusterPluginPackagePublisherTrustManagementService;
readonly secretBinding?: ClusterPluginPackageSecretBindingManagementService;
readonly now?: () => number;
}
@@ -528,6 +595,50 @@ export function normalizeClusterPluginPackageManagementCommand(
'publisher trust transition inspection request',
);
break;
case 'plugin-package.secret-binding.plan':
exactObject(
value.request,
['actionRef', 'assignments', 'packageName', 'projectId'],
'Secret binding plan request',
);
if (!Array.isArray(value.request.assignments)) {
throw new ClusterPluginPackageManagementTransportRequestError(
'Secret binding assignments are invalid',
);
}
for (const assignment of value.request.assignments) {
exactObject(assignment, ['name', 'secretRef'], 'Secret binding assignment');
}
break;
case 'plugin-package.secret-binding.propose':
exactObject(
value.request,
['actionRef', 'approvalAuditEventId', 'approvalRequestId'],
'Secret binding proposal request',
);
break;
case 'plugin-package.secret-binding.decide':
exactObject(
value.request,
[
'actionRef',
'approvalRequestId',
'expectedVersion',
'decisionId',
'auditEventId',
'decision',
'reasonCode',
],
'Secret binding decision request',
);
break;
case 'plugin-package.secret-binding.inspect':
exactObject(
value.request,
['actionRef', 'approvalRequestId', 'inspectionId'],
'Secret binding inspection request',
);
break;
default:
throw new ClusterPluginPackageManagementTransportRequestError(
'operation is not publicly available',
@@ -640,6 +751,26 @@ function lifecyclePlanSummary(
});
}
function secretBindingPlanSummary(
plan: Readonly<PluginPackageSecretBindingApprovalPlan>,
) {
return Object.freeze({
actionRef: plan.actionRef,
projectId: plan.bindingPlan.target.projectId,
packageName: plan.bindingPlan.target.packageName,
installationId: plan.bindingPlan.target.installationId,
generation: plan.bindingPlan.target.generation,
generationDigest: plan.bindingPlan.target.generationDigest,
lockDigest: plan.bindingPlan.target.lockDigest,
manifestDigest: plan.bindingPlan.target.manifestDigest,
entries: plan.bindingPlan.entries,
plannedAtMs: plan.bindingPlan.plannedAtMs,
expiresAtMs: plan.expiresAtMs,
planDigest: plan.bindingPlan.planDigest,
approvalPlanDigest: plan.approvalPlanDigest,
});
}
function publisherRevocationProposalSummary(
proposal: NonNullable<
InspectClusterPluginPackagePublisherRevocationResult['proposal']
@@ -694,6 +825,7 @@ function exactDecisionReplay(
| DecideClusterPluginPackageLifecycleCommand
| DecideClusterPluginPackagePublisherRevocationCommand
| DecideClusterPluginPackagePublisherTrustTransitionCommand
| DecideClusterPluginPackageSecretBindingCommand
>,
principal: Readonly<SecurityPrincipal>,
): Readonly<DecideApprovalRequestResult> | null {
@@ -726,6 +858,7 @@ export function createClusterPluginPackageManagementTransport(
key !== 'service' &&
key !== 'lifecycle' &&
key !== 'publisherTrust' &&
key !== 'secretBinding' &&
key !== 'now',
) ||
!options.service ||
@@ -745,6 +878,12 @@ export function createClusterPluginPackageManagementTransport(
typeof options.publisherTrust.propose !== 'function' ||
typeof options.publisherTrust.inspect !== 'function' ||
typeof options.publisherTrust.inspectAuthorized !== 'function')) ||
(options.secretBinding !== undefined &&
(!options.secretBinding ||
typeof options.secretBinding.plan !== 'function' ||
typeof options.secretBinding.propose !== 'function' ||
typeof options.secretBinding.decide !== 'function' ||
typeof options.secretBinding.inspectAuthorized !== 'function')) ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new ClusterPluginPackageManagementTransportConfigurationError(
@@ -941,6 +1080,78 @@ export function createClusterPluginPackageManagementTransport(
stale: result.stale,
});
}
case 'plugin-package.secret-binding.plan': {
if (!options.secretBinding) {
throw new ClusterPluginPackageManagementTransportConfigurationError(
'Secret binding management is not configured',
);
}
const result = await options.secretBinding.plan({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status: result.status,
plan: secretBindingPlanSummary(result.plan),
});
}
case 'plugin-package.secret-binding.propose': {
if (!options.secretBinding) {
throw new ClusterPluginPackageManagementTransportConfigurationError(
'Secret binding management is not configured',
);
}
const result = await options.secretBinding.propose({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
approvalStatus: result.approvalStatus,
plan: secretBindingPlanSummary(result.plan),
approval: approvalSummary(result.approvalRequest),
});
}
case 'plugin-package.secret-binding.decide': {
if (!options.secretBinding) {
throw new ClusterPluginPackageManagementTransportConfigurationError(
'Secret binding management is not configured',
);
}
const result = await options.secretBinding.decide({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status: result.status,
approval: approvalSummary(result.request),
});
}
case 'plugin-package.secret-binding.inspect': {
if (!options.secretBinding) {
throw new ClusterPluginPackageManagementTransportConfigurationError(
'Secret binding management is not configured',
);
}
const result = await options.secretBinding.inspectAuthorized({
...command.request,
principal,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
plan: result.plan ? secretBindingPlanSummary(result.plan) : null,
approval: result.approvalRequest
? approvalSummary(result.approvalRequest)
: null,
stale: result.stale,
});
}
case 'plugin-package.publisher-revocation.propose': {
if (!options.publisherTrust) {
throw new ClusterPluginPackageManagementTransportConfigurationError(
@@ -15,8 +15,11 @@ import {
import {
PluginPackageManagementAuthorizationError,
PluginPackageManagementConflictError,
PluginPackageManagementQuotaExceededError,
PluginPackageManagementRequestError,
PluginPackageManagementUnavailableError,
type PluginPackageManagementQuotaOperation,
type PluginPackageManagementQuotaPort,
} from '@qinglong/runtime-core/plugin-package-management';
import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation';
import type { PluginPackageSecretBindingAssignment } from '@qinglong/runtime-core/plugin-package-secret-binding';
@@ -111,6 +114,7 @@ export interface ClusterPluginPackageSecretBindingManagementOptions {
readonly now?: () => number;
readonly planLifetimeMs?: number;
readonly approvalLifetimeMs?: number;
readonly quota?: PluginPackageManagementQuotaPort;
}
function exact(value: unknown, keys: readonly string[], label: string): void {
@@ -226,12 +230,15 @@ export function createClusterPluginPackageSecretBindingManagementService(
key !== 'pool' &&
key !== 'now' &&
key !== 'planLifetimeMs' &&
key !== 'approvalLifetimeMs',
key !== 'approvalLifetimeMs' &&
key !== 'quota',
) ||
!options.pool ||
typeof options.pool.query !== 'function' ||
typeof options.pool.connect !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function')
(options.now !== undefined && typeof options.now !== 'function') ||
(options.quota !== undefined &&
(!options.quota || typeof options.quota.consume !== 'function'))
) {
throw new TypeError(
'cluster Plugin Package Secret binding management options are invalid',
@@ -265,6 +272,28 @@ export function createClusterPluginPackageSecretBindingManagementService(
new PostgresProjectPolicyRepository(options.pool),
);
const consumeQuota = async (
projectId: string,
principal: Readonly<SecurityPrincipal>,
operation: PluginPackageManagementQuotaOperation,
idempotencyKey: string,
): Promise<void> => {
if (!options.quota) return;
try {
await options.quota.consume({
projectId,
subject: principal.subject,
operation,
idempotencyKey,
});
} catch (error) {
if (error instanceof PluginPackageManagementQuotaExceededError) throw error;
throw new PluginPackageManagementUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
};
const authorize = async (
principalValue: SecurityPrincipal,
projectId: string,
@@ -346,6 +375,12 @@ export function createClusterPluginPackageSecretBindingManagementService(
currentTime(now),
);
const requestedActionRef = actionRef(request.actionRef);
await consumeQuota(
projectId,
authorization.principal,
'plugin-package.propose',
requestedActionRef,
);
let existingValue;
try {
existingValue = await plans.findByActionRef(requestedActionRef);
@@ -458,6 +493,12 @@ export function createClusterPluginPackageSecretBindingManagementService(
'secret.manage',
observedAtMs,
);
await consumeQuota(
plan.bindingPlan.target.projectId,
authorization.principal,
'plugin-package.propose',
approvalRequestId,
);
if (!sameSubject(plan.requestedBy, authorization.principal.subject)) {
throw new PluginPackageManagementAuthorizationError();
}
@@ -572,6 +613,12 @@ export function createClusterPluginPackageSecretBindingManagementService(
'approval.decide',
observedAtMs,
);
await consumeQuota(
approval.projectId,
authorization.principal,
'plugin-package.decide',
decisionId,
);
if (
approval.decisionId === decisionId &&
approval.decision === request.decision &&
@@ -636,19 +683,31 @@ export function createClusterPluginPackageSecretBindingManagementService(
plan?.bindingPlan.target.projectId ?? approval?.projectId;
if (!projectId) throw new PluginPackageManagementUnavailableError();
const observedAtMs = currentTime(now);
let authorization;
try {
await authorize(request.principal, projectId, 'secret.manage', observedAtMs);
authorization = await authorize(
request.principal,
projectId,
'secret.manage',
observedAtMs,
);
} catch (error) {
if (!(error instanceof PluginPackageManagementAuthorizationError)) {
throw error;
}
await authorize(
authorization = await authorize(
request.principal,
projectId,
'approval.decide',
observedAtMs,
);
}
await consumeQuota(
projectId,
authorization.principal,
'plugin-package.inspect',
request.inspectionId,
);
return Object.freeze({
plan,
approvalRequest: approval,