Files
qinglong/packages/ql3-local-sqlite/src/local-owner/ownerCredentialRecoveryRepository.ts
T

672 lines
26 KiB
TypeScript

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(),
);
}
}