mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): gate local secret transitions before activation
This commit is contained in:
@@ -1142,6 +1142,41 @@ export class LocalSqlitePluginPackageInstallRepository
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install."installation_id" = head."installation_id"
|
||||
WHERE install."state" IN ('queued','staged','activating')
|
||||
AND NOT (
|
||||
install."state" = 'staged' AND
|
||||
install."previous_active_lock_digest" IS NOT NULL AND
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageSecretBindings" AS previous_binding
|
||||
JOIN "QingLong3PluginPackageInstalls" AS previous_install
|
||||
ON previous_install.project_id = install.project_id
|
||||
AND previous_install.package_name = install.package_name
|
||||
AND previous_install.lock_digest =
|
||||
install.previous_active_lock_digest
|
||||
WHERE previous_binding.installation_id =
|
||||
previous_install.installation_id
|
||||
) OR
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageAdmissionReceipts" AS admission
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS proposal
|
||||
ON proposal.action_ref = admission.action_ref
|
||||
WHERE admission.installation_id = install.installation_id
|
||||
AND json_array_length(
|
||||
json_extract(
|
||||
proposal.proposal_json,
|
||||
'$.actionInput.manifest.spec.permissions.secrets'
|
||||
)
|
||||
) > 0
|
||||
)
|
||||
) AND
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageSecretBindingTransitionReceipts" AS transition_receipt
|
||||
WHERE transition_receipt.installation_id = install.installation_id
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
normalizePluginPackageInstallProposal,
|
||||
type PluginPackageInstallProposal,
|
||||
} from '@qinglong/runtime-core/plugin-package-proposal';
|
||||
import {
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
normalizePluginPackageInstallRecord,
|
||||
normalizePluginPackageLock,
|
||||
type PluginPackageInstallRecord,
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import type {
|
||||
PluginPackageActivationPrerequisite,
|
||||
PluginPackageActivationPrerequisiteObservation,
|
||||
} from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import { createPluginPackageSecretBindingTarget } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import { LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository } from './transitionReceiptRepository';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Secret binding activation prerequisite is unavailable');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageSecretBindingActivationPrerequisite
|
||||
implements PluginPackageActivationPrerequisite
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
readonly #receipts: LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.#receipts =
|
||||
new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository(
|
||||
this.#authority,
|
||||
);
|
||||
}
|
||||
|
||||
async inspect(
|
||||
recordValue: Readonly<PluginPackageInstallRecord>,
|
||||
lockValue: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
|
||||
const record = normalizePluginPackageInstallRecord(recordValue);
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
if (record.state !== 'staged') {
|
||||
throw new Error(
|
||||
'Secret binding activation prerequisite requires staged install',
|
||||
);
|
||||
}
|
||||
if (record.previousActiveLockDigest === null) {
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
}
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT proposal.proposal_json AS "proposalJson",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageInstalls" AS previous
|
||||
JOIN "QingLong3PluginPackageSecretBindings" AS binding
|
||||
ON binding.installation_id = previous.installation_id
|
||||
WHERE previous.project_id = install.project_id
|
||||
AND previous.package_name = install.package_name
|
||||
AND previous.lock_digest = install.previous_active_lock_digest
|
||||
) AS "previousBindingPresent"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
JOIN "QingLong3PluginPackageAdmissionReceipts" AS admission
|
||||
ON admission.installation_id = install.installation_id
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS proposal
|
||||
ON proposal.action_ref = admission.action_ref
|
||||
WHERE head.project_id = ? AND head.package_name = ?
|
||||
AND install.installation_id = ? AND install.lock_digest = ?
|
||||
AND install.state = 'staged'
|
||||
AND install.previous_active_lock_digest = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(
|
||||
record.projectId,
|
||||
record.packageName,
|
||||
record.installationId,
|
||||
record.lockDigest,
|
||||
record.previousActiveLockDigest,
|
||||
) as Row[];
|
||||
if (row.length !== 1) {
|
||||
throw new Error(
|
||||
'Secret binding activation prerequisite is unavailable',
|
||||
);
|
||||
}
|
||||
const proposal = normalizePluginPackageInstallProposal(
|
||||
JSON.parse(
|
||||
text(row[0]!, 'proposalJson'),
|
||||
) as PluginPackageInstallProposal,
|
||||
);
|
||||
if (
|
||||
proposal.actionDigest !== lock.approval.actionDigest ||
|
||||
proposal.previewDigest !== lock.approval.previewDigest ||
|
||||
proposal.actionInput.targetGeneration !== record.targetGeneration ||
|
||||
proposal.actionInput.source.contentDigest !==
|
||||
lock.source.contentDigest
|
||||
) {
|
||||
throw new Error(
|
||||
'Secret binding activation prerequisite provenance drift',
|
||||
);
|
||||
}
|
||||
const required =
|
||||
proposal.actionInput.manifest.spec.permissions.secrets.length > 0 ||
|
||||
row[0]!.previousBindingPresent === 1;
|
||||
if (!required) return Object.freeze({ status: 'ready' as const });
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: record.installationId,
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
lockDigest: record.lockDigest,
|
||||
generation: record.targetGeneration,
|
||||
previousActiveLockDigest: record.previousActiveLockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
const target = createPluginPackageSecretBindingTarget(
|
||||
generation,
|
||||
proposal.actionInput.manifest,
|
||||
);
|
||||
const receipt = this.#receipts.findInTransaction(
|
||||
target.generationDigest,
|
||||
);
|
||||
if (
|
||||
!receipt ||
|
||||
JSON.stringify(receipt.transitionPlan.nextTarget) !==
|
||||
JSON.stringify(target) ||
|
||||
receipt.transitionPlan.previousActiveLockDigest !==
|
||||
record.previousActiveLockDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'deferred' as const,
|
||||
reason: 'secret_binding_transition_required' as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
},
|
||||
() => new Error('Secret binding activation prerequisite is unavailable'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,22 @@ import {
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import { createPluginPackageSecretBindingTarget } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
import {
|
||||
createPluginPackageSecretBindingFromPlan,
|
||||
createPluginPackageSecretBindingPlan,
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
type PluginPackageSecretBindingPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-plan';
|
||||
import {
|
||||
createPluginPackageSecretBindingTransitionPlan,
|
||||
normalizePluginPackageSecretBindingTransitionPlan,
|
||||
type PluginPackageSecretBindingTransitionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan';
|
||||
import {
|
||||
createPluginPackageSecretBindingFromTransitionPlan,
|
||||
createPluginPackageSecretBindingTransitionReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
|
||||
import { parseSecretRef } from '@qinglong/runtime-core/secret-reference';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
@@ -35,6 +45,7 @@ import {
|
||||
sameSecurityAuditSemantic,
|
||||
} from '../../security/securityPersistence';
|
||||
import { LocalSqlitePluginPackageSecretBindingRepository } from './repository';
|
||||
import { LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository } from './transitionReceiptRepository';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
@@ -61,6 +72,24 @@ export interface ExecuteLocalPluginPackageSecretBindingRequest {
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PlanLocalPluginPackageSecretBindingTransitionRequest {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly assignments: readonly Readonly<{
|
||||
name: string;
|
||||
secretRef: string | null;
|
||||
}>[];
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly plannedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageSecretBindingTransitionRequest {
|
||||
readonly plan: PluginPackageSecretBindingTransitionPlan;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackageSecretBindingService {
|
||||
plan(
|
||||
request: PlanLocalPluginPackageSecretBindingRequest,
|
||||
@@ -72,6 +101,20 @@ export interface LocalPluginPackageSecretBindingService {
|
||||
generationDigest: string;
|
||||
}>
|
||||
>;
|
||||
planTransition(
|
||||
request: PlanLocalPluginPackageSecretBindingTransitionRequest,
|
||||
): Promise<Readonly<PluginPackageSecretBindingTransitionPlan>>;
|
||||
executeTransition(
|
||||
request: ExecuteLocalPluginPackageSecretBindingTransitionRequest,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
transitionDigest: string;
|
||||
receiptDigest: string;
|
||||
bindingDigest: string | null;
|
||||
generationDigest: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export class LocalPluginPackageSecretBindingConflictError extends Error {
|
||||
@@ -213,6 +256,145 @@ function generationFrom(current: ReturnType<typeof loadCurrent>) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadTransition(
|
||||
client: DatabaseSync,
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Readonly<{
|
||||
previous: ReturnType<typeof loadCurrent>;
|
||||
next: ReturnType<typeof loadCurrent>;
|
||||
previousAttemptGeneration: number;
|
||||
}> {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT current.record_json AS "nextRecordJson",
|
||||
current.lock_json AS "nextLockJson",
|
||||
current_proposal.proposal_json AS "nextProposalJson",
|
||||
previous.record_json AS "previousRecordJson",
|
||||
previous.lock_json AS "previousLockJson",
|
||||
previous_proposal.proposal_json AS "previousProposalJson",
|
||||
(
|
||||
SELECT MAX(history.target_generation)
|
||||
FROM "QingLong3PluginPackageInstalls" AS history
|
||||
WHERE history.project_id = current.project_id
|
||||
AND history.package_name = current.package_name
|
||||
AND history.target_generation < current.target_generation
|
||||
) AS "previousAttemptGeneration"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS current
|
||||
ON current.installation_id = head.installation_id
|
||||
JOIN "QingLong3PluginPackageAdmissionReceipts" AS current_admission
|
||||
ON current_admission.installation_id = current.installation_id
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS current_proposal
|
||||
ON current_proposal.action_ref = current_admission.action_ref
|
||||
JOIN "QingLong3PluginPackageInstalls" AS previous
|
||||
ON previous.project_id = current.project_id
|
||||
AND previous.package_name = current.package_name
|
||||
AND previous.lock_digest = current.previous_active_lock_digest
|
||||
JOIN "QingLong3PluginPackageAdmissionReceipts" AS previous_admission
|
||||
ON previous_admission.installation_id = previous.installation_id
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS previous_proposal
|
||||
ON previous_proposal.action_ref = previous_admission.action_ref
|
||||
WHERE head.project_id = ?
|
||||
AND head.package_name = ?
|
||||
AND current.state = 'staged'
|
||||
AND current.previous_active_lock_digest IS NOT NULL
|
||||
AND current.active_lock_digest = current.previous_active_lock_digest
|
||||
AND current.target_generation = (
|
||||
SELECT MAX(latest.target_generation)
|
||||
FROM "QingLong3PluginPackageInstalls" AS latest
|
||||
WHERE latest.project_id = current.project_id
|
||||
AND latest.package_name = current.package_name
|
||||
)
|
||||
AND previous.state = 'active'
|
||||
AND previous.active_lock_digest = previous.lock_digest
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(projectId, packageName) as Row[];
|
||||
if (rows.length !== 1) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'reviewed staged Package generation is absent or ambiguous',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const row = rows[0]!;
|
||||
const next = Object.freeze({
|
||||
record: normalizePluginPackageInstallRecord(
|
||||
JSON.parse(rowText(row, 'nextRecordJson')),
|
||||
),
|
||||
lock: normalizePluginPackageLock(
|
||||
JSON.parse(rowText(row, 'nextLockJson')),
|
||||
),
|
||||
proposal: normalizePluginPackageInstallProposal(
|
||||
JSON.parse(rowText(row, 'nextProposalJson')),
|
||||
),
|
||||
});
|
||||
const previous = Object.freeze({
|
||||
record: normalizePluginPackageInstallRecord(
|
||||
JSON.parse(rowText(row, 'previousRecordJson')),
|
||||
),
|
||||
lock: normalizePluginPackageLock(
|
||||
JSON.parse(rowText(row, 'previousLockJson')),
|
||||
),
|
||||
proposal: normalizePluginPackageInstallProposal(
|
||||
JSON.parse(rowText(row, 'previousProposalJson')),
|
||||
),
|
||||
});
|
||||
const previousAttemptGeneration = row.previousAttemptGeneration;
|
||||
if (
|
||||
!Number.isSafeInteger(previousAttemptGeneration) ||
|
||||
previousAttemptGeneration !== next.record.targetGeneration - 1 ||
|
||||
next.record.projectId !== projectId ||
|
||||
next.record.packageName !== packageName ||
|
||||
next.record.lockDigest !== next.lock.lockDigest ||
|
||||
next.record.previousActiveLockDigest !== previous.lock.lockDigest ||
|
||||
next.proposal.actionDigest !== next.lock.approval.actionDigest ||
|
||||
next.proposal.previewDigest !== next.lock.approval.previewDigest ||
|
||||
next.proposal.actionInput.targetGeneration !==
|
||||
next.record.targetGeneration ||
|
||||
next.proposal.actionInput.manifest.metadata.name !== packageName ||
|
||||
next.proposal.actionInput.source.contentDigest !==
|
||||
next.lock.source.contentDigest ||
|
||||
previous.record.lockDigest !== previous.lock.lockDigest ||
|
||||
previous.proposal.actionDigest !== previous.lock.approval.actionDigest ||
|
||||
previous.proposal.previewDigest !==
|
||||
previous.lock.approval.previewDigest ||
|
||||
previous.proposal.actionInput.targetGeneration !==
|
||||
previous.record.targetGeneration ||
|
||||
previous.proposal.actionInput.source.contentDigest !==
|
||||
previous.lock.source.contentDigest
|
||||
) {
|
||||
throw new Error('Package transition provenance drift');
|
||||
}
|
||||
return Object.freeze({
|
||||
previous,
|
||||
next,
|
||||
previousAttemptGeneration: previousAttemptGeneration as number,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackageSecretBindingConflictError)
|
||||
throw error;
|
||||
throw new LocalPluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function transitionGeneration(
|
||||
value: ReturnType<typeof loadTransition>['next'],
|
||||
) {
|
||||
return createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: value.record.installationId,
|
||||
projectId: value.record.projectId,
|
||||
packageName: value.record.packageName,
|
||||
lockDigest: value.record.lockDigest,
|
||||
generation: value.record.targetGeneration,
|
||||
previousActiveLockDigest: value.record.previousActiveLockDigest,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
resources: value.lock.resources,
|
||||
});
|
||||
}
|
||||
|
||||
function auditRecord(
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
eventId: string,
|
||||
@@ -234,6 +416,27 @@ function auditRecord(
|
||||
});
|
||||
}
|
||||
|
||||
function transitionAuditRecord(
|
||||
plan: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
eventId: string,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: `package_secret_binding_transition:${plan.transitionDigest}`,
|
||||
operationId: 'plugin_package.secret.transition',
|
||||
projectId: plan.nextTarget.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze([`owner_confirmed_secret_${plan.kind}`]),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function verifySecretVersions(
|
||||
client: DatabaseSync,
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
@@ -327,6 +530,10 @@ export function createLocalPluginPackageSecretBindingService(
|
||||
const bindings = new LocalSqlitePluginPackageSecretBindingRepository(
|
||||
authority,
|
||||
);
|
||||
const transitionReceipts =
|
||||
new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository(
|
||||
authority,
|
||||
);
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
@@ -538,5 +745,250 @@ export function createLocalPluginPackageSecretBindingService(
|
||||
() => new LocalPluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
},
|
||||
|
||||
async planTransition(
|
||||
request: PlanLocalPluginPackageSecretBindingTransitionRequest,
|
||||
) {
|
||||
const projectId = identity(request.projectId, 'Project ID', IDENTIFIER);
|
||||
const packageName = identity(
|
||||
request.packageName,
|
||||
'Package name',
|
||||
PACKAGE_NAME,
|
||||
);
|
||||
const plannedAtMs = timestamp(request.plannedAtMs, 'plannedAtMs');
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
plannedAtMs,
|
||||
);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
verifyPolicyFence(
|
||||
authority.client,
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
projectId,
|
||||
);
|
||||
const transition = loadTransition(
|
||||
authority.client,
|
||||
projectId,
|
||||
packageName,
|
||||
);
|
||||
const previousGeneration = generationFrom(transition.previous);
|
||||
const previousTarget = createPluginPackageSecretBindingTarget(
|
||||
previousGeneration,
|
||||
transition.previous.proposal.actionInput.manifest,
|
||||
);
|
||||
const previousBinding = bindings.findInTransaction(
|
||||
previousTarget.generationDigest,
|
||||
);
|
||||
if (
|
||||
transitionReceipts.findInTransaction(
|
||||
transitionGeneration(transition.next).generationDigest,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'staged generation transition is already committed',
|
||||
);
|
||||
}
|
||||
const plan = createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget,
|
||||
previousBinding,
|
||||
previousAttemptGeneration: transition.previousAttemptGeneration,
|
||||
nextGeneration: transitionGeneration(transition.next),
|
||||
nextManifest: transition.next.proposal.actionInput.manifest,
|
||||
assignments: request.assignments,
|
||||
plannedAtMs,
|
||||
});
|
||||
if (plan.nextBindingPlan) {
|
||||
verifySecretVersions(authority.client, plan.nextBindingPlan);
|
||||
}
|
||||
return plan;
|
||||
},
|
||||
() => new LocalPluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
},
|
||||
|
||||
async executeTransition(
|
||||
request: ExecuteLocalPluginPackageSecretBindingTransitionRequest,
|
||||
) {
|
||||
const plan = normalizePluginPackageSecretBindingTransitionPlan(
|
||||
request.plan,
|
||||
);
|
||||
if (typeof request.confirmAuthorization !== 'function') {
|
||||
throw new TypeError('confirmAuthorization is invalid');
|
||||
}
|
||||
const observedAtMs = timestamp(now(), 'transition execution clock');
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
plan.nextTarget.projectId,
|
||||
observedAtMs,
|
||||
);
|
||||
await request.confirmAuthorization();
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
authority.client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
verifyPolicyFence(
|
||||
authority.client,
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
plan.nextTarget.projectId,
|
||||
);
|
||||
const auditEventId = identity(
|
||||
request.auditEventId,
|
||||
'audit event ID',
|
||||
UUID,
|
||||
);
|
||||
const auditsForPlan = authority.client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "request_id" = ? AND "operation_id" = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(
|
||||
`package_secret_binding_transition:${plan.transitionDigest}`,
|
||||
'plugin_package.secret.transition',
|
||||
) as Row[];
|
||||
if (
|
||||
auditsForPlan.length > 1 ||
|
||||
(auditsForPlan.length === 1 &&
|
||||
rowText(auditsForPlan[0]!, 'eventId') !== auditEventId)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'transition plan already has another audit identity',
|
||||
);
|
||||
}
|
||||
const existingReceipt = transitionReceipts.findInTransaction(
|
||||
plan.nextTarget.generationDigest,
|
||||
);
|
||||
if (existingReceipt) {
|
||||
if (
|
||||
existingReceipt.transitionPlan.transitionDigest !==
|
||||
plan.transitionDigest ||
|
||||
existingReceipt.authority.kind !== 'local-owner-confirmation' ||
|
||||
existingReceipt.authority.evidenceDigest !==
|
||||
plan.transitionDigest ||
|
||||
auditsForPlan.length !== 1
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'generation is committed by another transition authority',
|
||||
);
|
||||
}
|
||||
authority.client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
transitionDigest: plan.transitionDigest,
|
||||
receiptDigest: existingReceipt.receiptDigest,
|
||||
bindingDigest: existingReceipt.bindingDigest,
|
||||
generationDigest: plan.nextTarget.generationDigest,
|
||||
});
|
||||
}
|
||||
const transition = loadTransition(
|
||||
authority.client,
|
||||
plan.nextTarget.projectId,
|
||||
plan.nextTarget.packageName,
|
||||
);
|
||||
const previousTarget = createPluginPackageSecretBindingTarget(
|
||||
generationFrom(transition.previous),
|
||||
transition.previous.proposal.actionInput.manifest,
|
||||
);
|
||||
const expected = createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget,
|
||||
previousBinding: bindings.findInTransaction(
|
||||
previousTarget.generationDigest,
|
||||
),
|
||||
previousAttemptGeneration: transition.previousAttemptGeneration,
|
||||
nextGeneration: transitionGeneration(transition.next),
|
||||
nextManifest: transition.next.proposal.actionInput.manifest,
|
||||
assignments:
|
||||
plan.nextBindingPlan?.entries.map(({ name, secretRef }) => ({
|
||||
name,
|
||||
secretRef,
|
||||
})) ?? [],
|
||||
plannedAtMs: plan.nextBindingPlan?.plannedAtMs ?? observedAtMs,
|
||||
});
|
||||
if (expected.transitionDigest !== plan.transitionDigest) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'staged Package generation changed after transition planning',
|
||||
);
|
||||
}
|
||||
if (plan.nextBindingPlan) {
|
||||
verifySecretVersions(authority.client, plan.nextBindingPlan);
|
||||
}
|
||||
const audit = transitionAuditRecord(
|
||||
plan,
|
||||
auditEventId,
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
);
|
||||
const existingAudit = authority.client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ? LIMIT 2`,
|
||||
)
|
||||
.get(audit.eventId) as Row | undefined;
|
||||
if (existingAudit) {
|
||||
if (
|
||||
!sameSecurityAuditSemantic(
|
||||
localSecurityAuditFromRow(existingAudit),
|
||||
audit,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'audit identity is already used by another operation',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
insertLocalSecurityAudit(authority.client, audit);
|
||||
}
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'local-owner-confirmation',
|
||||
plan.transitionDigest,
|
||||
observedAtMs,
|
||||
);
|
||||
const bindingResult = binding
|
||||
? bindings.publishInTransaction(binding)
|
||||
: null;
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding: bindingResult?.binding ?? null,
|
||||
committedAtMs: observedAtMs,
|
||||
});
|
||||
const receiptResult =
|
||||
transitionReceipts.publishInTransaction(receipt);
|
||||
authority.client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: receiptResult.status,
|
||||
transitionDigest: plan.transitionDigest,
|
||||
receiptDigest: receiptResult.receipt.receiptDigest,
|
||||
bindingDigest: receiptResult.receipt.bindingDigest,
|
||||
generationDigest: plan.nextTarget.generationDigest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (authority.client.isTransaction)
|
||||
authority.client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof LocalPluginPackageSecretBindingConflictError ||
|
||||
error instanceof LocalPluginPackageSecretBindingUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalPluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => new LocalPluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES,
|
||||
normalizePluginPackageSecretBindingTransitionReceipt,
|
||||
type PluginPackageSecretBindingTransitionReceipt,
|
||||
type PluginPackageSecretBindingTransitionReceiptRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
|
||||
import {
|
||||
PluginPackageSecretBindingConflictError,
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
throw new TypeError(
|
||||
'Secret binding transition generation digest is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value !== null && typeof value !== 'string') {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackageSecretBindingConflictError ||
|
||||
error instanceof PluginPackageSecretBindingUnavailableError ||
|
||||
error instanceof TypeError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageSecretBindingConflictError(
|
||||
'durable transition receipt identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository
|
||||
implements PluginPackageSecretBindingTransitionReceiptRepository
|
||||
{
|
||||
constructor(
|
||||
readonly authority: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
private parse(
|
||||
row: Row,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
try {
|
||||
const receipt = normalizePluginPackageSecretBindingTransitionReceipt(
|
||||
JSON.parse(text(row, 'receiptJson')),
|
||||
);
|
||||
if (
|
||||
receipt.transitionPlan.nextTarget.generationDigest !==
|
||||
text(row, 'generationDigest') ||
|
||||
receipt.transitionPlan.transitionDigest !==
|
||||
text(row, 'transitionDigest') ||
|
||||
receipt.transitionPlan.nextTarget.projectId !==
|
||||
text(row, 'projectId') ||
|
||||
receipt.transitionPlan.nextTarget.packageName !==
|
||||
text(row, 'packageName') ||
|
||||
receipt.transitionPlan.nextTarget.installationId !==
|
||||
text(row, 'installationId') ||
|
||||
receipt.transitionPlan.nextTarget.lockDigest !==
|
||||
text(row, 'lockDigest') ||
|
||||
receipt.transitionPlan.nextTarget.generation !==
|
||||
integer(row, 'generation') ||
|
||||
receipt.transitionPlan.nextTarget.manifestDigest !==
|
||||
text(row, 'manifestDigest') ||
|
||||
receipt.transitionPlan.previousActiveLockDigest !==
|
||||
text(row, 'previousActiveLockDigest') ||
|
||||
receipt.authority.kind !== text(row, 'authorityKind') ||
|
||||
receipt.authority.evidenceDigest !== text(row, 'evidenceDigest') ||
|
||||
receipt.bindingDigest !== nullableText(row, 'bindingDigest') ||
|
||||
receipt.committedAtMs !== integer(row, 'committedAtMs') ||
|
||||
receipt.receiptDigest !== text(row, 'receiptDigest')
|
||||
) {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageSecretBindingUnavailableError)
|
||||
throw error;
|
||||
throw new PluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private findStored(
|
||||
generationDigest: string,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> | null {
|
||||
const row = (this.authority as LocalSqliteOperationAuthority).client
|
||||
.prepare(
|
||||
`SELECT generation_digest AS "generationDigest",
|
||||
transition_digest AS "transitionDigest",
|
||||
project_id AS "projectId", package_name AS "packageName",
|
||||
installation_id AS "installationId", lock_digest AS "lockDigest",
|
||||
generation, manifest_digest AS "manifestDigest",
|
||||
previous_active_lock_digest AS "previousActiveLockDigest",
|
||||
authority_kind AS "authorityKind", evidence_digest AS "evidenceDigest",
|
||||
binding_digest AS "bindingDigest", committed_at_ms AS "committedAtMs",
|
||||
receipt_digest AS "receiptDigest", receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageSecretBindingTransitionReceipts"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(generationDigest) as Row | undefined;
|
||||
return row ? this.parse(row) : null;
|
||||
}
|
||||
|
||||
findInTransaction(
|
||||
generationDigestValue: string,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> | null {
|
||||
try {
|
||||
return this.findStored(digest(generationDigestValue));
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async find(
|
||||
generationDigestValue: string,
|
||||
): Promise<Readonly<PluginPackageSecretBindingTransitionReceipt> | null> {
|
||||
const normalized = digest(generationDigestValue);
|
||||
return (this.authority as LocalSqliteOperationAuthority).enqueue(
|
||||
async () => this.findStored(normalized),
|
||||
() => new PluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
publishInTransaction(
|
||||
value: Readonly<PluginPackageSecretBindingTransitionReceipt>,
|
||||
): Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageSecretBindingTransitionReceipt>;
|
||||
}> {
|
||||
const receipt = normalizePluginPackageSecretBindingTransitionReceipt(value);
|
||||
const receiptJson = JSON.stringify(receipt);
|
||||
if (
|
||||
Buffer.byteLength(receiptJson, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Secret binding transition receipt exceeds durable budget',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const existing = this.findStored(
|
||||
receipt.transitionPlan.nextTarget.generationDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (JSON.stringify(existing) !== receiptJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'generation is bound to another transition receipt',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt: existing,
|
||||
});
|
||||
}
|
||||
const target = receipt.transitionPlan.nextTarget;
|
||||
const result = (this.authority as LocalSqliteOperationAuthority).client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageSecretBindingTransitionReceipts" (
|
||||
generation_digest, transition_digest, project_id, package_name,
|
||||
installation_id, lock_digest, generation, manifest_digest,
|
||||
previous_active_lock_digest, authority_kind, evidence_digest,
|
||||
binding_digest, committed_at_ms, receipt_digest, receipt_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (generation_digest) DO NOTHING`,
|
||||
)
|
||||
.run(
|
||||
target.generationDigest,
|
||||
receipt.transitionPlan.transitionDigest,
|
||||
target.projectId,
|
||||
target.packageName,
|
||||
target.installationId,
|
||||
target.lockDigest,
|
||||
target.generation,
|
||||
target.manifestDigest,
|
||||
receipt.transitionPlan.previousActiveLockDigest,
|
||||
receipt.authority.kind,
|
||||
receipt.authority.evidenceDigest,
|
||||
receipt.bindingDigest,
|
||||
receipt.committedAtMs,
|
||||
receipt.receiptDigest,
|
||||
receiptJson,
|
||||
);
|
||||
const stored = this.findStored(target.generationDigest);
|
||||
if (!stored || JSON.stringify(stored) !== receiptJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'transition receipt target is not the reviewed staged generation',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status:
|
||||
result.changes === 1 ? ('created' as const) : ('existing' as const),
|
||||
receipt: stored,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
export const LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_NAME =
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_guard' as const;
|
||||
|
||||
export const LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL =
|
||||
`
|
||||
CREATE TRIGGER ${LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_NAME}
|
||||
BEFORE INSERT ON "QingLong3PluginPackageSecretBindingTransitionReceipts"
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT CASE WHEN NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
AND install.project_id = head.project_id
|
||||
AND install.package_name = head.package_name
|
||||
WHERE head.project_id = NEW.project_id
|
||||
AND head.package_name = NEW.package_name
|
||||
AND install.installation_id = NEW.installation_id
|
||||
AND install.lock_digest = NEW.lock_digest
|
||||
AND install.target_generation = NEW.generation
|
||||
AND install.state = 'staged'
|
||||
AND install.previous_active_lock_digest = NEW.previous_active_lock_digest
|
||||
AND install.active_lock_digest = install.previous_active_lock_digest
|
||||
AND json_extract(install.lock_json, '$.manifestDigest') = NEW.manifest_digest
|
||||
AND install.target_generation = (
|
||||
SELECT MAX(history.target_generation)
|
||||
FROM "QingLong3PluginPackageInstalls" AS history
|
||||
WHERE history.project_id = install.project_id
|
||||
AND history.package_name = install.package_name
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageInstalls" AS previous
|
||||
WHERE previous.project_id = install.project_id
|
||||
AND previous.package_name = install.package_name
|
||||
AND previous.lock_digest = NEW.previous_active_lock_digest
|
||||
AND previous.state = 'active'
|
||||
AND previous.active_lock_digest = previous.lock_digest
|
||||
AND previous.target_generation < install.target_generation
|
||||
)
|
||||
AND (
|
||||
(NEW.binding_digest IS NULL AND
|
||||
json_type(NEW.receipt_json, '$.transitionPlan.nextBindingPlan') = 'null')
|
||||
OR
|
||||
(NEW.binding_digest IS NOT NULL AND
|
||||
json_type(NEW.receipt_json, '$.transitionPlan.nextBindingPlan') = 'object' AND
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageSecretBindings" AS binding
|
||||
WHERE binding.generation_digest = NEW.generation_digest
|
||||
AND binding.binding_digest = NEW.binding_digest
|
||||
AND binding.authority_kind = NEW.authority_kind
|
||||
AND binding.evidence_digest = NEW.evidence_digest
|
||||
AND binding.bound_at_ms = NEW.committed_at_ms
|
||||
))
|
||||
)
|
||||
) THEN RAISE(ABORT,
|
||||
'Plugin Package Secret binding transition receipt target is not reviewed staged generation')
|
||||
END;
|
||||
END
|
||||
`.trim();
|
||||
Reference in New Issue
Block a user