mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
LocalOwnerCredentialRecoveryCredentialUnavailableError,
|
||||
LocalOwnerCredentialRecoveryInProgressError,
|
||||
LocalOwnerCredentialRecoveryMutationConflictError,
|
||||
LocalOwnerCredentialRecoveryNotAcknowledgedError,
|
||||
LocalOwnerCredentialRecoveryRepositoryUnavailableError,
|
||||
normalizeAcknowledgeLocalOwnerCredentialRecoveryCommand,
|
||||
normalizeCompleteLocalOwnerCredentialRecoveryCommand,
|
||||
normalizeIssueLocalOwnerCredentialRecoveryCommand,
|
||||
type AcknowledgeLocalOwnerCredentialRecoveryCommand,
|
||||
type CompleteLocalOwnerCredentialRecoveryCommand,
|
||||
type IssueLocalOwnerCredentialRecoveryCommand,
|
||||
type LocalOwnerCredentialRecoveryRecord,
|
||||
type LocalOwnerCredentialRecoveryRepository,
|
||||
type LocalOwnerCredentialRecoveryResult,
|
||||
} from '@qinglong/runtime-core/local-owner-credential-recovery';
|
||||
import {
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRecord,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type QueryRow = Record<string, unknown>;
|
||||
|
||||
const RECOVERY_SELECT = `
|
||||
SELECT recovery.*,
|
||||
replacement."state" AS "replacement_state",
|
||||
replacement."subject_type" AS "replacement_subject_type",
|
||||
replacement."subject_id" AS "replacement_subject_id",
|
||||
replacement."secret_digest" AS "replacement_secret_digest",
|
||||
replacement."created_at_ms" AS "replacement_created_at_ms",
|
||||
replacement."not_before_at_ms" AS "replacement_not_before_at_ms",
|
||||
replacement."expires_at_ms" AS "replacement_expires_at_ms",
|
||||
identity."status" AS "replacement_subject_status",
|
||||
pepper."pepper_key_id" AS "replacement_pepper_key_id"
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries" AS recovery
|
||||
JOIN "QingLong3ApiCredentials" AS replacement
|
||||
ON replacement."credential_id" = recovery."replacement_credential_id"
|
||||
AND replacement."version" = recovery."replacement_credential_version"
|
||||
JOIN "QingLong3IdentitySubjects" AS identity
|
||||
ON identity."subject_type" = replacement."subject_type"
|
||||
AND identity."subject_id" = replacement."subject_id"
|
||||
JOIN "QingLong3ApiCredentialPepperBindings" AS pepper
|
||||
ON pepper."credential_id" = replacement."credential_id"
|
||||
AND pepper."credential_version" = replacement."version"`;
|
||||
|
||||
const CREDENTIAL_SELECT = `
|
||||
SELECT credential."credential_id" AS "credential_credential_id",
|
||||
credential."version" AS "credential_credential_version",
|
||||
credential."state" AS "credential_state",
|
||||
credential."subject_type" AS "credential_subject_type",
|
||||
credential."subject_id" AS "credential_subject_id",
|
||||
credential."secret_digest" AS "credential_secret_digest",
|
||||
credential."created_at_ms" AS "credential_created_at_ms",
|
||||
credential."not_before_at_ms" AS "credential_not_before_at_ms",
|
||||
credential."expires_at_ms" AS "credential_expires_at_ms",
|
||||
identity."status" AS "credential_subject_status",
|
||||
pepper."pepper_key_id" AS "credential_pepper_key_id"
|
||||
FROM "QingLong3ApiCredentials" AS credential
|
||||
JOIN "QingLong3IdentitySubjects" AS identity
|
||||
ON identity."subject_type" = credential."subject_type"
|
||||
AND identity."subject_id" = credential."subject_id"
|
||||
JOIN "QingLong3ApiCredentialPepperBindings" AS pepper
|
||||
ON pepper."credential_id" = credential."credential_id"
|
||||
AND pepper."credential_version" = credential."version"`;
|
||||
|
||||
function string(row: QueryRow, name: string): string {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(row: QueryRow, name: string): string | undefined {
|
||||
const value = row[name];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return string(row, name);
|
||||
}
|
||||
|
||||
function integer(row: QueryRow, name: string): number {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalInteger(row: QueryRow, name: string): number | undefined {
|
||||
const value = row[name];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return integer(row, name);
|
||||
}
|
||||
|
||||
function credentialFromRow(
|
||||
row: QueryRow,
|
||||
prefix: 'credential' | 'replacement',
|
||||
): Readonly<ApiCredentialRecord> {
|
||||
try {
|
||||
return normalizeApiCredentialRecord({
|
||||
credentialId: string(row, `${prefix}_credential_id`),
|
||||
version: integer(row, `${prefix}_credential_version`),
|
||||
pepperKeyId: string(row, `${prefix}_pepper_key_id`),
|
||||
state: string(row, `${prefix}_state`) as ApiCredentialRecord['state'],
|
||||
subject: {
|
||||
type: string(
|
||||
row,
|
||||
`${prefix}_subject_type`,
|
||||
) as ApiCredentialRecord['subject']['type'],
|
||||
id: string(row, `${prefix}_subject_id`),
|
||||
},
|
||||
subjectStatus: string(
|
||||
row,
|
||||
`${prefix}_subject_status`,
|
||||
) as ApiCredentialRecord['subjectStatus'],
|
||||
secretDigest: string(row, `${prefix}_secret_digest`),
|
||||
createdAtMs: integer(row, `${prefix}_created_at_ms`),
|
||||
notBeforeAtMs: integer(row, `${prefix}_not_before_at_ms`),
|
||||
expiresAtMs: integer(row, `${prefix}_expires_at_ms`),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalOwnerCredentialRecoveryRepositoryUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function recoveryFromRow(
|
||||
row: QueryRow,
|
||||
): Readonly<LocalOwnerCredentialRecoveryRecord> {
|
||||
const state = string(row, 'state');
|
||||
if (state !== 'issued' && state !== 'acknowledged' && state !== 'completed') {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
const deliveryDigest = optionalString(row, 'delivery_digest');
|
||||
const acknowledgedAtMs = optionalInteger(row, 'acknowledged_at_ms');
|
||||
const completeMutationId = optionalString(row, 'complete_mutation_id');
|
||||
const completeRequestId = optionalString(row, 'complete_request_id');
|
||||
const revokedCredentialVersion = optionalInteger(
|
||||
row,
|
||||
'revoked_credential_version',
|
||||
);
|
||||
const completedAtMs = optionalInteger(row, 'completed_at_ms');
|
||||
if (
|
||||
(state === 'issued' &&
|
||||
(deliveryDigest !== undefined ||
|
||||
acknowledgedAtMs !== undefined ||
|
||||
completeMutationId !== undefined ||
|
||||
completeRequestId !== undefined ||
|
||||
revokedCredentialVersion !== undefined ||
|
||||
completedAtMs !== undefined)) ||
|
||||
(state === 'acknowledged' &&
|
||||
(deliveryDigest === undefined ||
|
||||
acknowledgedAtMs === undefined ||
|
||||
completeMutationId !== undefined ||
|
||||
completeRequestId !== undefined ||
|
||||
revokedCredentialVersion !== undefined ||
|
||||
completedAtMs !== undefined)) ||
|
||||
(state === 'completed' &&
|
||||
(deliveryDigest === undefined ||
|
||||
acknowledgedAtMs === undefined ||
|
||||
completeMutationId === undefined ||
|
||||
completeRequestId === undefined ||
|
||||
revokedCredentialVersion === undefined ||
|
||||
completedAtMs === undefined))
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
issueMutationId: string(row, 'issue_mutation_id'),
|
||||
issueRequestId: string(row, 'issue_request_id'),
|
||||
subjectId: string(row, 'subject_id'),
|
||||
previousCredentialId: string(row, 'previous_credential_id'),
|
||||
previousCredentialVersion: integer(row, 'previous_credential_version'),
|
||||
replacementCredential: credentialFromRow(row, 'replacement'),
|
||||
state,
|
||||
issuedAtMs: integer(row, 'issued_at_ms'),
|
||||
...(deliveryDigest === undefined ? {} : { deliveryDigest }),
|
||||
...(acknowledgedAtMs === undefined ? {} : { acknowledgedAtMs }),
|
||||
...(completeMutationId === undefined ? {} : { completeMutationId }),
|
||||
...(completeRequestId === undefined ? {} : { completeRequestId }),
|
||||
...(revokedCredentialVersion === undefined
|
||||
? {}
|
||||
: { revokedCredentialVersion }),
|
||||
...(completedAtMs === undefined ? {} : { completedAtMs }),
|
||||
});
|
||||
}
|
||||
|
||||
function sameCredential(
|
||||
left: Readonly<ApiCredentialRecord>,
|
||||
right: Readonly<ApiCredentialRecord>,
|
||||
): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function insertAudit(
|
||||
client: DatabaseSync,
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
) {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons_json,
|
||||
fence_project_version, fence_binding_version, occurred_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function isDomainError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof LocalOwnerCredentialRecoveryMutationConflictError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryInProgressError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryNotAcknowledgedError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryCredentialUnavailableError ||
|
||||
error instanceof LocalOwnerCredentialRecoveryRepositoryUnavailableError
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalSqliteOwnerCredentialRecoveryRepository
|
||||
implements LocalOwnerCredentialRecoveryRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
private resolveDirect(
|
||||
issueMutationId: string,
|
||||
): Readonly<LocalOwnerCredentialRecoveryRecord> | null {
|
||||
const rows = this.authority.client
|
||||
.prepare(
|
||||
`${RECOVERY_SELECT} WHERE recovery."issue_mutation_id" = ? LIMIT 2`,
|
||||
)
|
||||
.all(issueMutationId) as QueryRow[];
|
||||
if (rows.length > 1) {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
return rows[0] ? recoveryFromRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
private currentCredentialDirect(
|
||||
credentialId: string,
|
||||
): Readonly<ApiCredentialRecord> | null {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`${CREDENTIAL_SELECT}
|
||||
WHERE credential."credential_id" = ?
|
||||
ORDER BY credential."version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(credentialId) as QueryRow | undefined;
|
||||
return row ? credentialFromRow(row, 'credential') : null;
|
||||
}
|
||||
|
||||
resolve(
|
||||
issueMutationId: string,
|
||||
): Promise<Readonly<LocalOwnerCredentialRecoveryRecord> | null> {
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return this.resolveDirect(issueMutationId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
LocalOwnerCredentialRecoveryRepositoryUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerCredentialRecoveryRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
issue(
|
||||
input: IssueLocalOwnerCredentialRecoveryCommand,
|
||||
): Promise<LocalOwnerCredentialRecoveryResult> {
|
||||
const command = normalizeIssueLocalOwnerCredentialRecoveryCommand(input);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const replay = this.resolveDirect(command.mutationId);
|
||||
if (replay) {
|
||||
if (
|
||||
replay.issueRequestId !== command.requestId ||
|
||||
replay.previousCredentialId !== command.previousCredentialId ||
|
||||
replay.previousCredentialVersion !==
|
||||
command.expectedPreviousVersion ||
|
||||
!sameCredential(
|
||||
replay.replacementCredential,
|
||||
command.replacementCredential,
|
||||
)
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
recovery: replay,
|
||||
});
|
||||
}
|
||||
if (
|
||||
client
|
||||
.prepare(
|
||||
`SELECT 1 FROM "QingLong3LocalOwnerCredentialRecoveries"
|
||||
WHERE "subject_id" = ? AND "state" <> 'completed' LIMIT 1`,
|
||||
)
|
||||
.get(command.replacementCredential.subject.id)
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryInProgressError();
|
||||
}
|
||||
const previous = this.currentCredentialDirect(
|
||||
command.previousCredentialId,
|
||||
);
|
||||
if (
|
||||
!previous ||
|
||||
previous.version !== command.expectedPreviousVersion ||
|
||||
previous.state !== 'active' ||
|
||||
previous.subject.type !== 'user' ||
|
||||
previous.subject.id !== command.replacementCredential.subject.id ||
|
||||
previous.subjectStatus !== 'active' ||
|
||||
previous.notBeforeAtMs >
|
||||
command.replacementCredential.createdAtMs ||
|
||||
previous.expiresAtMs <= command.replacementCredential.createdAtMs
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryCredentialUnavailableError();
|
||||
}
|
||||
const activePepper = client
|
||||
.prepare(
|
||||
`SELECT key."pepper_key_id"
|
||||
FROM "QingLong3LocalOwnerPepperActivations" AS activation
|
||||
JOIN "QingLong3LocalOwnerPepperKeys" AS key
|
||||
ON key."pepper_key_id" = activation."active_pepper_key_id"
|
||||
WHERE key."state" = 'active'
|
||||
ORDER BY activation."generation" DESC LIMIT 1`,
|
||||
)
|
||||
.get() as QueryRow | undefined;
|
||||
if (
|
||||
!activePepper ||
|
||||
string(activePepper, 'pepper_key_id') !==
|
||||
command.replacementCredential.pepperKeyId
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryCredentialUnavailableError();
|
||||
}
|
||||
if (
|
||||
client
|
||||
.prepare(
|
||||
`SELECT 1 FROM "QingLong3ApiCredentials"
|
||||
WHERE "credential_id" = ? LIMIT 1`,
|
||||
)
|
||||
.get(command.replacementCredential.credentialId) ||
|
||||
client
|
||||
.prepare(
|
||||
`SELECT 1 FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ? LIMIT 1`,
|
||||
)
|
||||
.get(command.audit.eventId)
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
const replacement = command.replacementCredential;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
credential_id, version, state, subject_type, subject_id,
|
||||
secret_digest, created_at_ms, not_before_at_ms, expires_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
replacement.credentialId,
|
||||
replacement.version,
|
||||
replacement.state,
|
||||
replacement.subject.type,
|
||||
replacement.subject.id,
|
||||
replacement.secretDigest,
|
||||
replacement.createdAtMs,
|
||||
replacement.notBeforeAtMs,
|
||||
replacement.expiresAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
credential_id, credential_version, pepper_key_id
|
||||
) VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
replacement.credentialId,
|
||||
replacement.version,
|
||||
replacement.pepperKeyId,
|
||||
);
|
||||
insertAudit(client, command.audit);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerCredentialRecoveries" (
|
||||
issue_mutation_id, issue_request_id, subject_type, subject_id,
|
||||
previous_credential_id, previous_credential_version,
|
||||
replacement_credential_id, replacement_credential_version,
|
||||
state, issued_at_ms, issue_audit_event_id
|
||||
) VALUES (?, ?, 'user', ?, ?, ?, ?, ?, 'issued', ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
command.mutationId,
|
||||
command.requestId,
|
||||
replacement.subject.id,
|
||||
command.previousCredentialId,
|
||||
command.expectedPreviousVersion,
|
||||
replacement.credentialId,
|
||||
replacement.version,
|
||||
replacement.createdAtMs,
|
||||
command.audit.eventId,
|
||||
);
|
||||
const recovery = this.resolveDirect(command.mutationId);
|
||||
if (!recovery) {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'inserted' as const, recovery });
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerCredentialRecoveryRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
acknowledge(
|
||||
input: AcknowledgeLocalOwnerCredentialRecoveryCommand,
|
||||
): Promise<LocalOwnerCredentialRecoveryResult> {
|
||||
const command =
|
||||
normalizeAcknowledgeLocalOwnerCredentialRecoveryCommand(input);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const recovery = this.resolveDirect(command.issueMutationId);
|
||||
if (
|
||||
!recovery ||
|
||||
recovery.issueRequestId !== command.requestId ||
|
||||
recovery.replacementCredential.credentialId !==
|
||||
command.credentialId ||
|
||||
recovery.replacementCredential.secretDigest !==
|
||||
command.factDigest ||
|
||||
command.acknowledgedAtMs < recovery.issuedAtMs ||
|
||||
command.acknowledgedAtMs >=
|
||||
recovery.replacementCredential.expiresAtMs
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
if (recovery.state !== 'issued') {
|
||||
if (
|
||||
recovery.deliveryDigest !== command.deliveryDigest ||
|
||||
recovery.acknowledgedAtMs !== command.acknowledgedAtMs
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
recovery,
|
||||
});
|
||||
}
|
||||
const changed = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalOwnerCredentialRecoveries"
|
||||
SET state = 'acknowledged', delivery_digest = ?,
|
||||
acknowledged_at_ms = ?
|
||||
WHERE issue_mutation_id = ? AND state = 'issued'`,
|
||||
)
|
||||
.run(
|
||||
command.deliveryDigest,
|
||||
command.acknowledgedAtMs,
|
||||
command.issueMutationId,
|
||||
);
|
||||
if (changed.changes !== 1) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
const acknowledged = this.resolveDirect(command.issueMutationId);
|
||||
if (!acknowledged || acknowledged.state !== 'acknowledged') {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
recovery: acknowledged,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerCredentialRecoveryRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
complete(
|
||||
input: CompleteLocalOwnerCredentialRecoveryCommand,
|
||||
): Promise<LocalOwnerCredentialRecoveryResult> {
|
||||
const command = normalizeCompleteLocalOwnerCredentialRecoveryCommand(input);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const replayRow = client
|
||||
.prepare(
|
||||
`SELECT "issue_mutation_id"
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries"
|
||||
WHERE "complete_mutation_id" = ? LIMIT 1`,
|
||||
)
|
||||
.get(command.mutationId) as QueryRow | undefined;
|
||||
if (replayRow) {
|
||||
const replay = this.resolveDirect(
|
||||
string(replayRow, 'issue_mutation_id'),
|
||||
);
|
||||
const replayedRevocation = replay
|
||||
? this.currentCredentialDirect(replay.previousCredentialId)
|
||||
: null;
|
||||
if (
|
||||
!replay ||
|
||||
!replayedRevocation ||
|
||||
replay.issueMutationId !== command.issueMutationId ||
|
||||
replay.completeRequestId !== command.requestId ||
|
||||
replay.previousCredentialVersion !==
|
||||
command.expectedPreviousVersion ||
|
||||
replay.revokedCredentialVersion !==
|
||||
command.revokedCredential.version ||
|
||||
replay.completedAtMs !== command.revokedCredential.createdAtMs ||
|
||||
!sameCredential(replayedRevocation, command.revokedCredential)
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
recovery: replay,
|
||||
});
|
||||
}
|
||||
const recovery = this.resolveDirect(command.issueMutationId);
|
||||
if (!recovery) {
|
||||
throw new LocalOwnerCredentialRecoveryCredentialUnavailableError();
|
||||
}
|
||||
if (recovery.state === 'issued') {
|
||||
throw new LocalOwnerCredentialRecoveryNotAcknowledgedError();
|
||||
}
|
||||
if (recovery.state === 'completed') {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
const previous = this.currentCredentialDirect(
|
||||
recovery.previousCredentialId,
|
||||
);
|
||||
const revoked = command.revokedCredential;
|
||||
if (
|
||||
!previous ||
|
||||
previous.version !== command.expectedPreviousVersion ||
|
||||
previous.version !== recovery.previousCredentialVersion ||
|
||||
previous.state !== 'active' ||
|
||||
previous.subject.id !== recovery.subjectId ||
|
||||
revoked.credentialId !== previous.credentialId ||
|
||||
revoked.subject.type !== previous.subject.type ||
|
||||
revoked.subject.id !== previous.subject.id ||
|
||||
revoked.subjectStatus !== previous.subjectStatus ||
|
||||
revoked.pepperKeyId !== previous.pepperKeyId ||
|
||||
revoked.createdAtMs < (recovery.acknowledgedAtMs ?? 0) ||
|
||||
revoked.createdAtMs >= recovery.replacementCredential.expiresAtMs
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryCredentialUnavailableError();
|
||||
}
|
||||
if (
|
||||
client
|
||||
.prepare(
|
||||
`SELECT 1 FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ? LIMIT 1`,
|
||||
)
|
||||
.get(command.audit.eventId)
|
||||
) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
credential_id, version, state, subject_type, subject_id,
|
||||
secret_digest, created_at_ms, not_before_at_ms, expires_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
revoked.credentialId,
|
||||
revoked.version,
|
||||
revoked.state,
|
||||
revoked.subject.type,
|
||||
revoked.subject.id,
|
||||
revoked.secretDigest,
|
||||
revoked.createdAtMs,
|
||||
revoked.notBeforeAtMs,
|
||||
revoked.expiresAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
credential_id, credential_version, pepper_key_id
|
||||
) VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run(revoked.credentialId, revoked.version, revoked.pepperKeyId);
|
||||
insertAudit(client, command.audit);
|
||||
const changed = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalOwnerCredentialRecoveries"
|
||||
SET state = 'completed', complete_mutation_id = ?,
|
||||
complete_request_id = ?, revoked_credential_version = ?,
|
||||
completed_at_ms = ?, complete_audit_event_id = ?
|
||||
WHERE issue_mutation_id = ? AND state = 'acknowledged'`,
|
||||
)
|
||||
.run(
|
||||
command.mutationId,
|
||||
command.requestId,
|
||||
revoked.version,
|
||||
revoked.createdAtMs,
|
||||
command.audit.eventId,
|
||||
command.issueMutationId,
|
||||
);
|
||||
if (changed.changes !== 1) {
|
||||
throw new LocalOwnerCredentialRecoveryMutationConflictError();
|
||||
}
|
||||
const completed = this.resolveDirect(command.issueMutationId);
|
||||
if (!completed || completed.state !== 'completed') {
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
recovery: completed,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerCredentialRecoveryRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerCredentialRecoveryRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
localOwnerSecretDeliveryAcknowledgementSemanticDigest,
|
||||
normalizeLocalOwnerSecretDeliveryAcknowledgementRecord,
|
||||
type LocalOwnerSecretDeliveryAcknowledgementRecord,
|
||||
} from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import {
|
||||
LocalOwnerDeliveryAcknowledgementGcMutationConflictError,
|
||||
LocalOwnerDeliveryAcknowledgementGcReferenceConflictError,
|
||||
LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError,
|
||||
LocalOwnerDeliveryAcknowledgementGcRetentionPendingError,
|
||||
localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest,
|
||||
normalizeCompactLocalOwnerDeliveryAcknowledgementCommand,
|
||||
type CompactLocalOwnerDeliveryAcknowledgementCommand,
|
||||
type LocalOwnerDeliveryAcknowledgementGcRecord,
|
||||
type LocalOwnerDeliveryAcknowledgementGcRepository,
|
||||
type LocalOwnerDeliveryAcknowledgementGcResult,
|
||||
} from '@qinglong/runtime-core/local-owner-delivery-acknowledgement-gc';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type QueryRow = Record<string, unknown>;
|
||||
|
||||
const GC_SELECT = `
|
||||
SELECT * FROM "QingLong3LocalOwnerDeliveryAcknowledgementGc"`;
|
||||
|
||||
function text(row: QueryRow, name: string): string {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: QueryRow, name: string): string | undefined {
|
||||
const value = row[name];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return text(row, name);
|
||||
}
|
||||
|
||||
function integer(row: QueryRow, name: string): number {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeRetentionTimestamp(base: number, duration: number): number {
|
||||
const result = base + duration;
|
||||
if (!Number.isSafeInteger(result) || result < 0) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function acknowledgementFromRow(
|
||||
row: QueryRow,
|
||||
): Readonly<LocalOwnerSecretDeliveryAcknowledgementRecord> {
|
||||
try {
|
||||
const common = {
|
||||
mutationId: text(row, 'mutation_id'),
|
||||
requestId: text(row, 'request_id'),
|
||||
factDigest: text(row, 'fact_digest'),
|
||||
deliveryDigest: text(row, 'delivery_digest'),
|
||||
ttlMs: integer(row, 'ttl_ms'),
|
||||
acknowledgedAtMs: integer(row, 'acknowledged_at_ms'),
|
||||
};
|
||||
const kind = text(row, 'kind');
|
||||
return normalizeLocalOwnerSecretDeliveryAcknowledgementRecord(
|
||||
kind === 'credential'
|
||||
? {
|
||||
...common,
|
||||
kind,
|
||||
subjectId: text(row, 'subject_id'),
|
||||
credentialId: text(row, 'credential_id'),
|
||||
}
|
||||
: {
|
||||
...common,
|
||||
kind: kind as 'challenge',
|
||||
projectId: text(row, 'project_id'),
|
||||
challengeId: text(row, 'challenge_id'),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function gcRecord(
|
||||
row: QueryRow,
|
||||
): Readonly<LocalOwnerDeliveryAcknowledgementGcRecord> {
|
||||
const kind = text(row, 'acknowledgement_kind');
|
||||
const provisioningMutationId = optionalText(row, 'provisioning_mutation_id');
|
||||
const challengeMutationId = optionalText(row, 'challenge_mutation_id');
|
||||
const acknowledgementMutationId = text(row, 'acknowledgement_mutation_id');
|
||||
if (
|
||||
(kind !== 'credential' && kind !== 'challenge') ||
|
||||
(kind === 'credential' &&
|
||||
(provisioningMutationId !== acknowledgementMutationId ||
|
||||
challengeMutationId !== undefined)) ||
|
||||
(kind === 'challenge' &&
|
||||
(challengeMutationId !== acknowledgementMutationId ||
|
||||
provisioningMutationId !== undefined))
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
const retentionPolicy = Object.freeze({
|
||||
version: 1 as const,
|
||||
replayRetentionMs: integer(row, 'replay_retention_ms'),
|
||||
auditRetentionMs: integer(row, 'audit_retention_ms'),
|
||||
});
|
||||
const retentionPolicyDigest = text(row, 'retention_policy_digest');
|
||||
if (
|
||||
integer(row, 'retention_policy_version') !== 1 ||
|
||||
localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest(
|
||||
retentionPolicy,
|
||||
) !== retentionPolicyDigest
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
mutationId: text(row, 'gc_mutation_id'),
|
||||
requestId: text(row, 'gc_request_id'),
|
||||
acknowledgementMutationId,
|
||||
acknowledgementKind: kind,
|
||||
deliveryDigest: text(row, 'delivery_digest'),
|
||||
acknowledgedAtMs: integer(row, 'acknowledged_at_ms'),
|
||||
acknowledgementSemanticDigest: text(row, 'acknowledgement_semantic_digest'),
|
||||
bridgeClearEvidenceDigest: text(row, 'bridge_clear_evidence_digest'),
|
||||
retentionPolicy,
|
||||
retentionPolicyDigest,
|
||||
retentionEligibleAtMs: integer(row, 'retention_eligible_at_ms'),
|
||||
compactedAtMs: integer(row, 'compacted_at_ms'),
|
||||
});
|
||||
}
|
||||
|
||||
function sameCompact(
|
||||
record: Readonly<LocalOwnerDeliveryAcknowledgementGcRecord>,
|
||||
command: Readonly<CompactLocalOwnerDeliveryAcknowledgementCommand>,
|
||||
): boolean {
|
||||
return (
|
||||
record.mutationId === command.mutationId &&
|
||||
record.requestId === command.requestId &&
|
||||
record.acknowledgementMutationId === command.acknowledgementMutationId &&
|
||||
record.acknowledgementKind === command.expectedKind &&
|
||||
record.deliveryDigest === command.expectedDeliveryDigest &&
|
||||
record.bridgeClearEvidenceDigest ===
|
||||
command.bridgeClearEvidence.evidenceDigest &&
|
||||
record.retentionPolicyDigest ===
|
||||
localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest(
|
||||
command.retentionPolicy,
|
||||
) &&
|
||||
record.compactedAtMs === command.compactedAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function insertAudit(
|
||||
client: DatabaseSync,
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons_json,
|
||||
fence_project_version, fence_binding_version, occurred_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function sourceRetentionEligibleAt(
|
||||
client: DatabaseSync,
|
||||
acknowledgement: Readonly<LocalOwnerSecretDeliveryAcknowledgementRecord>,
|
||||
command: Readonly<CompactLocalOwnerDeliveryAcknowledgementCommand>,
|
||||
): number {
|
||||
const replayEligibleAt = safeRetentionTimestamp(
|
||||
acknowledgement.acknowledgedAtMs,
|
||||
command.retentionPolicy.replayRetentionMs,
|
||||
);
|
||||
if (acknowledgement.kind === 'credential') {
|
||||
const source = client
|
||||
.prepare(
|
||||
`SELECT provisioning."request_id", provisioning."subject_id",
|
||||
provisioning."credential_id", credential."version",
|
||||
credential."secret_digest", credential."not_before_at_ms",
|
||||
credential."expires_at_ms", audit."occurred_at_ms"
|
||||
FROM "QingLong3LocalIdentityProvisionings" AS provisioning
|
||||
JOIN "QingLong3ApiCredentials" AS credential
|
||||
ON credential."credential_id" = provisioning."credential_id"
|
||||
AND credential."version" = provisioning."credential_version"
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = provisioning."audit_event_id"
|
||||
WHERE provisioning."mutation_id" = ?`,
|
||||
)
|
||||
.get(acknowledgement.mutationId) as QueryRow | undefined;
|
||||
if (
|
||||
!source ||
|
||||
text(source, 'request_id') !== acknowledgement.requestId ||
|
||||
text(source, 'subject_id') !== acknowledgement.subjectId ||
|
||||
text(source, 'credential_id') !== acknowledgement.credentialId ||
|
||||
text(source, 'secret_digest') !== acknowledgement.factDigest ||
|
||||
integer(source, 'expires_at_ms') - integer(source, 'not_before_at_ms') !==
|
||||
acknowledgement.ttlMs
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
const current = client
|
||||
.prepare(
|
||||
`SELECT "state", "expires_at_ms"
|
||||
FROM "QingLong3ApiCredentials"
|
||||
WHERE "credential_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(acknowledgement.credentialId) as QueryRow | undefined;
|
||||
if (
|
||||
!current ||
|
||||
(text(current, 'state') === 'active' &&
|
||||
integer(current, 'expires_at_ms') > command.compactedAtMs)
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcReferenceConflictError();
|
||||
}
|
||||
if (
|
||||
client
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries"
|
||||
WHERE "state" <> 'completed'
|
||||
AND (("previous_credential_id" = ? AND "previous_credential_version" = ?)
|
||||
OR ("replacement_credential_id" = ? AND "replacement_credential_version" = ?))
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(
|
||||
acknowledgement.credentialId,
|
||||
integer(source, 'version'),
|
||||
acknowledgement.credentialId,
|
||||
integer(source, 'version'),
|
||||
)
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcReferenceConflictError();
|
||||
}
|
||||
return Math.max(
|
||||
replayEligibleAt,
|
||||
integer(source, 'expires_at_ms'),
|
||||
safeRetentionTimestamp(
|
||||
integer(source, 'occurred_at_ms'),
|
||||
command.retentionPolicy.auditRetentionMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const source = client
|
||||
.prepare(
|
||||
`SELECT challenge."issue_request_id", challenge."project_id",
|
||||
challenge."challenge_id", challenge."token_digest",
|
||||
challenge."issued_at_ms", challenge."expires_at_ms",
|
||||
challenge."consumed_at_ms",
|
||||
issue_audit."occurred_at_ms" AS "issue_audit_at_ms",
|
||||
claim_audit."occurred_at_ms" AS "claim_audit_at_ms"
|
||||
FROM "QingLong3LocalOwnerBootstrapChallenges" AS challenge
|
||||
JOIN "QingLong3SecurityAuditEvents" AS issue_audit
|
||||
ON issue_audit."event_id" = challenge."issue_audit_event_id"
|
||||
LEFT JOIN "QingLong3SecurityAuditEvents" AS claim_audit
|
||||
ON claim_audit."event_id" = challenge."claim_audit_event_id"
|
||||
WHERE challenge."issue_mutation_id" = ?`,
|
||||
)
|
||||
.get(acknowledgement.mutationId) as QueryRow | undefined;
|
||||
if (
|
||||
!source ||
|
||||
text(source, 'issue_request_id') !== acknowledgement.requestId ||
|
||||
text(source, 'project_id') !== acknowledgement.projectId ||
|
||||
text(source, 'challenge_id') !== acknowledgement.challengeId ||
|
||||
text(source, 'token_digest') !== acknowledgement.factDigest ||
|
||||
integer(source, 'expires_at_ms') - integer(source, 'issued_at_ms') !==
|
||||
acknowledgement.ttlMs
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
if (
|
||||
source.consumed_at_ms === null &&
|
||||
integer(source, 'expires_at_ms') > command.compactedAtMs
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcReferenceConflictError();
|
||||
}
|
||||
const claimAuditAtMs =
|
||||
source.claim_audit_at_ms === null
|
||||
? undefined
|
||||
: integer(source, 'claim_audit_at_ms');
|
||||
return Math.max(
|
||||
replayEligibleAt,
|
||||
integer(source, 'expires_at_ms'),
|
||||
safeRetentionTimestamp(
|
||||
integer(source, 'issue_audit_at_ms'),
|
||||
command.retentionPolicy.auditRetentionMs,
|
||||
),
|
||||
...(claimAuditAtMs === undefined
|
||||
? []
|
||||
: [
|
||||
safeRetentionTimestamp(
|
||||
claimAuditAtMs,
|
||||
command.retentionPolicy.auditRetentionMs,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function isDomainError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof LocalOwnerDeliveryAcknowledgementGcMutationConflictError ||
|
||||
error instanceof
|
||||
LocalOwnerDeliveryAcknowledgementGcReferenceConflictError ||
|
||||
error instanceof LocalOwnerDeliveryAcknowledgementGcRetentionPendingError ||
|
||||
error instanceof
|
||||
LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalSqliteOwnerDeliveryAcknowledgementGcRepository
|
||||
implements LocalOwnerDeliveryAcknowledgementGcRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
resolveByAcknowledgement(
|
||||
acknowledgementMutationId: string,
|
||||
): Promise<Readonly<LocalOwnerDeliveryAcknowledgementGcRecord> | null> {
|
||||
if (typeof acknowledgementMutationId !== 'string') {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const row = this.authority.client
|
||||
.prepare(`${GC_SELECT} WHERE "acknowledgement_mutation_id" = ?`)
|
||||
.get(acknowledgementMutationId) as QueryRow | undefined;
|
||||
return row ? gcRecord(row) : null;
|
||||
} catch (error) {
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
compact(
|
||||
candidate: CompactLocalOwnerDeliveryAcknowledgementCommand,
|
||||
): Promise<Readonly<LocalOwnerDeliveryAcknowledgementGcResult>> {
|
||||
const command =
|
||||
normalizeCompactLocalOwnerDeliveryAcknowledgementCommand(candidate);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
const replay = client
|
||||
.prepare(`${GC_SELECT} WHERE "gc_mutation_id" = ?`)
|
||||
.get(command.mutationId) as QueryRow | undefined;
|
||||
if (replay) {
|
||||
const record = gcRecord(replay);
|
||||
if (!sameCompact(record, command)) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'existing' as const, record });
|
||||
}
|
||||
if (
|
||||
client
|
||||
.prepare(`${GC_SELECT} WHERE "acknowledgement_mutation_id" = ?`)
|
||||
.get(command.acknowledgementMutationId)
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT * FROM "QingLong3LocalOwnerDeliveryAcknowledgements"
|
||||
WHERE "mutation_id" = ?`,
|
||||
)
|
||||
.get(command.acknowledgementMutationId) as QueryRow | undefined;
|
||||
if (!row) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
const acknowledgement = acknowledgementFromRow(row);
|
||||
if (
|
||||
acknowledgement.kind !== command.expectedKind ||
|
||||
acknowledgement.deliveryDigest !== command.expectedDeliveryDigest
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
const eligibleAtMs = sourceRetentionEligibleAt(
|
||||
client,
|
||||
acknowledgement,
|
||||
command,
|
||||
);
|
||||
if (command.compactedAtMs < eligibleAtMs) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRetentionPendingError(
|
||||
eligibleAtMs,
|
||||
);
|
||||
}
|
||||
if (
|
||||
client
|
||||
.prepare(
|
||||
`SELECT 1 FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ?`,
|
||||
)
|
||||
.get(command.audit.eventId)
|
||||
) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
const semanticDigest =
|
||||
localOwnerSecretDeliveryAcknowledgementSemanticDigest(
|
||||
acknowledgement,
|
||||
);
|
||||
const policyDigest =
|
||||
localOwnerDeliveryAcknowledgementGcRetentionPolicyDigest(
|
||||
command.retentionPolicy,
|
||||
);
|
||||
insertAudit(client, command.audit);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerDeliveryAcknowledgementGc" (
|
||||
gc_mutation_id, gc_request_id,
|
||||
acknowledgement_mutation_id, acknowledgement_kind,
|
||||
delivery_digest, acknowledged_at_ms,
|
||||
acknowledgement_semantic_digest,
|
||||
bridge_clear_evidence_digest, retention_policy_version,
|
||||
replay_retention_ms, audit_retention_ms,
|
||||
retention_policy_digest, retention_eligible_at_ms,
|
||||
compacted_at_ms, audit_event_id,
|
||||
provisioning_mutation_id, challenge_mutation_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
command.mutationId,
|
||||
command.requestId,
|
||||
acknowledgement.mutationId,
|
||||
acknowledgement.kind,
|
||||
acknowledgement.deliveryDigest,
|
||||
acknowledgement.acknowledgedAtMs,
|
||||
semanticDigest,
|
||||
command.bridgeClearEvidence.evidenceDigest,
|
||||
command.retentionPolicy.replayRetentionMs,
|
||||
command.retentionPolicy.auditRetentionMs,
|
||||
policyDigest,
|
||||
eligibleAtMs,
|
||||
command.compactedAtMs,
|
||||
command.audit.eventId,
|
||||
acknowledgement.kind === 'credential'
|
||||
? acknowledgement.mutationId
|
||||
: null,
|
||||
acknowledgement.kind === 'challenge'
|
||||
? acknowledgement.mutationId
|
||||
: null,
|
||||
);
|
||||
const deleted = client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3LocalOwnerDeliveryAcknowledgements"
|
||||
WHERE "mutation_id" = ?`,
|
||||
)
|
||||
.run(acknowledgement.mutationId);
|
||||
if (deleted.changes !== 1) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcMutationConflictError();
|
||||
}
|
||||
const inserted = client
|
||||
.prepare(`${GC_SELECT} WHERE "gc_mutation_id" = ?`)
|
||||
.get(command.mutationId) as QueryRow | undefined;
|
||||
if (!inserted) {
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
const record = gcRecord(inserted);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'inserted' as const, record });
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerDeliveryAcknowledgementGcRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
LocalOwnerPepperMaterialGcInProgressError,
|
||||
LocalOwnerPepperMaterialGcMutationConflictError,
|
||||
LocalOwnerPepperMaterialGcReferenceConflictError,
|
||||
LocalOwnerPepperMaterialGcRepositoryUnavailableError,
|
||||
LocalOwnerPepperMaterialGcRetentionPendingError,
|
||||
localOwnerPepperMaterialGcRetentionPolicyDigest,
|
||||
normalizeCompleteLocalOwnerPepperMaterialGcCommand,
|
||||
normalizePrepareLocalOwnerPepperMaterialGcCommand,
|
||||
type CompleteLocalOwnerPepperMaterialGcCommand,
|
||||
type LocalOwnerPepperMaterialGcRecord,
|
||||
type LocalOwnerPepperMaterialGcRepository,
|
||||
type LocalOwnerPepperMaterialGcResult,
|
||||
type PrepareLocalOwnerPepperMaterialGcCommand,
|
||||
} from '@qinglong/runtime-core/local-owner-pepper-material-gc';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type QueryRow = Record<string, unknown>;
|
||||
|
||||
const GC_SELECT = `
|
||||
SELECT * FROM "QingLong3LocalOwnerPepperMaterialGc"`;
|
||||
|
||||
function text(row: QueryRow, name: string): string {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: QueryRow, name: string): string | undefined {
|
||||
const value = row[name];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return text(row, name);
|
||||
}
|
||||
|
||||
function integer(row: QueryRow, name: string): number {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalInteger(row: QueryRow, name: string): number | undefined {
|
||||
const value = row[name];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return integer(row, name);
|
||||
}
|
||||
|
||||
function gcRecord(row: QueryRow): Readonly<LocalOwnerPepperMaterialGcRecord> {
|
||||
const state = text(row, 'state');
|
||||
const completeMutationId = optionalText(row, 'complete_mutation_id');
|
||||
const completeRequestId = optionalText(row, 'complete_request_id');
|
||||
const destructionProofDigest = optionalText(row, 'destruction_proof_digest');
|
||||
const completedAtMs = optionalInteger(row, 'completed_at_ms');
|
||||
if (
|
||||
(state !== 'prepared' && state !== 'completed') ||
|
||||
(state === 'prepared' &&
|
||||
(completeMutationId !== undefined ||
|
||||
completeRequestId !== undefined ||
|
||||
destructionProofDigest !== undefined ||
|
||||
completedAtMs !== undefined)) ||
|
||||
(state === 'completed' &&
|
||||
(completeMutationId === undefined ||
|
||||
completeRequestId === undefined ||
|
||||
destructionProofDigest === undefined ||
|
||||
completedAtMs === undefined))
|
||||
) {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
const retentionPolicy = Object.freeze({
|
||||
version: 1 as const,
|
||||
acknowledgementRetentionMs: integer(row, 'acknowledgement_retention_ms'),
|
||||
auditRetentionMs: integer(row, 'audit_retention_ms'),
|
||||
backupRetentionMs: integer(row, 'backup_retention_ms'),
|
||||
});
|
||||
if (
|
||||
integer(row, 'retention_policy_version') !== 1 ||
|
||||
localOwnerPepperMaterialGcRetentionPolicyDigest(retentionPolicy) !==
|
||||
text(row, 'retention_policy_digest')
|
||||
) {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
prepareMutationId: text(row, 'prepare_mutation_id'),
|
||||
prepareRequestId: text(row, 'prepare_request_id'),
|
||||
pepperKeyId: text(row, 'pepper_key_id'),
|
||||
materialDigest: text(row, 'material_digest'),
|
||||
backupMaterialDigest: text(row, 'backup_material_digest'),
|
||||
activePepperKeyId: text(row, 'active_pepper_key_id'),
|
||||
activeGeneration: integer(row, 'active_generation'),
|
||||
activeMaterialDigest: text(row, 'active_material_digest'),
|
||||
retentionPolicy,
|
||||
retentionPolicyDigest: text(row, 'retention_policy_digest'),
|
||||
referencesInspectedAtMs: integer(row, 'references_inspected_at_ms'),
|
||||
retentionEligibleAtMs: integer(row, 'retention_eligible_at_ms'),
|
||||
preparedAtMs: integer(row, 'prepared_at_ms'),
|
||||
state,
|
||||
...(completeMutationId === undefined ? {} : { completeMutationId }),
|
||||
...(completeRequestId === undefined ? {} : { completeRequestId }),
|
||||
...(destructionProofDigest === undefined ? {} : { destructionProofDigest }),
|
||||
...(completedAtMs === undefined ? {} : { completedAtMs }),
|
||||
});
|
||||
}
|
||||
|
||||
function insertAudit(
|
||||
client: DatabaseSync,
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons_json,
|
||||
fence_project_version, fence_binding_version, occurred_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function safeRetentionTimestamp(base: number, duration: number): number {
|
||||
const result = base + duration;
|
||||
if (!Number.isSafeInteger(result) || result < 0) {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function nullableMaximum(row: QueryRow | undefined): number | undefined {
|
||||
const value = row?.latest_at_ms;
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function retentionEligibleAt(
|
||||
client: DatabaseSync,
|
||||
pepperKeyId: string,
|
||||
retiredAtMs: number,
|
||||
command: Readonly<PrepareLocalOwnerPepperMaterialGcCommand>,
|
||||
): number {
|
||||
const acknowledgement = nullableMaximum(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT MAX("acknowledged_at_ms") AS "latest_at_ms"
|
||||
FROM (
|
||||
SELECT acknowledgement."acknowledged_at_ms"
|
||||
FROM "QingLong3LocalOwnerDeliveryAcknowledgements" AS acknowledgement
|
||||
JOIN "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
ON binding."credential_id" = acknowledgement."credential_id"
|
||||
WHERE acknowledgement."kind" = 'credential'
|
||||
AND binding."pepper_key_id" = ?
|
||||
UNION ALL
|
||||
SELECT compacted."acknowledged_at_ms"
|
||||
FROM "QingLong3LocalOwnerDeliveryAcknowledgementGc" AS compacted
|
||||
JOIN "QingLong3LocalIdentityProvisionings" AS provisioning
|
||||
ON provisioning."mutation_id" = compacted."provisioning_mutation_id"
|
||||
JOIN "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
ON binding."credential_id" = provisioning."credential_id"
|
||||
AND binding."credential_version" = provisioning."credential_version"
|
||||
WHERE compacted."acknowledgement_kind" = 'credential'
|
||||
AND binding."pepper_key_id" = ?
|
||||
UNION ALL
|
||||
SELECT recovery."acknowledged_at_ms"
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries" AS recovery
|
||||
WHERE recovery."acknowledged_at_ms" IS NOT NULL
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentialPepperBindings" AS previous
|
||||
WHERE previous."credential_id" = recovery."previous_credential_id"
|
||||
AND previous."credential_version" = recovery."previous_credential_version"
|
||||
AND previous."pepper_key_id" = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentialPepperBindings" AS replacement
|
||||
WHERE replacement."credential_id" = recovery."replacement_credential_id"
|
||||
AND replacement."credential_version" = recovery."replacement_credential_version"
|
||||
AND replacement."pepper_key_id" = ?
|
||||
)
|
||||
)
|
||||
)`,
|
||||
)
|
||||
.get(pepperKeyId, pepperKeyId, pepperKeyId, pepperKeyId) as
|
||||
| QueryRow
|
||||
| undefined,
|
||||
);
|
||||
const securityAudit = nullableMaximum(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT MAX(audit."occurred_at_ms") AS "latest_at_ms"
|
||||
FROM "QingLong3SecurityAuditEvents" AS audit
|
||||
WHERE audit."event_id" IN (
|
||||
SELECT provisioning."audit_event_id"
|
||||
FROM "QingLong3LocalIdentityProvisionings" AS provisioning
|
||||
JOIN "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
ON binding."credential_id" = provisioning."credential_id"
|
||||
AND binding."credential_version" = provisioning."credential_version"
|
||||
WHERE binding."pepper_key_id" = ?
|
||||
UNION
|
||||
SELECT recovery."issue_audit_event_id"
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries" AS recovery
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
WHERE binding."pepper_key_id" = ?
|
||||
AND (
|
||||
(binding."credential_id" = recovery."previous_credential_id"
|
||||
AND binding."credential_version" = recovery."previous_credential_version")
|
||||
OR (binding."credential_id" = recovery."replacement_credential_id"
|
||||
AND binding."credential_version" = recovery."replacement_credential_version")
|
||||
)
|
||||
)
|
||||
UNION
|
||||
SELECT recovery."complete_audit_event_id"
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries" AS recovery
|
||||
WHERE recovery."complete_audit_event_id" IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
WHERE binding."pepper_key_id" = ?
|
||||
AND (
|
||||
(binding."credential_id" = recovery."previous_credential_id"
|
||||
AND binding."credential_version" = recovery."previous_credential_version")
|
||||
OR (binding."credential_id" = recovery."replacement_credential_id"
|
||||
AND binding."credential_version" = recovery."replacement_credential_version")
|
||||
)
|
||||
)
|
||||
)`,
|
||||
)
|
||||
.get(pepperKeyId, pepperKeyId, pepperKeyId) as QueryRow | undefined,
|
||||
);
|
||||
return Math.max(
|
||||
safeRetentionTimestamp(
|
||||
retiredAtMs,
|
||||
command.retentionPolicy.backupRetentionMs,
|
||||
),
|
||||
...(acknowledgement === undefined
|
||||
? []
|
||||
: [
|
||||
safeRetentionTimestamp(
|
||||
acknowledgement,
|
||||
command.retentionPolicy.acknowledgementRetentionMs,
|
||||
),
|
||||
]),
|
||||
...(securityAudit === undefined
|
||||
? []
|
||||
: [
|
||||
safeRetentionTimestamp(
|
||||
securityAudit,
|
||||
command.retentionPolicy.auditRetentionMs,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function hasRuntimeReferences(
|
||||
client: DatabaseSync,
|
||||
pepperKeyId: string,
|
||||
inspectedAtMs: number,
|
||||
): boolean {
|
||||
const current = client
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
JOIN "QingLong3ApiCredentials" AS credential
|
||||
ON credential."credential_id" = binding."credential_id"
|
||||
AND credential."version" = binding."credential_version"
|
||||
WHERE binding."pepper_key_id" = ?
|
||||
AND credential."state" = 'active'
|
||||
AND credential."expires_at_ms" > ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentials" AS later
|
||||
WHERE later."credential_id" = credential."credential_id"
|
||||
AND later."version" > credential."version"
|
||||
)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(pepperKeyId, inspectedAtMs);
|
||||
if (current) return true;
|
||||
return !!client
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries" AS recovery
|
||||
WHERE recovery."state" <> 'completed'
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentialPepperBindings" AS previous
|
||||
WHERE previous."credential_id" = recovery."previous_credential_id"
|
||||
AND previous."credential_version" = recovery."previous_credential_version"
|
||||
AND previous."pepper_key_id" = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentialPepperBindings" AS replacement
|
||||
WHERE replacement."credential_id" = recovery."replacement_credential_id"
|
||||
AND replacement."credential_version" = recovery."replacement_credential_version"
|
||||
AND replacement."pepper_key_id" = ?
|
||||
)
|
||||
)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(pepperKeyId, pepperKeyId);
|
||||
}
|
||||
|
||||
function samePrepare(
|
||||
record: Readonly<LocalOwnerPepperMaterialGcRecord>,
|
||||
command: Readonly<PrepareLocalOwnerPepperMaterialGcCommand>,
|
||||
): boolean {
|
||||
return (
|
||||
record.prepareMutationId === command.mutationId &&
|
||||
record.prepareRequestId === command.requestId &&
|
||||
record.pepperKeyId === command.pepperKeyId &&
|
||||
record.materialDigest === command.expectedMaterialDigest &&
|
||||
record.backupMaterialDigest === command.expectedBackupMaterialDigest &&
|
||||
record.activePepperKeyId === command.expectedActivePepperKeyId &&
|
||||
record.activeGeneration === command.expectedActiveGeneration &&
|
||||
record.activeMaterialDigest === command.expectedActiveMaterialDigest &&
|
||||
record.retentionPolicyDigest ===
|
||||
localOwnerPepperMaterialGcRetentionPolicyDigest(
|
||||
command.retentionPolicy,
|
||||
) &&
|
||||
record.preparedAtMs === command.preparedAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function sameComplete(
|
||||
record: Readonly<LocalOwnerPepperMaterialGcRecord>,
|
||||
command: Readonly<CompleteLocalOwnerPepperMaterialGcCommand>,
|
||||
): boolean {
|
||||
return (
|
||||
record.completeMutationId === command.mutationId &&
|
||||
record.completeRequestId === command.requestId &&
|
||||
record.destructionProofDigest === command.destructionProofDigest &&
|
||||
record.completedAtMs === command.completedAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function isDomainError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof LocalOwnerPepperMaterialGcMutationConflictError ||
|
||||
error instanceof LocalOwnerPepperMaterialGcInProgressError ||
|
||||
error instanceof LocalOwnerPepperMaterialGcReferenceConflictError ||
|
||||
error instanceof LocalOwnerPepperMaterialGcRetentionPendingError ||
|
||||
error instanceof LocalOwnerPepperMaterialGcRepositoryUnavailableError
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalSqliteOwnerPepperMaterialGcRepository
|
||||
implements LocalOwnerPepperMaterialGcRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
resolve(
|
||||
prepareMutationId: string,
|
||||
): Promise<Readonly<LocalOwnerPepperMaterialGcRecord> | null> {
|
||||
if (typeof prepareMutationId !== 'string') {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const row = this.authority.client
|
||||
.prepare(`${GC_SELECT} WHERE "prepare_mutation_id" = ?`)
|
||||
.get(prepareMutationId) as QueryRow | undefined;
|
||||
return row ? gcRecord(row) : null;
|
||||
} catch (error) {
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperMaterialGcRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
prepare(
|
||||
candidate: PrepareLocalOwnerPepperMaterialGcCommand,
|
||||
): Promise<LocalOwnerPepperMaterialGcResult> {
|
||||
const command =
|
||||
normalizePrepareLocalOwnerPepperMaterialGcCommand(candidate);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
const replay = client
|
||||
.prepare(`${GC_SELECT} WHERE "prepare_mutation_id" = ?`)
|
||||
.get(command.mutationId) as QueryRow | undefined;
|
||||
if (replay) {
|
||||
const record = gcRecord(replay);
|
||||
if (!samePrepare(record, command)) {
|
||||
throw new LocalOwnerPepperMaterialGcMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'existing' as const, record });
|
||||
}
|
||||
if (
|
||||
client
|
||||
.prepare(`${GC_SELECT} WHERE "state" = 'prepared' LIMIT 1`)
|
||||
.get()
|
||||
) {
|
||||
throw new LocalOwnerPepperMaterialGcInProgressError();
|
||||
}
|
||||
const key = client
|
||||
.prepare(
|
||||
`SELECT "material_digest", "backup_digest", "retired_at_ms"
|
||||
FROM "QingLong3LocalOwnerPepperKeys"
|
||||
WHERE "pepper_key_id" = ? AND "state" = 'retired'`,
|
||||
)
|
||||
.get(command.pepperKeyId) as QueryRow | undefined;
|
||||
if (
|
||||
!key ||
|
||||
text(key, 'material_digest') !== command.expectedMaterialDigest ||
|
||||
text(key, 'backup_digest') !== command.expectedBackupMaterialDigest
|
||||
) {
|
||||
throw new LocalOwnerPepperMaterialGcMutationConflictError();
|
||||
}
|
||||
const active = client
|
||||
.prepare(
|
||||
`SELECT activation."generation", key."pepper_key_id",
|
||||
key."material_digest"
|
||||
FROM "QingLong3LocalOwnerPepperActivations" AS activation
|
||||
JOIN "QingLong3LocalOwnerPepperKeys" AS key
|
||||
ON key."pepper_key_id" = activation."active_pepper_key_id"
|
||||
WHERE key."state" = 'active'
|
||||
ORDER BY activation."generation" DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get() as QueryRow | undefined;
|
||||
if (
|
||||
!active ||
|
||||
integer(active, 'generation') !==
|
||||
command.expectedActiveGeneration ||
|
||||
text(active, 'pepper_key_id') !==
|
||||
command.expectedActivePepperKeyId ||
|
||||
text(active, 'material_digest') !==
|
||||
command.expectedActiveMaterialDigest
|
||||
) {
|
||||
throw new LocalOwnerPepperMaterialGcMutationConflictError();
|
||||
}
|
||||
if (
|
||||
hasRuntimeReferences(
|
||||
client,
|
||||
command.pepperKeyId,
|
||||
command.preparedAtMs,
|
||||
)
|
||||
) {
|
||||
throw new LocalOwnerPepperMaterialGcReferenceConflictError();
|
||||
}
|
||||
const eligibleAtMs = retentionEligibleAt(
|
||||
client,
|
||||
command.pepperKeyId,
|
||||
integer(key, 'retired_at_ms'),
|
||||
command,
|
||||
);
|
||||
if (command.preparedAtMs < eligibleAtMs) {
|
||||
throw new LocalOwnerPepperMaterialGcRetentionPendingError(
|
||||
eligibleAtMs,
|
||||
);
|
||||
}
|
||||
const policyDigest = localOwnerPepperMaterialGcRetentionPolicyDigest(
|
||||
command.retentionPolicy,
|
||||
);
|
||||
insertAudit(client, command.audit);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperMaterialGc" (
|
||||
prepare_mutation_id, prepare_request_id, pepper_key_id,
|
||||
material_digest, backup_material_digest,
|
||||
active_pepper_key_id, active_generation,
|
||||
active_material_digest, retention_policy_version,
|
||||
acknowledgement_retention_ms, audit_retention_ms,
|
||||
backup_retention_ms, retention_policy_digest,
|
||||
references_inspected_at_ms, retention_eligible_at_ms,
|
||||
prepared_at_ms, prepare_audit_event_id, state
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, 'prepared')`,
|
||||
)
|
||||
.run(
|
||||
command.mutationId,
|
||||
command.requestId,
|
||||
command.pepperKeyId,
|
||||
command.expectedMaterialDigest,
|
||||
command.expectedBackupMaterialDigest,
|
||||
command.expectedActivePepperKeyId,
|
||||
command.expectedActiveGeneration,
|
||||
command.expectedActiveMaterialDigest,
|
||||
command.retentionPolicy.acknowledgementRetentionMs,
|
||||
command.retentionPolicy.auditRetentionMs,
|
||||
command.retentionPolicy.backupRetentionMs,
|
||||
policyDigest,
|
||||
command.preparedAtMs,
|
||||
eligibleAtMs,
|
||||
command.preparedAtMs,
|
||||
command.audit.eventId,
|
||||
);
|
||||
const inserted = client
|
||||
.prepare(`${GC_SELECT} WHERE "prepare_mutation_id" = ?`)
|
||||
.get(command.mutationId) as QueryRow | undefined;
|
||||
if (!inserted) {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
const record = gcRecord(inserted);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'inserted' as const, record });
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperMaterialGcRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
complete(
|
||||
candidate: CompleteLocalOwnerPepperMaterialGcCommand,
|
||||
): Promise<LocalOwnerPepperMaterialGcResult> {
|
||||
const command =
|
||||
normalizeCompleteLocalOwnerPepperMaterialGcCommand(candidate);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
const row = client
|
||||
.prepare(`${GC_SELECT} WHERE "prepare_mutation_id" = ?`)
|
||||
.get(command.prepareMutationId) as QueryRow | undefined;
|
||||
if (!row) {
|
||||
throw new LocalOwnerPepperMaterialGcMutationConflictError();
|
||||
}
|
||||
const existing = gcRecord(row);
|
||||
if (existing.state === 'completed') {
|
||||
if (!sameComplete(existing, command)) {
|
||||
throw new LocalOwnerPepperMaterialGcMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
record: existing,
|
||||
});
|
||||
}
|
||||
if (command.completedAtMs < existing.preparedAtMs) {
|
||||
throw new LocalOwnerPepperMaterialGcMutationConflictError();
|
||||
}
|
||||
insertAudit(client, command.audit);
|
||||
const result = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalOwnerPepperMaterialGc"
|
||||
SET "state" = 'completed',
|
||||
"complete_mutation_id" = ?,
|
||||
"complete_request_id" = ?,
|
||||
"destruction_proof_digest" = ?,
|
||||
"completed_at_ms" = ?,
|
||||
"complete_audit_event_id" = ?
|
||||
WHERE "prepare_mutation_id" = ? AND "state" = 'prepared'`,
|
||||
)
|
||||
.run(
|
||||
command.mutationId,
|
||||
command.requestId,
|
||||
command.destructionProofDigest,
|
||||
command.completedAtMs,
|
||||
command.audit.eventId,
|
||||
command.prepareMutationId,
|
||||
);
|
||||
if (result.changes !== 1) {
|
||||
throw new LocalOwnerPepperMaterialGcMutationConflictError();
|
||||
}
|
||||
const completed = client
|
||||
.prepare(`${GC_SELECT} WHERE "prepare_mutation_id" = ?`)
|
||||
.get(command.prepareMutationId) as QueryRow | undefined;
|
||||
if (!completed) {
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
const record = gcRecord(completed);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'inserted' as const, record });
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (isDomainError(error)) throw error;
|
||||
throw new LocalOwnerPepperMaterialGcRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperMaterialGcRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import {
|
||||
LocalOwnerPepperCatalogFullError,
|
||||
LocalOwnerPepperGenerationConflictError,
|
||||
LocalOwnerPepperKeyNotActivatableError,
|
||||
LocalOwnerPepperMutationConflictError,
|
||||
LocalOwnerPepperRepositoryUnavailableError,
|
||||
MAX_LOCAL_OWNER_PEPPER_KEYS,
|
||||
normalizeActivateLocalOwnerPepperKeyCommand,
|
||||
normalizeRegisterLocalOwnerPepperKeyCommand,
|
||||
type ActivateLocalOwnerPepperKeyCommand,
|
||||
type ActivateLocalOwnerPepperKeyResult,
|
||||
type LocalOwnerPepperActivationRecord,
|
||||
type LocalOwnerPepperKeyRecord,
|
||||
type LocalOwnerPepperKeyState,
|
||||
type LocalOwnerPepperReferenceRepository,
|
||||
type LocalOwnerPepperReferenceSummary,
|
||||
type RegisterLocalOwnerPepperKeyCommand,
|
||||
type RegisterLocalOwnerPepperKeyResult,
|
||||
} from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import { assertApiCredentialPepperKeyId } from '@qinglong/runtime-core/api-credential';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
interface KeyRow {
|
||||
pepper_key_id: unknown;
|
||||
material_digest: unknown;
|
||||
backup_digest: unknown;
|
||||
state: unknown;
|
||||
version: unknown;
|
||||
register_mutation_id: unknown;
|
||||
activate_mutation_id: unknown;
|
||||
retire_mutation_id: unknown;
|
||||
registered_at_ms: unknown;
|
||||
activated_at_ms: unknown;
|
||||
retired_at_ms: unknown;
|
||||
}
|
||||
|
||||
interface ActivationRow {
|
||||
generation: unknown;
|
||||
mutation_id: unknown;
|
||||
expected_generation: unknown;
|
||||
previous_pepper_key_id: unknown;
|
||||
active_pepper_key_id: unknown;
|
||||
material_digest: unknown;
|
||||
backup_digest: unknown;
|
||||
activated_at_ms: unknown;
|
||||
}
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const KEY_STATES = new Set<LocalOwnerPepperKeyState>([
|
||||
'recovery_required',
|
||||
'staged',
|
||||
'active',
|
||||
'retired',
|
||||
]);
|
||||
|
||||
function requiredString(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (value === null) return undefined;
|
||||
return requiredString(value);
|
||||
}
|
||||
|
||||
function integer(value: unknown, minimum: number): number {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < minimum ||
|
||||
value > 2_147_483_647
|
||||
) {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value: unknown): number | undefined {
|
||||
if (value === null) return undefined;
|
||||
return timestamp(value);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
const result = requiredString(value);
|
||||
if (!DIGEST_PATTERN.test(result)) {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function keyRecord(row: KeyRow): Readonly<LocalOwnerPepperKeyRecord> {
|
||||
const pepperKeyId = requiredString(row.pepper_key_id);
|
||||
const state = requiredString(row.state) as LocalOwnerPepperKeyState;
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
if (!KEY_STATES.has(state)) {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
const materialDigest = optionalString(row.material_digest);
|
||||
const backupDigest = optionalString(row.backup_digest);
|
||||
if (
|
||||
(materialDigest !== undefined && !DIGEST_PATTERN.test(materialDigest)) ||
|
||||
(backupDigest !== undefined && !DIGEST_PATTERN.test(backupDigest)) ||
|
||||
(materialDigest === undefined) !== (backupDigest === undefined)
|
||||
) {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
pepperKeyId,
|
||||
...(materialDigest === undefined ? {} : { materialDigest }),
|
||||
...(backupDigest === undefined ? {} : { backupDigest }),
|
||||
state,
|
||||
version: integer(row.version, 1),
|
||||
...(optionalString(row.register_mutation_id) === undefined
|
||||
? {}
|
||||
: { registerMutationId: requiredString(row.register_mutation_id) }),
|
||||
...(optionalString(row.activate_mutation_id) === undefined
|
||||
? {}
|
||||
: { activateMutationId: requiredString(row.activate_mutation_id) }),
|
||||
...(optionalString(row.retire_mutation_id) === undefined
|
||||
? {}
|
||||
: { retireMutationId: requiredString(row.retire_mutation_id) }),
|
||||
registeredAtMs: timestamp(row.registered_at_ms),
|
||||
...(optionalTimestamp(row.activated_at_ms) === undefined
|
||||
? {}
|
||||
: { activatedAtMs: timestamp(row.activated_at_ms) }),
|
||||
...(optionalTimestamp(row.retired_at_ms) === undefined
|
||||
? {}
|
||||
: { retiredAtMs: timestamp(row.retired_at_ms) }),
|
||||
});
|
||||
}
|
||||
|
||||
function activationRecord(
|
||||
row: ActivationRow,
|
||||
): Readonly<LocalOwnerPepperActivationRecord> {
|
||||
const activePepperKeyId = requiredString(row.active_pepper_key_id);
|
||||
const previousPepperKeyId = optionalString(row.previous_pepper_key_id);
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(activePepperKeyId);
|
||||
if (previousPepperKeyId !== undefined) {
|
||||
assertApiCredentialPepperKeyId(previousPepperKeyId);
|
||||
}
|
||||
} catch {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
generation: integer(row.generation, 1),
|
||||
mutationId: requiredString(row.mutation_id),
|
||||
expectedGeneration: integer(row.expected_generation, 0),
|
||||
...(previousPepperKeyId === undefined ? {} : { previousPepperKeyId }),
|
||||
activePepperKeyId,
|
||||
materialDigest: digest(row.material_digest),
|
||||
backupDigest: digest(row.backup_digest),
|
||||
activatedAtMs: timestamp(row.activated_at_ms),
|
||||
});
|
||||
}
|
||||
|
||||
function sameRegistration(
|
||||
key: LocalOwnerPepperKeyRecord,
|
||||
command: RegisterLocalOwnerPepperKeyCommand,
|
||||
): boolean {
|
||||
return (
|
||||
key.pepperKeyId === command.pepperKeyId &&
|
||||
key.materialDigest === command.materialDigest &&
|
||||
key.backupDigest === command.backupDigest &&
|
||||
key.registerMutationId === command.mutationId &&
|
||||
key.registeredAtMs === command.registeredAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function sameActivation(
|
||||
activation: LocalOwnerPepperActivationRecord,
|
||||
command: ActivateLocalOwnerPepperKeyCommand,
|
||||
): boolean {
|
||||
return (
|
||||
activation.mutationId === command.mutationId &&
|
||||
activation.activePepperKeyId === command.pepperKeyId &&
|
||||
activation.expectedGeneration === command.expectedGeneration &&
|
||||
activation.previousPepperKeyId === command.expectedActivePepperKeyId &&
|
||||
activation.activatedAtMs === command.activatedAtMs
|
||||
);
|
||||
}
|
||||
|
||||
const KEY_SELECT = `
|
||||
SELECT pepper_key_id, material_digest, backup_digest, state, version,
|
||||
register_mutation_id, activate_mutation_id, retire_mutation_id,
|
||||
registered_at_ms, activated_at_ms, retired_at_ms
|
||||
FROM "QingLong3LocalOwnerPepperKeys"`;
|
||||
|
||||
const ACTIVATION_SELECT = `
|
||||
SELECT generation, mutation_id, expected_generation,
|
||||
previous_pepper_key_id, active_pepper_key_id,
|
||||
material_digest, backup_digest, activated_at_ms
|
||||
FROM "QingLong3LocalOwnerPepperActivations"`;
|
||||
|
||||
export class LocalSqliteOwnerPepperRepository
|
||||
implements LocalOwnerPepperReferenceRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority) {
|
||||
this.authority = authority;
|
||||
}
|
||||
|
||||
resolveKey(
|
||||
pepperKeyId: string,
|
||||
): Promise<Readonly<LocalOwnerPepperKeyRecord> | null> {
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const row = this.authority.client
|
||||
.prepare(`${KEY_SELECT} WHERE pepper_key_id = ?`)
|
||||
.get(pepperKeyId) as KeyRow | undefined;
|
||||
return row ? keyRecord(row) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerPepperRepositoryUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
resolveActive(): Promise<Readonly<LocalOwnerPepperActivationRecord> | null> {
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const row = this.authority.client
|
||||
.prepare(`${ACTIVATION_SELECT} ORDER BY generation DESC LIMIT 1`)
|
||||
.get() as ActivationRow | undefined;
|
||||
return row ? activationRecord(row) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerPepperRepositoryUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
inspectReferences(
|
||||
pepperKeyId: string,
|
||||
inspectedAtMs: number,
|
||||
): Promise<Readonly<LocalOwnerPepperReferenceSummary>> {
|
||||
try {
|
||||
assertApiCredentialPepperKeyId(pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
if (!Number.isSafeInteger(inspectedAtMs) || inspectedAtMs < 0) {
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const keyRow = this.authority.client
|
||||
.prepare(`${KEY_SELECT} WHERE pepper_key_id = ?`)
|
||||
.get(pepperKeyId) as KeyRow | undefined;
|
||||
if (!keyRow) throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
const key = keyRecord(keyRow);
|
||||
const historical = this.authority.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3ApiCredentialPepperBindings"
|
||||
WHERE "pepper_key_id" = ?`,
|
||||
)
|
||||
.get(pepperKeyId) as { count?: unknown } | undefined;
|
||||
const current = this.authority.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
JOIN "QingLong3ApiCredentials" AS credential
|
||||
ON credential."credential_id" = binding."credential_id"
|
||||
AND credential."version" = binding."credential_version"
|
||||
WHERE binding."pepper_key_id" = ?
|
||||
AND credential."state" = 'active'
|
||||
AND credential."expires_at_ms" > ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "QingLong3ApiCredentials" AS later
|
||||
WHERE later."credential_id" = credential."credential_id"
|
||||
AND later."version" > credential."version"
|
||||
)`,
|
||||
)
|
||||
.get(pepperKeyId, inspectedAtMs) as { count?: unknown } | undefined;
|
||||
const inFlight = this.authority.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries" AS recovery
|
||||
WHERE recovery."state" <> 'completed'
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3ApiCredentialPepperBindings" AS previous
|
||||
WHERE previous."credential_id" = recovery."previous_credential_id"
|
||||
AND previous."credential_version" = recovery."previous_credential_version"
|
||||
AND previous."pepper_key_id" = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3ApiCredentialPepperBindings" AS replacement
|
||||
WHERE replacement."credential_id" = recovery."replacement_credential_id"
|
||||
AND replacement."credential_version" = recovery."replacement_credential_version"
|
||||
AND replacement."pepper_key_id" = ?
|
||||
)
|
||||
)`,
|
||||
)
|
||||
.get(pepperKeyId, pepperKeyId) as { count?: unknown } | undefined;
|
||||
const currentCredentialReferences = integer(current?.count, 0);
|
||||
const inFlightRecoveryReferences = integer(inFlight?.count, 0);
|
||||
return Object.freeze({
|
||||
pepperKeyId,
|
||||
inspectedAtMs,
|
||||
currentCredentialReferences,
|
||||
inFlightRecoveryReferences,
|
||||
historicalCredentialReferences: integer(historical?.count, 0),
|
||||
runtimeReferencesClear:
|
||||
key.state === 'retired' &&
|
||||
currentCredentialReferences === 0 &&
|
||||
inFlightRecoveryReferences === 0,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerPepperRepositoryUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
register(
|
||||
input: RegisterLocalOwnerPepperKeyCommand,
|
||||
): Promise<RegisterLocalOwnerPepperKeyResult> {
|
||||
const command = normalizeRegisterLocalOwnerPepperKeyCommand(input);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const replay = client
|
||||
.prepare(`${KEY_SELECT} WHERE register_mutation_id = ?`)
|
||||
.get(command.mutationId) as KeyRow | undefined;
|
||||
if (replay) {
|
||||
const key = keyRecord(replay);
|
||||
if (!sameRegistration(key, command)) {
|
||||
throw new LocalOwnerPepperMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'existing' as const, key });
|
||||
}
|
||||
const existingRow = client
|
||||
.prepare(`${KEY_SELECT} WHERE pepper_key_id = ?`)
|
||||
.get(command.pepperKeyId) as KeyRow | undefined;
|
||||
if (existingRow) {
|
||||
const existing = keyRecord(existingRow);
|
||||
if (existing.state !== 'recovery_required') {
|
||||
throw new LocalOwnerPepperMutationConflictError();
|
||||
}
|
||||
const changed = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalOwnerPepperKeys"
|
||||
SET material_digest = ?, backup_digest = ?, state = 'staged',
|
||||
version = version + 1, register_mutation_id = ?,
|
||||
registered_at_ms = ?
|
||||
WHERE pepper_key_id = ? AND version = ?
|
||||
AND state = 'recovery_required'`,
|
||||
)
|
||||
.run(
|
||||
command.materialDigest,
|
||||
command.backupDigest,
|
||||
command.mutationId,
|
||||
command.registeredAtMs,
|
||||
command.pepperKeyId,
|
||||
existing.version,
|
||||
);
|
||||
if (changed.changes !== 1) {
|
||||
throw new LocalOwnerPepperMutationConflictError();
|
||||
}
|
||||
} else {
|
||||
const count = client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "QingLong3LocalOwnerPepperKeys"`,
|
||||
)
|
||||
.get() as { count?: unknown } | undefined;
|
||||
if (integer(count?.count, 0) >= MAX_LOCAL_OWNER_PEPPER_KEYS) {
|
||||
throw new LocalOwnerPepperCatalogFullError();
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||
pepper_key_id, material_digest, backup_digest, state,
|
||||
version, register_mutation_id, registered_at_ms
|
||||
) VALUES (?, ?, ?, 'staged', 1, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
command.pepperKeyId,
|
||||
command.materialDigest,
|
||||
command.backupDigest,
|
||||
command.mutationId,
|
||||
command.registeredAtMs,
|
||||
);
|
||||
}
|
||||
const key = keyRecord(
|
||||
client
|
||||
.prepare(`${KEY_SELECT} WHERE pepper_key_id = ?`)
|
||||
.get(command.pepperKeyId) as unknown as KeyRow,
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'inserted' as const, key });
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof LocalOwnerPepperMutationConflictError ||
|
||||
error instanceof LocalOwnerPepperCatalogFullError ||
|
||||
error instanceof LocalOwnerPepperRepositoryUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
activate(
|
||||
input: ActivateLocalOwnerPepperKeyCommand,
|
||||
): Promise<ActivateLocalOwnerPepperKeyResult> {
|
||||
const command = normalizeActivateLocalOwnerPepperKeyCommand(input);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const replay = client
|
||||
.prepare(`${ACTIVATION_SELECT} WHERE mutation_id = ?`)
|
||||
.get(command.mutationId) as ActivationRow | undefined;
|
||||
if (replay) {
|
||||
const activation = activationRecord(replay);
|
||||
if (!sameActivation(activation, command)) {
|
||||
throw new LocalOwnerPepperMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'existing' as const, activation });
|
||||
}
|
||||
const currentRow = client
|
||||
.prepare(`${ACTIVATION_SELECT} ORDER BY generation DESC LIMIT 1`)
|
||||
.get() as ActivationRow | undefined;
|
||||
const current = currentRow ? activationRecord(currentRow) : null;
|
||||
const generation = current?.generation ?? 0;
|
||||
if (
|
||||
generation !== command.expectedGeneration ||
|
||||
current?.activePepperKeyId !== command.expectedActivePepperKeyId
|
||||
) {
|
||||
throw new LocalOwnerPepperGenerationConflictError();
|
||||
}
|
||||
const targetRow = client
|
||||
.prepare(`${KEY_SELECT} WHERE pepper_key_id = ?`)
|
||||
.get(command.pepperKeyId) as KeyRow | undefined;
|
||||
const target = targetRow ? keyRecord(targetRow) : null;
|
||||
if (
|
||||
!target ||
|
||||
target.state !== 'staged' ||
|
||||
!target.materialDigest ||
|
||||
!target.backupDigest ||
|
||||
command.activatedAtMs < target.registeredAtMs
|
||||
) {
|
||||
throw new LocalOwnerPepperKeyNotActivatableError();
|
||||
}
|
||||
if (current) {
|
||||
const retired = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalOwnerPepperKeys"
|
||||
SET state = 'retired', version = version + 1,
|
||||
retire_mutation_id = ?, retired_at_ms = ?
|
||||
WHERE pepper_key_id = ? AND state = 'active'`,
|
||||
)
|
||||
.run(
|
||||
command.mutationId,
|
||||
command.activatedAtMs,
|
||||
current.activePepperKeyId,
|
||||
);
|
||||
if (retired.changes !== 1) {
|
||||
throw new LocalOwnerPepperGenerationConflictError();
|
||||
}
|
||||
}
|
||||
const activated = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalOwnerPepperKeys"
|
||||
SET state = 'active', version = version + 1,
|
||||
activate_mutation_id = ?, activated_at_ms = ?
|
||||
WHERE pepper_key_id = ? AND version = ? AND state = 'staged'`,
|
||||
)
|
||||
.run(
|
||||
command.mutationId,
|
||||
command.activatedAtMs,
|
||||
command.pepperKeyId,
|
||||
target.version,
|
||||
);
|
||||
if (activated.changes !== 1) {
|
||||
throw new LocalOwnerPepperGenerationConflictError();
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||
generation, mutation_id, expected_generation,
|
||||
previous_pepper_key_id, active_pepper_key_id,
|
||||
material_digest, backup_digest, activated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
generation + 1,
|
||||
command.mutationId,
|
||||
command.expectedGeneration,
|
||||
current?.activePepperKeyId ?? null,
|
||||
command.pepperKeyId,
|
||||
target.materialDigest,
|
||||
target.backupDigest,
|
||||
command.activatedAtMs,
|
||||
);
|
||||
const activation = activationRecord(
|
||||
client
|
||||
.prepare(`${ACTIVATION_SELECT} WHERE mutation_id = ?`)
|
||||
.get(command.mutationId) as unknown as ActivationRow,
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'inserted' as const, activation });
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof LocalOwnerPepperMutationConflictError ||
|
||||
error instanceof LocalOwnerPepperGenerationConflictError ||
|
||||
error instanceof LocalOwnerPepperKeyNotActivatableError ||
|
||||
error instanceof LocalOwnerPepperRepositoryUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerPepperRepositoryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalOwnerPepperRepositoryUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user