From e43fd607aa2911065faa6af5c0d00af34eee5d1f Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 11:54:18 +0800 Subject: [PATCH] feat(ql3): define package secret approval plans --- packages/ql3-runtime-core/package.json | 8 + .../secret-binding/approvalPlan.ts | 319 ++++++++++++++++++ ...nPackageSecretBindingApprovalPlan.test.cjs | 162 +++++++++ 3 files changed, 489 insertions(+) create mode 100644 packages/ql3-runtime-core/src/plugin-package/secret-binding/approvalPlan.ts create mode 100644 packages/ql3-runtime-core/test/pluginPackageSecretBindingApprovalPlan.test.cjs diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index 24d43707..aa0e35a9 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -89,6 +89,9 @@ "plugin-package-secret-binding-plan": [ "dist/plugin-package/secret-binding/plan.d.ts" ], + "plugin-package-secret-binding-approval-plan": [ + "dist/plugin-package/secret-binding/approvalPlan.d.ts" + ], "secret-projection": [ "dist/secret/secretProjection.d.ts" ], @@ -390,6 +393,11 @@ "require": "./dist/plugin-package/secret-binding/plan.js", "default": "./dist/plugin-package/secret-binding/plan.js" }, + "./plugin-package-secret-binding-approval-plan": { + "types": "./dist/plugin-package/secret-binding/approvalPlan.d.ts", + "require": "./dist/plugin-package/secret-binding/approvalPlan.js", + "default": "./dist/plugin-package/secret-binding/approvalPlan.js" + }, "./secret-projection": { "types": "./dist/secret/secretProjection.d.ts", "require": "./dist/secret/secretProjection.js", diff --git a/packages/ql3-runtime-core/src/plugin-package/secret-binding/approvalPlan.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/approvalPlan.ts new file mode 100644 index 00000000..3d923861 --- /dev/null +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/approvalPlan.ts @@ -0,0 +1,319 @@ +import { createHash } from 'node:crypto'; + +import { + createPluginPackageSecretBindingFromEntries, + type PluginPackageSecretBinding, +} from './binding'; +import { + normalizePluginPackageSecretBindingPlan, + type PluginPackageSecretBindingPlan, +} from './plan'; +import type { ApprovedActionBinding } from '../../approved-action/approvedAction'; +import type { SecuritySubject } from '../../security/security'; + +export const PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA = + 'qinglong/plugin-package-secret-binding-approval-plan@v1' as const; +export const PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE = + 'plugin_package.secret_binding.bind' as const; +export const PLUGIN_PACKAGE_SECRET_BINDING_PERMISSION = + 'secret.manage' as const; +export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_LIFETIME_MS = + 15 * 60 * 1000; +export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_JSON_BYTES = + 96 * 1024; + +export interface PluginPackageSecretBindingApprovalPlan { + readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA; + readonly actionRef: string; + readonly bindingPlan: Readonly; + readonly requestedBy: Readonly; + readonly expiresAtMs: number; + readonly approvalPlanDigest: string; +} + +export interface CreatePluginPackageSecretBindingApprovalPlanInput { + readonly actionRef: string; + readonly bindingPlan: Readonly; + readonly requestedBy: SecuritySubject; + readonly expiresAtMs: number; +} + +export interface CreatePluginPackageSecretBindingApprovalPlanResult { + readonly status: 'created' | 'existing'; + readonly plan: Readonly; +} + +export interface PluginPackageSecretBindingApprovalPlanRepository { + create( + plan: Readonly, + ): Promise>; + findByActionRef( + actionRef: string, + ): Promise | null>; +} + +export class InvalidPluginPackageSecretBindingApprovalPlanError extends TypeError { + readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_INVALID'; + + constructor(message: string) { + super(`Plugin Package Secret binding approval plan is invalid: ${message}`); + this.name = 'InvalidPluginPackageSecretBindingApprovalPlanError'; + } +} + +export class PluginPackageSecretBindingApprovalPlanConflictError extends Error { + readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_CONFLICT'; + + constructor(message: string) { + super( + `Plugin Package Secret binding approval plan conflicts with durable state: ${message}`, + ); + this.name = 'PluginPackageSecretBindingApprovalPlanConflictError'; + } +} + +export class PluginPackageSecretBindingApprovalPlanUnavailableError extends Error { + readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_UNAVAILABLE'; + + constructor(options?: ErrorOptions) { + super( + 'Plugin Package Secret binding approval plan is unavailable', + options, + ); + this.name = 'PluginPackageSecretBindingApprovalPlanUnavailableError'; + } +} + +const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; +const SUBJECT_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const APPROVAL_PLAN_DIGEST_DOMAIN = Buffer.from( + 'qinglong/plugin-package-secret-binding-approval-plan-digest@v1\0', + 'utf8', +); + +function invalid(message: string): never { + throw new InvalidPluginPackageSecretBindingApprovalPlanError(message); +} + +function record(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + return invalid(`${label} must be an object`); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + Object.values(descriptors).some( + (descriptor) => + descriptor.get !== undefined || + descriptor.set !== undefined || + descriptor.enumerable !== true, + ) + ) { + return invalid(`${label} must contain enumerable data properties`); + } + return value as Record; +} + +function exactKeys( + value: object, + expected: readonly string[], + label: string, +): void { + const keys = Reflect.ownKeys(value); + const canonical = [...expected].sort(); + if ( + keys.some((key) => typeof key !== 'string') || + keys.length !== canonical.length || + keys + .map(String) + .sort() + .some((key, index) => key !== canonical[index]) + ) { + invalid(`${label} shape is invalid`); + } +} + +function actionRef(value: unknown): string { + if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) { + return invalid('actionRef is invalid'); + } + return value; +} + +function timestamp(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + return invalid(`${label} is invalid`); + } + return value as number; +} + +function requestedBy(value: SecuritySubject): Readonly { + const candidate = record(value, 'requestedBy'); + exactKeys(candidate, ['id', 'type'], 'requestedBy'); + if ( + value.type !== 'user' || + typeof value.id !== 'string' || + value.id.length < 1 || + Buffer.byteLength(value.id, 'utf8') > 255 || + SUBJECT_CONTROL_PATTERN.test(value.id) + ) { + return invalid('requestedBy must be a User subject'); + } + return Object.freeze({ type: 'user', id: value.id }); +} + +function fields( + value: Omit, +): object { + return { + schema: value.schema, + actionRef: value.actionRef, + bindingPlan: value.bindingPlan, + requestedBy: value.requestedBy, + expiresAtMs: value.expiresAtMs, + }; +} + +export function pluginPackageSecretBindingApprovalPlanDigest( + value: Omit, +): string { + return createHash('sha256') + .update(APPROVAL_PLAN_DIGEST_DOMAIN) + .update(JSON.stringify(fields(value)), 'utf8') + .digest('hex'); +} + +function normalizedWithoutDigest( + value: Omit, +): Omit { + if (value.schema !== PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA) { + return invalid('schema is invalid'); + } + record(value.bindingPlan, 'bindingPlan'); + const bindingPlan = normalizePluginPackageSecretBindingPlan( + value.bindingPlan, + ); + const expiresAtMs = timestamp(value.expiresAtMs, 'expiresAtMs'); + if ( + expiresAtMs <= bindingPlan.plannedAtMs || + expiresAtMs - bindingPlan.plannedAtMs > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_LIFETIME_MS + ) { + return invalid('lifetime is invalid'); + } + return Object.freeze({ + schema: PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA, + actionRef: actionRef(value.actionRef), + bindingPlan, + requestedBy: requestedBy(value.requestedBy), + expiresAtMs, + }); +} + +function boundedPlan( + value: Readonly, +): Readonly { + if ( + Buffer.byteLength(JSON.stringify(value), 'utf8') > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_JSON_BYTES + ) { + return invalid('encoded plan exceeds the size limit'); + } + return value; +} + +export function createPluginPackageSecretBindingApprovalPlan( + input: CreatePluginPackageSecretBindingApprovalPlanInput, +): Readonly { + const candidate = record(input, 'approval plan input'); + exactKeys( + candidate, + ['actionRef', 'bindingPlan', 'expiresAtMs', 'requestedBy'], + 'approval plan input', + ); + const unsigned = normalizedWithoutDigest({ + schema: PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA, + actionRef: input.actionRef, + bindingPlan: input.bindingPlan, + requestedBy: input.requestedBy, + expiresAtMs: input.expiresAtMs, + }); + return boundedPlan( + Object.freeze({ + ...unsigned, + approvalPlanDigest: + pluginPackageSecretBindingApprovalPlanDigest(unsigned), + }), + ); +} + +export function normalizePluginPackageSecretBindingApprovalPlan( + value: PluginPackageSecretBindingApprovalPlan, +): Readonly { + const candidate = record(value, 'approval plan'); + exactKeys( + candidate, + [ + 'actionRef', + 'approvalPlanDigest', + 'bindingPlan', + 'expiresAtMs', + 'requestedBy', + 'schema', + ], + 'approval plan', + ); + const unsigned = normalizedWithoutDigest(value); + const approvalPlanDigest = + pluginPackageSecretBindingApprovalPlanDigest(unsigned); + if ( + typeof value.approvalPlanDigest !== 'string' || + !DIGEST_PATTERN.test(value.approvalPlanDigest) || + value.approvalPlanDigest !== approvalPlanDigest + ) { + return invalid('approvalPlanDigest does not match approval plan'); + } + return boundedPlan(Object.freeze({ ...unsigned, approvalPlanDigest })); +} + +export function pluginPackageSecretBindingApprovedAction( + value: PluginPackageSecretBindingApprovalPlan, +): Readonly { + const plan = normalizePluginPackageSecretBindingApprovalPlan(value); + return Object.freeze({ + permission: PLUGIN_PACKAGE_SECRET_BINDING_PERMISSION, + actionType: PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE, + actionRef: plan.actionRef, + actionDigest: plan.approvalPlanDigest, + previewDigest: plan.bindingPlan.planDigest, + }); +} + +export function createPluginPackageSecretBindingFromApprovalPlan( + planValue: PluginPackageSecretBindingApprovalPlan, + boundAtMsValue: number, +): Readonly { + const plan = normalizePluginPackageSecretBindingApprovalPlan(planValue); + const boundAtMs = timestamp(boundAtMsValue, 'boundAtMs'); + if ( + boundAtMs < plan.bindingPlan.plannedAtMs || + boundAtMs > plan.expiresAtMs + ) { + return invalid('boundAtMs is outside the approved lifetime'); + } + return createPluginPackageSecretBindingFromEntries({ + target: plan.bindingPlan.target, + entries: plan.bindingPlan.entries, + authority: Object.freeze({ + kind: 'approved-action-execution', + evidenceDigest: plan.approvalPlanDigest, + }), + boundAtMs, + }); +} diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBindingApprovalPlan.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBindingApprovalPlan.test.cjs new file mode 100644 index 00000000..a9e8e65c --- /dev/null +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBindingApprovalPlan.test.cjs @@ -0,0 +1,162 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + InvalidPluginPackageSecretBindingApprovalPlanError, + createPluginPackageSecretBindingApprovalPlan, + createPluginPackageSecretBindingFromApprovalPlan, + normalizePluginPackageSecretBindingApprovalPlan, + pluginPackageSecretBindingApprovedAction, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'); +const { + createPluginPackageSecretBindingPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); + +const manifest = { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'Secret binding approval plan fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, +}; + +const generation = createPluginPackageResourceGeneration({ + installationId: 'install-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: manifest.spec.contents, +}); + +function approvalPlan() { + const bindingPlan = createPluginPackageSecretBindingPlan({ + generation, + manifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }, + ], + plannedAtMs: 10_000, + }); + return createPluginPackageSecretBindingApprovalPlan({ + actionRef: 'secret-binding:example-monitor-v1', + bindingPlan, + requestedBy: { type: 'user', id: 'cluster-owner' }, + expiresAtMs: 20_000, + }); +} + +test('binds one short-lived Cluster approval to the exact Secret plan', () => { + const plan = approvalPlan(); + assert.deepEqual(normalizePluginPackageSecretBindingApprovalPlan(plan), plan); + assert.match(plan.approvalPlanDigest, /^[0-9a-f]{64}$/); + assert.equal(JSON.stringify(plan).includes('secret-value'), false); + assert.equal(Object.isFrozen(plan), true); + + const action = pluginPackageSecretBindingApprovedAction(plan); + assert.deepEqual(action, { + permission: 'secret.manage', + actionType: 'plugin_package.secret_binding.bind', + actionRef: plan.actionRef, + actionDigest: plan.approvalPlanDigest, + previewDigest: plan.bindingPlan.planDigest, + }); +}); + +test('materializes only the exact approved lifetime and evidence', () => { + const plan = approvalPlan(); + const binding = createPluginPackageSecretBindingFromApprovalPlan( + plan, + 15_000, + ); + assert.equal(binding.authority.kind, 'approved-action-execution'); + assert.equal(binding.authority.evidenceDigest, plan.approvalPlanDigest); + assert.equal(binding.boundAtMs, 15_000); + assert.deepEqual(binding.target, plan.bindingPlan.target); + assert.deepEqual(binding.entries, plan.bindingPlan.entries); + assert.throws( + () => createPluginPackageSecretBindingFromApprovalPlan(plan, 20_001), + InvalidPluginPackageSecretBindingApprovalPlanError, + ); +}); + +test('rejects digest drift, weak subjects and unbounded lifetime', () => { + const plan = approvalPlan(); + assert.throws( + () => + normalizePluginPackageSecretBindingApprovalPlan({ + ...plan, + approvalPlanDigest: 'f'.repeat(64), + }), + InvalidPluginPackageSecretBindingApprovalPlanError, + ); + assert.throws( + () => + createPluginPackageSecretBindingApprovalPlan({ + actionRef: plan.actionRef, + bindingPlan: plan.bindingPlan, + requestedBy: { type: 'system', id: 'executor' }, + expiresAtMs: plan.expiresAtMs, + }), + InvalidPluginPackageSecretBindingApprovalPlanError, + ); + assert.throws( + () => + createPluginPackageSecretBindingApprovalPlan({ + actionRef: plan.actionRef, + bindingPlan: plan.bindingPlan, + requestedBy: plan.requestedBy, + expiresAtMs: plan.bindingPlan.plannedAtMs + 15 * 60 * 1000 + 1, + }), + InvalidPluginPackageSecretBindingApprovalPlanError, + ); +}); + +test('exports the approval contract only through its explicit subpath', () => { + assert.equal( + require('../dist').createPluginPackageSecretBindingApprovalPlan, + undefined, + ); + assert.equal( + require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan') + .createPluginPackageSecretBindingApprovalPlan, + createPluginPackageSecretBindingApprovalPlan, + ); +});