mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
ApiCredentialUnavailableError,
|
||||
assertApiCredentialId,
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRecord,
|
||||
type ApiCredentialRepository,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type CredentialRow = Record<string, unknown>;
|
||||
|
||||
function text(row: CredentialRow, name: string): string {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'string') throw new ApiCredentialUnavailableError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: CredentialRow, name: string): number {
|
||||
const value = row[name];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ApiCredentialUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function record(row: CredentialRow): Readonly<ApiCredentialRecord> {
|
||||
try {
|
||||
return normalizeApiCredentialRecord({
|
||||
credentialId: text(row, 'credentialId'),
|
||||
version: integer(row, 'version'),
|
||||
pepperKeyId: text(row, 'pepperKeyId'),
|
||||
state: text(row, 'state') as ApiCredentialRecord['state'],
|
||||
subject: {
|
||||
type: text(
|
||||
row,
|
||||
'subjectType',
|
||||
) as ApiCredentialRecord['subject']['type'],
|
||||
id: text(row, 'subjectId'),
|
||||
},
|
||||
subjectStatus: text(
|
||||
row,
|
||||
'subjectStatus',
|
||||
) as ApiCredentialRecord['subjectStatus'],
|
||||
secretDigest: text(row, 'secretDigest'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
notBeforeAtMs: integer(row, 'notBeforeAtMs'),
|
||||
expiresAtMs: integer(row, 'expiresAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiCredentialUnavailableError) throw error;
|
||||
throw new ApiCredentialUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSqliteApiCredentialRepository
|
||||
implements ApiCredentialRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
resolve(credentialId: string): Promise<Readonly<ApiCredentialRecord> | null> {
|
||||
assertApiCredentialId(credentialId);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`SELECT
|
||||
credential."credential_id" AS "credentialId",
|
||||
credential."version" AS "version",
|
||||
pepper."pepper_key_id" AS "pepperKeyId",
|
||||
credential."state" AS "state",
|
||||
credential."subject_type" AS "subjectType",
|
||||
credential."subject_id" AS "subjectId",
|
||||
identity."status" AS "subjectStatus",
|
||||
credential."secret_digest" AS "secretDigest",
|
||||
credential."created_at_ms" AS "createdAtMs",
|
||||
credential."not_before_at_ms" AS "notBeforeAtMs",
|
||||
credential."expires_at_ms" AS "expiresAtMs"
|
||||
FROM "QingLong3ApiCredentials" AS credential
|
||||
JOIN "QingLong3IdentitySubjects" AS identity
|
||||
ON identity."subject_type" = credential."subject_type"
|
||||
AND identity."subject_id" = credential."subject_id"
|
||||
LEFT JOIN "QingLong3ApiCredentialPepperBindings" AS pepper
|
||||
ON pepper."credential_id" = credential."credential_id"
|
||||
AND pepper."credential_version" = credential."version"
|
||||
WHERE credential."credential_id" = ?
|
||||
ORDER BY credential."version" DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(credentialId) as CredentialRow | undefined;
|
||||
return row ? record(row) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiCredentialUnavailableError) throw error;
|
||||
throw new ApiCredentialUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new ApiCredentialUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
type LocalIdentityAdministrationAuthorization,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import { resolveLocalInstanceAuthorityProjectId } from '../../authority/instanceAuthorityProject';
|
||||
import {
|
||||
LOCAL_ROLE_BINDING_SELECT,
|
||||
localRoleBindingFromRow,
|
||||
} from '../securityPersistence';
|
||||
|
||||
import { integer, type Row } from './codec';
|
||||
|
||||
export function assertAuthorizationInTransaction(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
auth: Readonly<LocalIdentityAdministrationAuthorization>,
|
||||
beforeMutation: () => void,
|
||||
): void {
|
||||
try {
|
||||
beforeMutation();
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAuthorizationFenceConflictError();
|
||||
}
|
||||
if (
|
||||
resolveLocalInstanceAuthorityProjectId(authority.client) !== auth.projectId
|
||||
) {
|
||||
throw new LocalIdentityCredentialAuthorizationFenceConflictError();
|
||||
}
|
||||
const project = authority.client
|
||||
.prepare(
|
||||
`SELECT "status" AS "status", "version" AS "version"
|
||||
FROM "QingLong3Projects" WHERE "id" = ?`,
|
||||
)
|
||||
.get(auth.projectId) as Row | undefined;
|
||||
const actorRow = authority.client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_ROLE_BINDING_SELECT}
|
||||
FROM "QingLong3ProjectRoleBindings"
|
||||
WHERE "project_id" = ? AND "subject_type" = ?
|
||||
AND "subject_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(auth.projectId, auth.actor.type, auth.actor.id) as Row | undefined;
|
||||
if (
|
||||
!project ||
|
||||
project.status !== 'active' ||
|
||||
integer(project, 'version') !== auth.fence.projectVersion ||
|
||||
!actorRow
|
||||
) {
|
||||
throw new LocalIdentityCredentialAuthorizationFenceConflictError();
|
||||
}
|
||||
const binding = localRoleBindingFromRow(actorRow);
|
||||
if (
|
||||
binding.version !== auth.fence.bindingVersion ||
|
||||
binding.state !== 'active' ||
|
||||
binding.role !== 'owner'
|
||||
) {
|
||||
throw new LocalIdentityCredentialAuthorizationFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
export function activeOwnerBindingExists(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return !!authority.client
|
||||
.prepare(
|
||||
`SELECT 1 AS "present"
|
||||
FROM "QingLong3ProjectRoleBindings" AS binding
|
||||
WHERE binding."subject_type" = ? AND binding."subject_id" = ?
|
||||
AND binding."state" = 'active' AND binding."role" = 'owner'
|
||||
AND binding."version" = (
|
||||
SELECT max(latest."version")
|
||||
FROM "QingLong3ProjectRoleBindings" AS latest
|
||||
WHERE latest."project_id" = binding."project_id"
|
||||
AND latest."subject_type" = binding."subject_type"
|
||||
AND latest."subject_id" = binding."subject_id"
|
||||
)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(subject.type, subject.id);
|
||||
}
|
||||
|
||||
export function anotherActiveCredentialExists(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
excludedCredentialId: string,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
return !!authority.client
|
||||
.prepare(
|
||||
`SELECT 1 AS "present"
|
||||
FROM "QingLong3ApiCredentials" AS credential
|
||||
JOIN "QingLong3ApiCredentialPepperBindings" AS binding
|
||||
ON binding."credential_id" = credential."credential_id"
|
||||
AND binding."credential_version" = credential."version"
|
||||
JOIN "QingLong3LocalOwnerPepperKeys" AS pepper
|
||||
ON pepper."pepper_key_id" = binding."pepper_key_id"
|
||||
WHERE credential."subject_type" = ?
|
||||
AND credential."subject_id" = ?
|
||||
AND credential."credential_id" <> ?
|
||||
AND credential."state" = 'active'
|
||||
AND credential."not_before_at_ms" <= ?
|
||||
AND credential."expires_at_ms" > ?
|
||||
AND pepper."state" IN ('active','retired')
|
||||
AND credential."version" = (
|
||||
SELECT max(latest."version")
|
||||
FROM "QingLong3ApiCredentials" AS latest
|
||||
WHERE latest."credential_id" = credential."credential_id"
|
||||
)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(subject.type, subject.id, excludedCredentialId, nowMs, nowMs);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { type ApiCredentialMutationRecord } from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRecord,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
normalizeIdentitySubjectRecord,
|
||||
type IdentitySubjectMutationRecord,
|
||||
type IdentitySubjectRecord,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
import {
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
type LocalIdentityAdministrationAuthorization,
|
||||
type ResolvedLocalApiCredentialMutation,
|
||||
type ResolvedLocalIdentitySubjectMutation,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import { sameSecurityAuditSemantic } from '../securityPersistence';
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const ADMINISTRABLE_SUBJECT_TYPES = new Set([
|
||||
'user',
|
||||
'api_app',
|
||||
'mcp_client',
|
||||
'agent',
|
||||
]);
|
||||
|
||||
export const IDENTITY_MUTATION_SELECT = `
|
||||
mutation."mutation_id" AS "mutationId",
|
||||
mutation."project_id" AS "mutationProjectId",
|
||||
mutation."operation" AS "operation",
|
||||
mutation."subject_type" AS "targetSubjectType",
|
||||
mutation."subject_id" AS "targetSubjectId",
|
||||
mutation."subject_version" AS "subjectVersion",
|
||||
mutation."expected_previous_version" AS "expectedPreviousVersion",
|
||||
mutation."status" AS "status",
|
||||
mutation."changed_by_type" AS "changedByType",
|
||||
mutation."changed_by_id" AS "changedById",
|
||||
mutation."identity_created_at_ms" AS "identityCreatedAtMs",
|
||||
mutation."created_at_ms" AS "createdAtMs"
|
||||
`;
|
||||
|
||||
export const CREDENTIAL_MUTATION_SELECT = `
|
||||
mutation."mutation_id" AS "mutationId",
|
||||
mutation."project_id" AS "mutationProjectId",
|
||||
mutation."operation" AS "operation",
|
||||
mutation."credential_id" AS "credentialId",
|
||||
mutation."credential_version" AS "credentialVersion",
|
||||
mutation."expected_previous_version" AS "expectedPreviousVersion",
|
||||
mutation."subject_type" AS "targetSubjectType",
|
||||
mutation."subject_id" AS "targetSubjectId",
|
||||
mutation."subject_status" AS "subjectStatus",
|
||||
mutation."state" AS "state",
|
||||
mutation."pepper_key_id" AS "pepperKeyId",
|
||||
mutation."secret_digest" AS "secretDigest",
|
||||
mutation."not_before_at_ms" AS "notBeforeAtMs",
|
||||
mutation."expires_at_ms" AS "expiresAtMs",
|
||||
mutation."delivery_digest" AS "deliveryDigest",
|
||||
mutation."changed_by_type" AS "changedByType",
|
||||
mutation."changed_by_id" AS "changedById",
|
||||
mutation."created_at_ms" AS "createdAtMs"
|
||||
`;
|
||||
|
||||
export const ADMIN_AUDIT_SELECT = `
|
||||
audit."event_id" AS "eventId",
|
||||
audit."request_id" AS "requestId",
|
||||
audit."operation_id" AS "operationId",
|
||||
audit."project_id" AS "projectId",
|
||||
audit."subject_type" AS "subjectType",
|
||||
audit."subject_id" AS "subjectId",
|
||||
audit."authentication_id" AS "authenticationId",
|
||||
audit."outcome" AS "outcome",
|
||||
audit."reasons_json" AS "reasonsJson",
|
||||
audit."fence_project_version" AS "fenceProjectVersion",
|
||||
audit."fence_binding_version" AS "fenceBindingVersion",
|
||||
audit."occurred_at_ms" AS "occurredAtMs"
|
||||
`;
|
||||
|
||||
export function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value !== null && typeof value !== 'string') {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
return value as string | null;
|
||||
}
|
||||
|
||||
export function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
export function exactFence(
|
||||
value: SecurityPolicyFence,
|
||||
): Readonly<SecurityPolicyFence> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !== 'bindingVersion,projectVersion' ||
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
!Number.isSafeInteger(value.bindingVersion) ||
|
||||
(value.bindingVersion as number) < 1
|
||||
) {
|
||||
throw new TypeError('Local Identity administration fence is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectVersion: value.projectVersion,
|
||||
bindingVersion: value.bindingVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function authorization(
|
||||
value: LocalIdentityAdministrationAuthorization,
|
||||
): Readonly<LocalIdentityAdministrationAuthorization> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !== 'actor,fence,projectId'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local Identity administration authorization is invalid',
|
||||
);
|
||||
}
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
if (actor.type !== 'user') {
|
||||
throw new TypeError('Local Identity administration actor is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
actor,
|
||||
fence: exactFence(value.fence),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertAllowedAudit(
|
||||
input: SecurityAuditRecord,
|
||||
operationId: string,
|
||||
mutationId: string,
|
||||
auth: Readonly<LocalIdentityAdministrationAuthorization>,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
const audit = normalizeSecurityAuditRecord(input);
|
||||
if (
|
||||
audit.eventId !== mutationId ||
|
||||
audit.operationId !== operationId ||
|
||||
audit.projectId !== auth.projectId ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, auth.actor) ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.fence?.projectVersion !== auth.fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== auth.fence.bindingVersion
|
||||
) {
|
||||
throw new TypeError('Local Identity administration audit is invalid');
|
||||
}
|
||||
return audit;
|
||||
}
|
||||
|
||||
export function identityFromRow(row: Row): Readonly<IdentitySubjectRecord> {
|
||||
return normalizeIdentitySubjectRecord({
|
||||
subject: {
|
||||
type: text(row, 'subjectType') as SecuritySubject['type'],
|
||||
id: text(row, 'subjectId'),
|
||||
},
|
||||
status: text(row, 'status') as IdentitySubjectRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
updatedAtMs: integer(row, 'updatedAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function identityMutationFromRow(
|
||||
row: Row,
|
||||
): Readonly<IdentitySubjectMutationRecord> {
|
||||
return Object.freeze({
|
||||
mutationId: text(row, 'mutationId'),
|
||||
operation: text(
|
||||
row,
|
||||
'operation',
|
||||
) as IdentitySubjectMutationRecord['operation'],
|
||||
subject: Object.freeze({
|
||||
type: text(row, 'targetSubjectType') as SecuritySubject['type'],
|
||||
id: text(row, 'targetSubjectId'),
|
||||
}),
|
||||
subjectVersion: integer(row, 'subjectVersion'),
|
||||
expectedPreviousVersion: integer(row, 'expectedPreviousVersion'),
|
||||
status: text(row, 'status') as IdentitySubjectMutationRecord['status'],
|
||||
changedBy: Object.freeze({
|
||||
type: text(row, 'changedByType') as SecuritySubject['type'],
|
||||
id: text(row, 'changedById'),
|
||||
}),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function identityResultFromMutationRow(
|
||||
row: Row,
|
||||
): Readonly<IdentitySubjectRecord> {
|
||||
return normalizeIdentitySubjectRecord({
|
||||
subject: {
|
||||
type: text(row, 'targetSubjectType') as SecuritySubject['type'],
|
||||
id: text(row, 'targetSubjectId'),
|
||||
},
|
||||
status: text(row, 'status') as IdentitySubjectRecord['status'],
|
||||
version: integer(row, 'subjectVersion'),
|
||||
createdAtMs: integer(row, 'identityCreatedAtMs'),
|
||||
updatedAtMs: integer(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function credentialFromMutationRow(
|
||||
row: Row,
|
||||
): Readonly<ApiCredentialRecord> {
|
||||
return normalizeApiCredentialRecord({
|
||||
credentialId: text(row, 'credentialId'),
|
||||
version: integer(row, 'credentialVersion'),
|
||||
pepperKeyId: text(row, 'pepperKeyId'),
|
||||
state: text(row, 'state') as ApiCredentialRecord['state'],
|
||||
subject: {
|
||||
type: text(row, 'targetSubjectType') as SecuritySubject['type'],
|
||||
id: text(row, 'targetSubjectId'),
|
||||
},
|
||||
subjectStatus: text(
|
||||
row,
|
||||
'subjectStatus',
|
||||
) as ApiCredentialRecord['subjectStatus'],
|
||||
secretDigest: text(row, 'secretDigest'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
notBeforeAtMs: integer(row, 'notBeforeAtMs'),
|
||||
expiresAtMs: integer(row, 'expiresAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function credentialMutationFromRow(
|
||||
row: Row,
|
||||
): Readonly<ApiCredentialMutationRecord> {
|
||||
return Object.freeze({
|
||||
mutationId: text(row, 'mutationId'),
|
||||
operation: text(
|
||||
row,
|
||||
'operation',
|
||||
) as ApiCredentialMutationRecord['operation'],
|
||||
credentialId: text(row, 'credentialId'),
|
||||
credentialVersion: integer(row, 'credentialVersion'),
|
||||
expectedPreviousVersion: integer(row, 'expectedPreviousVersion'),
|
||||
changedBy: Object.freeze({
|
||||
type: text(row, 'changedByType') as SecuritySubject['type'],
|
||||
id: text(row, 'changedById'),
|
||||
}),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function sameIdentitySemantic(
|
||||
existing: Readonly<ResolvedLocalIdentitySubjectMutation>,
|
||||
expected: Readonly<{
|
||||
projectId: string;
|
||||
identity: IdentitySubjectRecord;
|
||||
mutation: IdentitySubjectMutationRecord;
|
||||
audit: SecurityAuditRecord;
|
||||
}>,
|
||||
): boolean {
|
||||
return (
|
||||
existing.projectId === expected.projectId &&
|
||||
sameSubject(existing.identity.subject, expected.identity.subject) &&
|
||||
existing.identity.status === expected.identity.status &&
|
||||
existing.identity.version === expected.identity.version &&
|
||||
existing.mutation.operation === expected.mutation.operation &&
|
||||
existing.mutation.subjectVersion === expected.mutation.subjectVersion &&
|
||||
existing.mutation.expectedPreviousVersion ===
|
||||
expected.mutation.expectedPreviousVersion &&
|
||||
sameSubject(existing.mutation.changedBy, expected.mutation.changedBy) &&
|
||||
sameSecurityAuditSemantic(existing.audit, expected.audit)
|
||||
);
|
||||
}
|
||||
|
||||
export function sameCredentialSemantic(
|
||||
existing: Readonly<ResolvedLocalApiCredentialMutation>,
|
||||
expected: Readonly<{
|
||||
projectId: string;
|
||||
credential: ApiCredentialRecord;
|
||||
mutation: ApiCredentialMutationRecord;
|
||||
deliveryDigest: string | null;
|
||||
audit: SecurityAuditRecord;
|
||||
}>,
|
||||
): boolean {
|
||||
const left = existing.credential;
|
||||
const right = expected.credential;
|
||||
return (
|
||||
existing.projectId === expected.projectId &&
|
||||
existing.mutation.operation === expected.mutation.operation &&
|
||||
existing.mutation.credentialId === expected.mutation.credentialId &&
|
||||
existing.mutation.credentialVersion ===
|
||||
expected.mutation.credentialVersion &&
|
||||
existing.mutation.expectedPreviousVersion ===
|
||||
expected.mutation.expectedPreviousVersion &&
|
||||
sameSubject(existing.mutation.changedBy, expected.mutation.changedBy) &&
|
||||
left.credentialId === right.credentialId &&
|
||||
left.version === right.version &&
|
||||
left.pepperKeyId === right.pepperKeyId &&
|
||||
left.state === right.state &&
|
||||
sameSubject(left.subject, right.subject) &&
|
||||
left.subjectStatus === right.subjectStatus &&
|
||||
left.secretDigest === right.secretDigest &&
|
||||
left.notBeforeAtMs === right.notBeforeAtMs &&
|
||||
left.expiresAtMs === right.expiresAtMs &&
|
||||
(existing.delivery?.digest ?? null) === expected.deliveryDigest &&
|
||||
sameSecurityAuditSemantic(existing.audit, expected.audit)
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { LocalIdentityCredentialAdministrationUnavailableError } from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import { type SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import { resolveLocalInstanceAuthorityProjectId } from '../../authority/instanceAuthorityProject';
|
||||
import { LocalSqliteSecurityAuthorityStore } from '../securityAuthorityStore';
|
||||
|
||||
export function record(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
audit: SecurityAuditRecord,
|
||||
): Promise<void> {
|
||||
return new LocalSqliteSecurityAuthorityStore(authority).record(audit);
|
||||
}
|
||||
|
||||
export function resolveAuthorityProjectId(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
): Promise<string | null> {
|
||||
return authority.enqueue(
|
||||
async () => resolveLocalInstanceAuthorityProjectId(authority.client),
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
ApiCredentialAdministrationSubjectNotFoundError,
|
||||
ApiCredentialAdministrationVersionConflictError,
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
normalizeApiCredentialAdministrationMutationId,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
assertApiCredentialId,
|
||||
normalizeApiCredentialRecord,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
LocalCredentialOwnerContinuityError,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
type AppendAuthorizedLocalApiCredentialCommand,
|
||||
type AppendAuthorizedLocalApiCredentialResult,
|
||||
type InspectAuthorizedLocalApiCredentialCommand,
|
||||
type InspectAuthorizedLocalApiCredentialResult,
|
||||
type ResolvedLocalApiCredentialMutation,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import { SecurityAuditUnavailableError } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import {
|
||||
insertLocalSecurityAudit,
|
||||
localSecurityAuditFromRow,
|
||||
} from '../securityPersistence';
|
||||
|
||||
import {
|
||||
ADMIN_AUDIT_SELECT,
|
||||
ADMINISTRABLE_SUBJECT_TYPES,
|
||||
CREDENTIAL_MUTATION_SELECT,
|
||||
DIGEST_PATTERN,
|
||||
assertAllowedAudit,
|
||||
authorization,
|
||||
credentialFromMutationRow,
|
||||
credentialMutationFromRow,
|
||||
identityFromRow,
|
||||
integer,
|
||||
optionalText,
|
||||
sameCredentialSemantic,
|
||||
sameSubject,
|
||||
text,
|
||||
type Row,
|
||||
} from './codec';
|
||||
|
||||
import {
|
||||
activeOwnerBindingExists,
|
||||
anotherActiveCredentialExists,
|
||||
assertAuthorizationInTransaction,
|
||||
} from './authorization';
|
||||
|
||||
export function resolveCredentialMutation(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
requestedMutationId: string,
|
||||
): Promise<Readonly<ResolvedLocalApiCredentialMutation> | null> {
|
||||
normalizeApiCredentialAdministrationMutationId(requestedMutationId);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const row = authority.client
|
||||
.prepare(
|
||||
`SELECT ${CREDENTIAL_MUTATION_SELECT},
|
||||
${ADMIN_AUDIT_SELECT}
|
||||
FROM "QingLong3ApiCredentialAdministrationMutations" AS mutation
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = mutation."audit_event_id"
|
||||
WHERE mutation."mutation_id" = ?`,
|
||||
)
|
||||
.get(requestedMutationId) as Row | undefined;
|
||||
if (!row) return null;
|
||||
const deliveryDigest = optionalText(row, 'deliveryDigest');
|
||||
return Object.freeze({
|
||||
projectId: text(row, 'mutationProjectId'),
|
||||
credential: credentialFromMutationRow(row),
|
||||
mutation: credentialMutationFromRow(row),
|
||||
delivery:
|
||||
deliveryDigest === null
|
||||
? null
|
||||
: Object.freeze({ digest: deliveryDigest }),
|
||||
audit: localSecurityAuditFromRow(row),
|
||||
});
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectAuthorizedCredential(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
beforeMutation: () => void,
|
||||
input: InspectAuthorizedLocalApiCredentialCommand,
|
||||
): Promise<InspectAuthorizedLocalApiCredentialResult> {
|
||||
assertApiCredentialId(input.credentialId);
|
||||
const auth = authorization(input.authorization);
|
||||
const audit = assertAllowedAudit(
|
||||
input.audit,
|
||||
'credential.inspect',
|
||||
input.audit.eventId,
|
||||
auth,
|
||||
);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const client = authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
assertAuthorizationInTransaction(authority, auth, beforeMutation);
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT credential."credential_id" AS "credentialId",
|
||||
credential."version" AS "credentialVersion",
|
||||
pepper."pepper_key_id" AS "pepperKeyId",
|
||||
credential."state" AS "state",
|
||||
credential."subject_type" AS "targetSubjectType",
|
||||
credential."subject_id" AS "targetSubjectId",
|
||||
identity."status" AS "subjectStatus",
|
||||
credential."secret_digest" AS "secretDigest",
|
||||
credential."created_at_ms" AS "createdAtMs",
|
||||
credential."not_before_at_ms" AS "notBeforeAtMs",
|
||||
credential."expires_at_ms" AS "expiresAtMs"
|
||||
FROM "QingLong3ApiCredentials" AS credential
|
||||
JOIN "QingLong3IdentitySubjects" AS identity
|
||||
ON identity."subject_type" = credential."subject_type"
|
||||
AND identity."subject_id" = credential."subject_id"
|
||||
LEFT JOIN "QingLong3ApiCredentialPepperBindings" AS pepper
|
||||
ON pepper."credential_id" = credential."credential_id"
|
||||
AND pepper."credential_version" = credential."version"
|
||||
WHERE credential."credential_id" = ?
|
||||
ORDER BY credential."version" DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(input.credentialId) as Row | undefined;
|
||||
insertLocalSecurityAudit(client, audit);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
credential: row ? credentialFromMutationRow(row) : null,
|
||||
audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof SecurityAuditUnavailableError) throw error;
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendAuthorizedCredential(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
beforeMutation: () => void,
|
||||
input: AppendAuthorizedLocalApiCredentialCommand,
|
||||
): Promise<AppendAuthorizedLocalApiCredentialResult> {
|
||||
const auth = authorization(input.authorization);
|
||||
const credential = normalizeApiCredentialRecord(input.credential);
|
||||
const mutation = input.mutation;
|
||||
normalizeApiCredentialAdministrationMutationId(mutation.mutationId);
|
||||
const deliveryDigest = input.delivery?.digest ?? null;
|
||||
if (
|
||||
!ADMINISTRABLE_SUBJECT_TYPES.has(credential.subject.type) ||
|
||||
!Number.isSafeInteger(input.expectedCurrentVersion) ||
|
||||
input.expectedCurrentVersion < 0 ||
|
||||
mutation.credentialId !== credential.credentialId ||
|
||||
mutation.credentialVersion !== input.expectedCurrentVersion + 1 ||
|
||||
mutation.expectedPreviousVersion !== input.expectedCurrentVersion ||
|
||||
credential.version !== mutation.credentialVersion ||
|
||||
!sameSubject(mutation.changedBy, auth.actor) ||
|
||||
(mutation.operation === 'issue'
|
||||
? input.expectedCurrentVersion !== 0 ||
|
||||
credential.state !== 'active' ||
|
||||
deliveryDigest === null
|
||||
: mutation.operation === 'rotate'
|
||||
? input.expectedCurrentVersion < 1 ||
|
||||
credential.state !== 'active' ||
|
||||
deliveryDigest === null
|
||||
: input.expectedCurrentVersion < 1 ||
|
||||
credential.state !== 'revoked' ||
|
||||
credential.secretDigest !== REVOKED_API_CREDENTIAL_DIGEST ||
|
||||
deliveryDigest !== null) ||
|
||||
(deliveryDigest !== null && !DIGEST_PATTERN.test(deliveryDigest))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local API credential administration command is invalid',
|
||||
);
|
||||
}
|
||||
const audit = assertAllowedAudit(
|
||||
input.audit,
|
||||
`credential.${mutation.operation}`,
|
||||
mutation.mutationId,
|
||||
auth,
|
||||
);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const client = authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
assertAuthorizationInTransaction(authority, auth, beforeMutation);
|
||||
const replayRow = client
|
||||
.prepare(
|
||||
`SELECT ${CREDENTIAL_MUTATION_SELECT},
|
||||
${ADMIN_AUDIT_SELECT}
|
||||
FROM "QingLong3ApiCredentialAdministrationMutations" AS mutation
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = mutation."audit_event_id"
|
||||
WHERE mutation."mutation_id" = ?`,
|
||||
)
|
||||
.get(mutation.mutationId) as Row | undefined;
|
||||
if (replayRow) {
|
||||
const replayDelivery = optionalText(replayRow, 'deliveryDigest');
|
||||
const existing = Object.freeze({
|
||||
projectId: text(replayRow, 'mutationProjectId'),
|
||||
credential: credentialFromMutationRow(replayRow),
|
||||
mutation: credentialMutationFromRow(replayRow),
|
||||
delivery:
|
||||
replayDelivery === null
|
||||
? null
|
||||
: Object.freeze({ digest: replayDelivery }),
|
||||
audit: localSecurityAuditFromRow(replayRow),
|
||||
});
|
||||
if (
|
||||
!sameCredentialSemantic(existing, {
|
||||
projectId: auth.projectId,
|
||||
credential,
|
||||
mutation,
|
||||
deliveryDigest,
|
||||
audit,
|
||||
})
|
||||
) {
|
||||
throw new ApiCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
credential: existing.credential,
|
||||
mutation: existing.mutation,
|
||||
delivery: existing.delivery,
|
||||
audit: existing.audit,
|
||||
});
|
||||
}
|
||||
const identityRow = client
|
||||
.prepare(
|
||||
`SELECT "subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId",
|
||||
"status" AS "status", "version" AS "version",
|
||||
"created_at_ms" AS "createdAtMs",
|
||||
"updated_at_ms" AS "updatedAtMs"
|
||||
FROM "QingLong3IdentitySubjects"
|
||||
WHERE "subject_type" = ? AND "subject_id" = ?`,
|
||||
)
|
||||
.get(credential.subject.type, credential.subject.id) as
|
||||
| Row
|
||||
| undefined;
|
||||
const identity = identityRow ? identityFromRow(identityRow) : null;
|
||||
if (
|
||||
!identity ||
|
||||
(mutation.operation !== 'revoke' && identity.status !== 'active') ||
|
||||
identity.status !== credential.subjectStatus
|
||||
) {
|
||||
throw new ApiCredentialAdministrationSubjectNotFoundError();
|
||||
}
|
||||
const currentRow = client
|
||||
.prepare(
|
||||
`SELECT "credential_id" AS "credentialId",
|
||||
"version" AS "version", "state" AS "state",
|
||||
"subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId"
|
||||
FROM "QingLong3ApiCredentials"
|
||||
WHERE "credential_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(credential.credentialId) as Row | undefined;
|
||||
const currentVersion = currentRow ? integer(currentRow, 'version') : 0;
|
||||
if (
|
||||
currentVersion !== input.expectedCurrentVersion ||
|
||||
(currentRow &&
|
||||
(text(currentRow, 'subjectType') !== credential.subject.type ||
|
||||
text(currentRow, 'subjectId') !== credential.subject.id))
|
||||
) {
|
||||
throw new ApiCredentialAdministrationVersionConflictError();
|
||||
}
|
||||
if (
|
||||
mutation.operation === 'revoke' &&
|
||||
credential.subject.type === 'user' &&
|
||||
activeOwnerBindingExists(authority, credential.subject) &&
|
||||
!anotherActiveCredentialExists(
|
||||
authority,
|
||||
credential.subject,
|
||||
credential.credentialId,
|
||||
mutation.createdAtMs,
|
||||
)
|
||||
) {
|
||||
throw new LocalCredentialOwnerContinuityError();
|
||||
}
|
||||
if (mutation.operation !== 'revoke') {
|
||||
const pepper = client
|
||||
.prepare(
|
||||
`SELECT "state" AS "state"
|
||||
FROM "QingLong3LocalOwnerPepperKeys"
|
||||
WHERE "pepper_key_id" = ?`,
|
||||
)
|
||||
.get(credential.pepperKeyId) as Row | undefined;
|
||||
if (!pepper || pepper.state !== 'active') {
|
||||
throw new LocalIdentityCredentialAuthorizationFenceConflictError();
|
||||
}
|
||||
}
|
||||
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(
|
||||
credential.credentialId,
|
||||
credential.version,
|
||||
credential.state,
|
||||
credential.subject.type,
|
||||
credential.subject.id,
|
||||
credential.secretDigest,
|
||||
credential.createdAtMs,
|
||||
credential.notBeforeAtMs,
|
||||
credential.expiresAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
credential.credentialId,
|
||||
credential.version,
|
||||
credential.pepperKeyId,
|
||||
);
|
||||
insertLocalSecurityAudit(client, audit);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialAdministrationMutations" (
|
||||
"mutation_id", "project_id", "operation", "credential_id",
|
||||
"credential_version", "expected_previous_version",
|
||||
"subject_type", "subject_id", "subject_status", "state",
|
||||
"pepper_key_id", "secret_digest", "not_before_at_ms",
|
||||
"expires_at_ms", "delivery_digest", "changed_by_type",
|
||||
"changed_by_id", "audit_event_id", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
mutation.mutationId,
|
||||
auth.projectId,
|
||||
mutation.operation,
|
||||
credential.credentialId,
|
||||
credential.version,
|
||||
mutation.expectedPreviousVersion,
|
||||
credential.subject.type,
|
||||
credential.subject.id,
|
||||
credential.subjectStatus,
|
||||
credential.state,
|
||||
credential.pepperKeyId,
|
||||
credential.secretDigest,
|
||||
credential.notBeforeAtMs,
|
||||
credential.expiresAtMs,
|
||||
deliveryDigest,
|
||||
auth.actor.type,
|
||||
auth.actor.id,
|
||||
audit.eventId,
|
||||
credential.createdAtMs,
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
credential,
|
||||
mutation: Object.freeze({ ...mutation }),
|
||||
delivery:
|
||||
deliveryDigest === null
|
||||
? null
|
||||
: Object.freeze({ digest: deliveryDigest }),
|
||||
audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError ||
|
||||
error instanceof LocalCredentialOwnerContinuityError ||
|
||||
error instanceof ApiCredentialAdministrationSubjectNotFoundError ||
|
||||
error instanceof ApiCredentialAdministrationVersionConflictError ||
|
||||
error instanceof ApiCredentialAdministrationMutationConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof SecurityAuditUnavailableError) throw error;
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { type ApiCredentialRepository } from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
type LocalIdentityCredentialAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import { type ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
|
||||
import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import { LocalSqliteApiCredentialRepository } from '../apiCredentialRepository';
|
||||
import {
|
||||
assertLocalSqliteOptions,
|
||||
assertLocalSqlitePathBoundary,
|
||||
openLocalSqliteClient,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteProfile,
|
||||
} from '../../storage/config';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import { LocalSqliteOwnerPepperRepository } from '../../local-owner/ownerPepperRepository';
|
||||
import {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '../../administration/packageManagement';
|
||||
import {
|
||||
auditLocalSqliteReadiness,
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '../../readiness/readiness';
|
||||
import { LocalSqliteSecurityAuthorityStore } from '../securityAuthorityStore';
|
||||
|
||||
import { LocalSqliteIdentityCredentialAdministrationRepository } from './repository';
|
||||
|
||||
export interface LocalSqliteIdentityCredentialAdministrationDatabase {
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
readonly ownerPepper: Pick<
|
||||
LocalOwnerPepperRepository,
|
||||
'resolveActive' | 'resolveKey'
|
||||
>;
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly identityCredentialAdministration: LocalIdentityCredentialAdministrationRepository;
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function sameCredentialFence(
|
||||
left: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
right: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): boolean {
|
||||
return (
|
||||
left.credentialId === right.credentialId &&
|
||||
left.credentialVersion === right.credentialVersion &&
|
||||
left.pepperKeyId === right.pepperKeyId &&
|
||||
left.materialDigest === right.materialDigest &&
|
||||
left.subjectType === right.subjectType &&
|
||||
left.subjectId === right.subjectId &&
|
||||
left.secretDigest === right.secretDigest &&
|
||||
left.notBeforeAtMs === right.notBeforeAtMs &&
|
||||
left.expiresAtMs === right.expiresAtMs
|
||||
);
|
||||
}
|
||||
|
||||
export async function openLocalSqliteIdentityCredentialAdministrationDatabase(
|
||||
options: LocalSqliteDatabaseOptions,
|
||||
): Promise<LocalSqliteIdentityCredentialAdministrationDatabase> {
|
||||
assertLocalSqliteOptions(options);
|
||||
assertLocalSqlitePathBoundary(options.databasePath, false);
|
||||
const client = openLocalSqliteClient(options, false);
|
||||
try {
|
||||
const readiness = await auditLocalSqliteReadiness(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let activeFence:
|
||||
| Readonly<LocalSqliteAuthenticatedUserCredentialFence>
|
||||
| undefined;
|
||||
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
||||
const projectPolicy: ProjectPolicyRepository = Object.freeze({
|
||||
resolve: (
|
||||
...[projectId, subject]: Parameters<ProjectPolicyRepository['resolve']>
|
||||
) => securityAuthority.resolve(projectId, subject),
|
||||
append: (...[command]: Parameters<ProjectPolicyRepository['append']>) =>
|
||||
securityAuthority.append(command),
|
||||
});
|
||||
const identityCredentialAdministration =
|
||||
new LocalSqliteIdentityCredentialAdministrationRepository(
|
||||
authority,
|
||||
() => {
|
||||
if (!activeFence) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
authority,
|
||||
activeFence,
|
||||
);
|
||||
},
|
||||
);
|
||||
let closePromise: Promise<void> | undefined;
|
||||
return Object.freeze({
|
||||
profile: options.profile,
|
||||
readiness,
|
||||
apiCredentials: new LocalSqliteApiCredentialRepository(authority),
|
||||
ownerPepper: new LocalSqliteOwnerPepperRepository(authority),
|
||||
projectPolicy,
|
||||
identityCredentialAdministration,
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
) {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
|
||||
if (activeFence && !sameCredentialFence(activeFence, fence)) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
activeFence = Object.freeze({ ...fence });
|
||||
},
|
||||
close() {
|
||||
if (!closePromise) {
|
||||
closePromise = authority.close().catch(() => {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
});
|
||||
}
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isOpen) client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import { normalizeApiCredentialAdministrationMutationId } from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
LocalCredentialDeliveryMutationConflictError,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
type AppendAuthorizedLocalCredentialDeliveryAcknowledgementCommand,
|
||||
type AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult,
|
||||
type LocalCredentialDeliveryAcknowledgementRecord,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
import { SecurityAuditUnavailableError } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import {
|
||||
insertLocalSecurityAudit,
|
||||
localSecurityAuditFromRow,
|
||||
sameSecurityAuditSemantic,
|
||||
} from '../securityPersistence';
|
||||
|
||||
import {
|
||||
ADMIN_AUDIT_SELECT,
|
||||
DIGEST_PATTERN,
|
||||
assertAllowedAudit,
|
||||
authorization,
|
||||
integer,
|
||||
optionalText,
|
||||
sameSubject,
|
||||
text,
|
||||
type Row,
|
||||
} from './codec';
|
||||
|
||||
import { assertAuthorizationInTransaction } from './authorization';
|
||||
|
||||
export function resolveDeliveryAcknowledgement(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
credentialMutationId: string,
|
||||
): Promise<Readonly<LocalCredentialDeliveryAcknowledgementRecord> | null> {
|
||||
normalizeApiCredentialAdministrationMutationId(credentialMutationId);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const row = authority.client
|
||||
.prepare(
|
||||
`SELECT "credential_mutation_id" AS "credentialMutationId",
|
||||
"acknowledgement_mutation_id" AS "acknowledgementMutationId",
|
||||
"project_id" AS "projectId",
|
||||
"delivery_digest" AS "deliveryDigest",
|
||||
"acknowledged_by_type" AS "acknowledgedByType",
|
||||
"acknowledged_by_id" AS "acknowledgedById",
|
||||
"acknowledged_at_ms" AS "acknowledgedAtMs"
|
||||
FROM "QingLong3ApiCredentialDeliveryAcknowledgements"
|
||||
WHERE "credential_mutation_id" = ?`,
|
||||
)
|
||||
.get(credentialMutationId) as Row | undefined;
|
||||
return row
|
||||
? Object.freeze({
|
||||
credentialMutationId: text(row, 'credentialMutationId'),
|
||||
acknowledgementMutationId: text(row, 'acknowledgementMutationId'),
|
||||
projectId: text(row, 'projectId'),
|
||||
deliveryDigest: text(row, 'deliveryDigest'),
|
||||
acknowledgedBy: Object.freeze({
|
||||
type: text(row, 'acknowledgedByType') as SecuritySubject['type'],
|
||||
id: text(row, 'acknowledgedById'),
|
||||
}),
|
||||
acknowledgedAtMs: integer(row, 'acknowledgedAtMs'),
|
||||
})
|
||||
: null;
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendAuthorizedDeliveryAcknowledgement(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
beforeMutation: () => void,
|
||||
input: AppendAuthorizedLocalCredentialDeliveryAcknowledgementCommand,
|
||||
): Promise<AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult> {
|
||||
const auth = authorization(input.authorization);
|
||||
const acknowledgement = input.acknowledgement;
|
||||
normalizeApiCredentialAdministrationMutationId(
|
||||
acknowledgement.credentialMutationId,
|
||||
);
|
||||
normalizeApiCredentialAdministrationMutationId(
|
||||
acknowledgement.acknowledgementMutationId,
|
||||
);
|
||||
if (
|
||||
acknowledgement.credentialMutationId ===
|
||||
acknowledgement.acknowledgementMutationId ||
|
||||
acknowledgement.projectId !== auth.projectId ||
|
||||
!DIGEST_PATTERN.test(acknowledgement.deliveryDigest) ||
|
||||
!sameSubject(acknowledgement.acknowledgedBy, auth.actor) ||
|
||||
!Number.isSafeInteger(acknowledgement.acknowledgedAtMs) ||
|
||||
acknowledgement.acknowledgedAtMs < 0
|
||||
) {
|
||||
throw new TypeError('Local credential delivery acknowledgement is invalid');
|
||||
}
|
||||
const audit = assertAllowedAudit(
|
||||
input.audit,
|
||||
'credential.delivery.acknowledge',
|
||||
acknowledgement.acknowledgementMutationId,
|
||||
auth,
|
||||
);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const client = authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
assertAuthorizationInTransaction(authority, auth, beforeMutation);
|
||||
const existing = client
|
||||
.prepare(
|
||||
`SELECT "credential_mutation_id" AS "credentialMutationId",
|
||||
"acknowledgement_mutation_id" AS "acknowledgementMutationId",
|
||||
"project_id" AS "projectId",
|
||||
"delivery_digest" AS "deliveryDigest",
|
||||
"acknowledged_by_type" AS "acknowledgedByType",
|
||||
"acknowledged_by_id" AS "acknowledgedById",
|
||||
"acknowledged_at_ms" AS "acknowledgedAtMs"
|
||||
FROM "QingLong3ApiCredentialDeliveryAcknowledgements"
|
||||
WHERE "credential_mutation_id" = ?
|
||||
OR "acknowledgement_mutation_id" = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(
|
||||
acknowledgement.credentialMutationId,
|
||||
acknowledgement.acknowledgementMutationId,
|
||||
) as Row | undefined;
|
||||
if (existing) {
|
||||
const stored = Object.freeze({
|
||||
credentialMutationId: text(existing, 'credentialMutationId'),
|
||||
acknowledgementMutationId: text(
|
||||
existing,
|
||||
'acknowledgementMutationId',
|
||||
),
|
||||
projectId: text(existing, 'projectId'),
|
||||
deliveryDigest: text(existing, 'deliveryDigest'),
|
||||
acknowledgedBy: Object.freeze({
|
||||
type: text(
|
||||
existing,
|
||||
'acknowledgedByType',
|
||||
) as SecuritySubject['type'],
|
||||
id: text(existing, 'acknowledgedById'),
|
||||
}),
|
||||
acknowledgedAtMs: integer(existing, 'acknowledgedAtMs'),
|
||||
});
|
||||
const auditRow = client
|
||||
.prepare(
|
||||
`SELECT ${ADMIN_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents" AS audit
|
||||
WHERE "event_id" = ?`,
|
||||
)
|
||||
.get(stored.acknowledgementMutationId) as Row | undefined;
|
||||
if (
|
||||
stored.credentialMutationId !==
|
||||
acknowledgement.credentialMutationId ||
|
||||
stored.acknowledgementMutationId !==
|
||||
acknowledgement.acknowledgementMutationId ||
|
||||
stored.projectId !== acknowledgement.projectId ||
|
||||
stored.deliveryDigest !== acknowledgement.deliveryDigest ||
|
||||
!sameSubject(
|
||||
stored.acknowledgedBy,
|
||||
acknowledgement.acknowledgedBy,
|
||||
) ||
|
||||
!auditRow ||
|
||||
!sameSecurityAuditSemantic(
|
||||
localSecurityAuditFromRow(auditRow),
|
||||
audit,
|
||||
)
|
||||
) {
|
||||
throw new LocalCredentialDeliveryMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
acknowledgement: stored,
|
||||
audit: localSecurityAuditFromRow(auditRow),
|
||||
});
|
||||
}
|
||||
const credentialMutation = client
|
||||
.prepare(
|
||||
`SELECT "project_id" AS "projectId",
|
||||
"delivery_digest" AS "deliveryDigest",
|
||||
"operation" AS "operation"
|
||||
FROM "QingLong3ApiCredentialAdministrationMutations"
|
||||
WHERE "mutation_id" = ?`,
|
||||
)
|
||||
.get(acknowledgement.credentialMutationId) as Row | undefined;
|
||||
if (
|
||||
!credentialMutation ||
|
||||
text(credentialMutation, 'projectId') !== auth.projectId ||
|
||||
text(credentialMutation, 'operation') === 'revoke' ||
|
||||
optionalText(credentialMutation, 'deliveryDigest') !==
|
||||
acknowledgement.deliveryDigest
|
||||
) {
|
||||
throw new LocalCredentialDeliveryMutationConflictError();
|
||||
}
|
||||
insertLocalSecurityAudit(client, audit);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialDeliveryAcknowledgements" (
|
||||
"credential_mutation_id", "acknowledgement_mutation_id",
|
||||
"project_id", "delivery_digest", "acknowledged_by_type",
|
||||
"acknowledged_by_id", "audit_event_id",
|
||||
"acknowledged_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
acknowledgement.credentialMutationId,
|
||||
acknowledgement.acknowledgementMutationId,
|
||||
acknowledgement.projectId,
|
||||
acknowledgement.deliveryDigest,
|
||||
acknowledgement.acknowledgedBy.type,
|
||||
acknowledgement.acknowledgedBy.id,
|
||||
audit.eventId,
|
||||
acknowledgement.acknowledgedAtMs,
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
acknowledgement: Object.freeze({
|
||||
...acknowledgement,
|
||||
acknowledgedBy: Object.freeze({
|
||||
...acknowledgement.acknowledgedBy,
|
||||
}),
|
||||
}),
|
||||
audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError ||
|
||||
error instanceof LocalCredentialDeliveryMutationConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof SecurityAuditUnavailableError) throw error;
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
IdentityAdministrationMutationConflictError,
|
||||
IdentityAdministrationVersionConflictError,
|
||||
normalizeIdentityAdministrationMutationId,
|
||||
normalizeIdentityAdministrationSubject,
|
||||
normalizeIdentitySubjectRecord,
|
||||
type IdentitySubjectRecord,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
import {
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
LocalIdentityOwnerBindingConflictError,
|
||||
type AppendAuthorizedLocalIdentityCommand,
|
||||
type AppendAuthorizedLocalIdentityResult,
|
||||
type InspectAuthorizedLocalIdentityCommand,
|
||||
type InspectAuthorizedLocalIdentityResult,
|
||||
type ResolvedLocalIdentitySubjectMutation,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
import { SecurityAuditUnavailableError } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import {
|
||||
insertLocalSecurityAudit,
|
||||
localSecurityAuditFromRow,
|
||||
} from '../securityPersistence';
|
||||
|
||||
import {
|
||||
ADMIN_AUDIT_SELECT,
|
||||
ADMINISTRABLE_SUBJECT_TYPES,
|
||||
IDENTITY_MUTATION_SELECT,
|
||||
assertAllowedAudit,
|
||||
authorization,
|
||||
identityFromRow,
|
||||
identityMutationFromRow,
|
||||
identityResultFromMutationRow,
|
||||
sameIdentitySemantic,
|
||||
sameSubject,
|
||||
text,
|
||||
type Row,
|
||||
} from './codec';
|
||||
|
||||
import {
|
||||
activeOwnerBindingExists,
|
||||
assertAuthorizationInTransaction,
|
||||
} from './authorization';
|
||||
|
||||
export function resolveIdentity(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
requested: SecuritySubject,
|
||||
): Promise<Readonly<IdentitySubjectRecord> | null> {
|
||||
const subject = normalizeIdentityAdministrationSubject(requested);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const row = authority.client
|
||||
.prepare(
|
||||
`SELECT "subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId",
|
||||
"status" AS "status", "version" AS "version",
|
||||
"created_at_ms" AS "createdAtMs",
|
||||
"updated_at_ms" AS "updatedAtMs"
|
||||
FROM "QingLong3IdentitySubjects"
|
||||
WHERE "subject_type" = ? AND "subject_id" = ?`,
|
||||
)
|
||||
.get(subject.type, subject.id) as Row | undefined;
|
||||
return row ? identityFromRow(row) : null;
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveIdentityMutation(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
requestedMutationId: string,
|
||||
): Promise<Readonly<ResolvedLocalIdentitySubjectMutation> | null> {
|
||||
normalizeIdentityAdministrationMutationId(requestedMutationId);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const row = authority.client
|
||||
.prepare(
|
||||
`SELECT ${IDENTITY_MUTATION_SELECT},
|
||||
${ADMIN_AUDIT_SELECT}
|
||||
FROM "QingLong3IdentityAdministrationMutations" AS mutation
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = mutation."audit_event_id"
|
||||
WHERE mutation."mutation_id" = ?`,
|
||||
)
|
||||
.get(requestedMutationId) as Row | undefined;
|
||||
if (!row) return null;
|
||||
return Object.freeze({
|
||||
projectId: text(row, 'mutationProjectId'),
|
||||
identity: identityResultFromMutationRow(row),
|
||||
mutation: identityMutationFromRow(row),
|
||||
audit: localSecurityAuditFromRow(row),
|
||||
});
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectAuthorizedIdentity(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
beforeMutation: () => void,
|
||||
input: InspectAuthorizedLocalIdentityCommand,
|
||||
): Promise<InspectAuthorizedLocalIdentityResult> {
|
||||
const subject = normalizeIdentityAdministrationSubject(input.target);
|
||||
const auth = authorization(input.authorization);
|
||||
const audit = assertAllowedAudit(
|
||||
input.audit,
|
||||
'identity.inspect',
|
||||
input.audit.eventId,
|
||||
auth,
|
||||
);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const client = authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
assertAuthorizationInTransaction(authority, auth, beforeMutation);
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT "subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId",
|
||||
"status" AS "status", "version" AS "version",
|
||||
"created_at_ms" AS "createdAtMs",
|
||||
"updated_at_ms" AS "updatedAtMs"
|
||||
FROM "QingLong3IdentitySubjects"
|
||||
WHERE "subject_type" = ? AND "subject_id" = ?`,
|
||||
)
|
||||
.get(subject.type, subject.id) as Row | undefined;
|
||||
insertLocalSecurityAudit(client, audit);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
identity: row ? identityFromRow(row) : null,
|
||||
audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof SecurityAuditUnavailableError) throw error;
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendAuthorizedIdentity(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
beforeMutation: () => void,
|
||||
input: AppendAuthorizedLocalIdentityCommand,
|
||||
): Promise<AppendAuthorizedLocalIdentityResult> {
|
||||
const auth = authorization(input.authorization);
|
||||
const mutation = input.mutation;
|
||||
normalizeIdentityAdministrationMutationId(mutation.mutationId);
|
||||
const subject = normalizeIdentityAdministrationSubject(mutation.subject);
|
||||
if (
|
||||
!ADMINISTRABLE_SUBJECT_TYPES.has(subject.type) ||
|
||||
!Number.isSafeInteger(input.expectedCurrentVersion) ||
|
||||
input.expectedCurrentVersion < 0 ||
|
||||
mutation.subjectVersion !== input.expectedCurrentVersion + 1 ||
|
||||
mutation.expectedPreviousVersion !== input.expectedCurrentVersion ||
|
||||
!sameSubject(mutation.changedBy, auth.actor) ||
|
||||
(mutation.operation === 'register'
|
||||
? input.expectedCurrentVersion !== 0 || mutation.status !== 'active'
|
||||
: input.expectedCurrentVersion < 1 ||
|
||||
mutation.status !==
|
||||
(mutation.operation === 'disable' ? 'disabled' : 'active')) ||
|
||||
!Number.isSafeInteger(mutation.createdAtMs) ||
|
||||
mutation.createdAtMs < 0
|
||||
) {
|
||||
throw new TypeError('Local Identity administration command is invalid');
|
||||
}
|
||||
const audit = assertAllowedAudit(
|
||||
input.audit,
|
||||
`identity.${mutation.operation}`,
|
||||
mutation.mutationId,
|
||||
auth,
|
||||
);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
const client = authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
assertAuthorizationInTransaction(authority, auth, beforeMutation);
|
||||
const replayRow = client
|
||||
.prepare(
|
||||
`SELECT ${IDENTITY_MUTATION_SELECT},
|
||||
${ADMIN_AUDIT_SELECT}
|
||||
FROM "QingLong3IdentityAdministrationMutations" AS mutation
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = mutation."audit_event_id"
|
||||
WHERE mutation."mutation_id" = ?`,
|
||||
)
|
||||
.get(mutation.mutationId) as Row | undefined;
|
||||
if (replayRow) {
|
||||
const existing = Object.freeze({
|
||||
projectId: text(replayRow, 'mutationProjectId'),
|
||||
identity: identityResultFromMutationRow(replayRow),
|
||||
mutation: identityMutationFromRow(replayRow),
|
||||
audit: localSecurityAuditFromRow(replayRow),
|
||||
});
|
||||
const expectedIdentity: IdentitySubjectRecord = {
|
||||
subject,
|
||||
status: mutation.status,
|
||||
version: mutation.subjectVersion,
|
||||
createdAtMs:
|
||||
mutation.operation === 'register'
|
||||
? mutation.createdAtMs
|
||||
: existing.identity.createdAtMs,
|
||||
updatedAtMs: mutation.createdAtMs,
|
||||
};
|
||||
if (
|
||||
!sameIdentitySemantic(existing, {
|
||||
projectId: auth.projectId,
|
||||
identity: expectedIdentity,
|
||||
mutation,
|
||||
audit,
|
||||
})
|
||||
) {
|
||||
throw new IdentityAdministrationMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
identity: existing.identity,
|
||||
mutation: existing.mutation,
|
||||
audit: existing.audit,
|
||||
});
|
||||
}
|
||||
const currentRow = client
|
||||
.prepare(
|
||||
`SELECT "subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId",
|
||||
"status" AS "status", "version" AS "version",
|
||||
"created_at_ms" AS "createdAtMs",
|
||||
"updated_at_ms" AS "updatedAtMs"
|
||||
FROM "QingLong3IdentitySubjects"
|
||||
WHERE "subject_type" = ? AND "subject_id" = ?`,
|
||||
)
|
||||
.get(subject.type, subject.id) as Row | undefined;
|
||||
const current = currentRow ? identityFromRow(currentRow) : null;
|
||||
if ((current?.version ?? 0) !== input.expectedCurrentVersion) {
|
||||
throw new IdentityAdministrationVersionConflictError();
|
||||
}
|
||||
if (
|
||||
mutation.operation === 'disable' &&
|
||||
activeOwnerBindingExists(authority, subject)
|
||||
) {
|
||||
throw new LocalIdentityOwnerBindingConflictError();
|
||||
}
|
||||
const identity = normalizeIdentitySubjectRecord({
|
||||
subject,
|
||||
status: mutation.status,
|
||||
version: mutation.subjectVersion,
|
||||
createdAtMs: current?.createdAtMs ?? mutation.createdAtMs,
|
||||
updatedAtMs: mutation.createdAtMs,
|
||||
});
|
||||
if (mutation.operation === 'register') {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||
"subject_type", "subject_id", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
subject.type,
|
||||
subject.id,
|
||||
identity.status,
|
||||
identity.version,
|
||||
identity.createdAtMs,
|
||||
identity.updatedAtMs,
|
||||
);
|
||||
} else {
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3IdentitySubjects"
|
||||
SET "status" = ?, "version" = ?, "updated_at_ms" = ?
|
||||
WHERE "subject_type" = ? AND "subject_id" = ?
|
||||
AND "version" = ?`,
|
||||
)
|
||||
.run(
|
||||
identity.status,
|
||||
identity.version,
|
||||
identity.updatedAtMs,
|
||||
subject.type,
|
||||
subject.id,
|
||||
input.expectedCurrentVersion,
|
||||
);
|
||||
}
|
||||
insertLocalSecurityAudit(client, audit);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentityAdministrationMutations" (
|
||||
"mutation_id", "project_id", "operation", "subject_type",
|
||||
"subject_id", "subject_version",
|
||||
"expected_previous_version", "status", "changed_by_type",
|
||||
"changed_by_id", "audit_event_id",
|
||||
"identity_created_at_ms", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
mutation.mutationId,
|
||||
auth.projectId,
|
||||
mutation.operation,
|
||||
subject.type,
|
||||
subject.id,
|
||||
mutation.subjectVersion,
|
||||
mutation.expectedPreviousVersion,
|
||||
mutation.status,
|
||||
auth.actor.type,
|
||||
auth.actor.id,
|
||||
audit.eventId,
|
||||
identity.createdAtMs,
|
||||
mutation.createdAtMs,
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
identity,
|
||||
mutation: Object.freeze({ ...mutation, subject }),
|
||||
audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError ||
|
||||
error instanceof LocalIdentityOwnerBindingConflictError ||
|
||||
error instanceof IdentityAdministrationVersionConflictError ||
|
||||
error instanceof IdentityAdministrationMutationConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof SecurityAuditUnavailableError) throw error;
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalIdentityCredentialAdministrationUnavailableError(),
|
||||
);
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { type IdentitySubjectRecord } from '@qinglong/runtime-core/identity-administration';
|
||||
import {
|
||||
type AppendAuthorizedLocalApiCredentialCommand,
|
||||
type AppendAuthorizedLocalApiCredentialResult,
|
||||
type AppendAuthorizedLocalCredentialDeliveryAcknowledgementCommand,
|
||||
type AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult,
|
||||
type AppendAuthorizedLocalIdentityCommand,
|
||||
type AppendAuthorizedLocalIdentityResult,
|
||||
type InspectAuthorizedLocalApiCredentialCommand,
|
||||
type InspectAuthorizedLocalApiCredentialResult,
|
||||
type InspectAuthorizedLocalIdentityCommand,
|
||||
type InspectAuthorizedLocalIdentityResult,
|
||||
type LocalCredentialDeliveryAcknowledgementRecord,
|
||||
type LocalIdentityCredentialAdministrationRepository,
|
||||
type ResolvedLocalApiCredentialMutation,
|
||||
type ResolvedLocalIdentitySubjectMutation,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
import { type SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
import * as commonOperations from './commonOperations';
|
||||
|
||||
import * as identityOperations from './identityOperations';
|
||||
|
||||
import * as credentialOperations from './credentialOperations';
|
||||
|
||||
import * as deliveryOperations from './deliveryOperations';
|
||||
|
||||
export class LocalSqliteIdentityCredentialAdministrationRepository
|
||||
implements LocalIdentityCredentialAdministrationRepository
|
||||
{
|
||||
constructor(
|
||||
private readonly authority: LocalSqliteOperationAuthority,
|
||||
private readonly beforeMutation: () => void,
|
||||
) {
|
||||
if (
|
||||
!(authority instanceof LocalSqliteOperationAuthority) ||
|
||||
typeof beforeMutation !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local SQLite Identity administration dependencies are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
record(audit: SecurityAuditRecord): Promise<void> {
|
||||
return commonOperations.record(this.authority, audit);
|
||||
}
|
||||
|
||||
resolveAuthorityProjectId(): Promise<string | null> {
|
||||
return commonOperations.resolveAuthorityProjectId(this.authority);
|
||||
}
|
||||
|
||||
resolveIdentity(
|
||||
requested: SecuritySubject,
|
||||
): Promise<Readonly<IdentitySubjectRecord> | null> {
|
||||
return identityOperations.resolveIdentity(this.authority, requested);
|
||||
}
|
||||
|
||||
resolveIdentityMutation(
|
||||
requestedMutationId: string,
|
||||
): Promise<Readonly<ResolvedLocalIdentitySubjectMutation> | null> {
|
||||
return identityOperations.resolveIdentityMutation(
|
||||
this.authority,
|
||||
requestedMutationId,
|
||||
);
|
||||
}
|
||||
|
||||
inspectAuthorizedIdentity(
|
||||
input: InspectAuthorizedLocalIdentityCommand,
|
||||
): Promise<InspectAuthorizedLocalIdentityResult> {
|
||||
return identityOperations.inspectAuthorizedIdentity(
|
||||
this.authority,
|
||||
this.beforeMutation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
appendAuthorizedIdentity(
|
||||
input: AppendAuthorizedLocalIdentityCommand,
|
||||
): Promise<AppendAuthorizedLocalIdentityResult> {
|
||||
return identityOperations.appendAuthorizedIdentity(
|
||||
this.authority,
|
||||
this.beforeMutation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
resolveCredentialMutation(
|
||||
requestedMutationId: string,
|
||||
): Promise<Readonly<ResolvedLocalApiCredentialMutation> | null> {
|
||||
return credentialOperations.resolveCredentialMutation(
|
||||
this.authority,
|
||||
requestedMutationId,
|
||||
);
|
||||
}
|
||||
|
||||
inspectAuthorizedCredential(
|
||||
input: InspectAuthorizedLocalApiCredentialCommand,
|
||||
): Promise<InspectAuthorizedLocalApiCredentialResult> {
|
||||
return credentialOperations.inspectAuthorizedCredential(
|
||||
this.authority,
|
||||
this.beforeMutation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
appendAuthorizedCredential(
|
||||
input: AppendAuthorizedLocalApiCredentialCommand,
|
||||
): Promise<AppendAuthorizedLocalApiCredentialResult> {
|
||||
return credentialOperations.appendAuthorizedCredential(
|
||||
this.authority,
|
||||
this.beforeMutation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
resolveDeliveryAcknowledgement(
|
||||
credentialMutationId: string,
|
||||
): Promise<Readonly<LocalCredentialDeliveryAcknowledgementRecord> | null> {
|
||||
return deliveryOperations.resolveDeliveryAcknowledgement(
|
||||
this.authority,
|
||||
credentialMutationId,
|
||||
);
|
||||
}
|
||||
|
||||
appendAuthorizedDeliveryAcknowledgement(
|
||||
input: AppendAuthorizedLocalCredentialDeliveryAcknowledgementCommand,
|
||||
): Promise<AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult> {
|
||||
return deliveryOperations.appendAuthorizedDeliveryAcknowledgement(
|
||||
this.authority,
|
||||
this.beforeMutation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { LocalSqliteIdentityCredentialAdministrationRepository } from './identity-credential-administration/repository';
|
||||
export {
|
||||
openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
type LocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
} from './identity-credential-administration/database';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type {
|
||||
AppendProjectRoleBindingCommand,
|
||||
AppendProjectRoleBindingResult,
|
||||
ProjectPolicyRepository,
|
||||
ProjectPolicySnapshot,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import { LocalSqliteSecurityAuthorityStore } from './securityAuthorityStore';
|
||||
|
||||
export class LocalSqliteProjectPolicyRepository
|
||||
implements ProjectPolicyRepository
|
||||
{
|
||||
readonly #store: LocalSqliteSecurityAuthorityStore;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#store = new LocalSqliteSecurityAuthorityStore(
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority),
|
||||
);
|
||||
}
|
||||
|
||||
resolve(
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
): Promise<Readonly<ProjectPolicySnapshot> | null> {
|
||||
return this.#store.resolve(projectId, subject);
|
||||
}
|
||||
|
||||
append(
|
||||
command: AppendProjectRoleBindingCommand,
|
||||
): Promise<AppendProjectRoleBindingResult> {
|
||||
return this.#store.append(command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
InvalidProjectPolicyValueError,
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import { resolveLocalInstanceAuthorityProjectId } from '../authority/instanceAuthorityProject';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import type { LocalSqliteAuthenticatedUserCredentialFence } from '../administration/packageManagement';
|
||||
import {
|
||||
LOCAL_ROLE_BINDING_SELECT,
|
||||
localRoleBindingFromRow,
|
||||
} from './securityPersistence';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export interface LocalSecurityAuditInstanceAuthorization {
|
||||
readonly authorityProjectId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
}
|
||||
|
||||
function exactFence(value: SecurityPolicyFence): void {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !== 'bindingVersion,projectVersion' ||
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
!Number.isSafeInteger(value.bindingVersion) ||
|
||||
(value.bindingVersion as number) < 1
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError('authorization fence is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function integer(row: Row | undefined, key: string): number {
|
||||
const value = row?.[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'authority Project version is invalid',
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeLocalSecurityAuditInstanceAuthorization(
|
||||
value: LocalSecurityAuditInstanceAuthorization,
|
||||
): Readonly<LocalSecurityAuditInstanceAuthorization> {
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.authorityProjectId);
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
exactFence(value.fence);
|
||||
return Object.freeze({
|
||||
authorityProjectId: value.authorityProjectId,
|
||||
actor,
|
||||
fence: Object.freeze({ ...value.fence }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProjectPolicyValueError) throw error;
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'Local security audit authorization is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecurityAuditInstanceOwnerInTransaction(
|
||||
authority: LocalSqliteOperationAuthority,
|
||||
input: Readonly<LocalSecurityAuditInstanceAuthorization>,
|
||||
beforeOperation: () => void,
|
||||
conflict: () => Error,
|
||||
): void {
|
||||
try {
|
||||
beforeOperation();
|
||||
} catch {
|
||||
throw conflict();
|
||||
}
|
||||
const client = authority.client;
|
||||
if (
|
||||
resolveLocalInstanceAuthorityProjectId(client) !== input.authorityProjectId
|
||||
) {
|
||||
throw conflict();
|
||||
}
|
||||
const project = client
|
||||
.prepare(
|
||||
`SELECT "status" AS "status", "version" AS "version"
|
||||
FROM "QingLong3Projects"
|
||||
WHERE "id" = ?`,
|
||||
)
|
||||
.get(input.authorityProjectId) as Row | undefined;
|
||||
const actorRow = client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_ROLE_BINDING_SELECT}
|
||||
FROM "QingLong3ProjectRoleBindings"
|
||||
WHERE "project_id" = ? AND "subject_type" = ?
|
||||
AND "subject_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(input.authorityProjectId, input.actor.type, input.actor.id) as
|
||||
| Row
|
||||
| undefined;
|
||||
if (
|
||||
!project ||
|
||||
project.status !== 'active' ||
|
||||
integer(project, 'version') !== input.fence.projectVersion ||
|
||||
!actorRow
|
||||
) {
|
||||
throw conflict();
|
||||
}
|
||||
const binding = localRoleBindingFromRow(actorRow);
|
||||
if (
|
||||
binding.version !== input.fence.bindingVersion ||
|
||||
binding.state !== 'active' ||
|
||||
binding.role !== 'owner'
|
||||
) {
|
||||
throw conflict();
|
||||
}
|
||||
}
|
||||
|
||||
export function sameLocalSqliteAuthenticatedUserCredentialFence(
|
||||
left: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
right: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): boolean {
|
||||
return (
|
||||
left.credentialId === right.credentialId &&
|
||||
left.credentialVersion === right.credentialVersion &&
|
||||
left.pepperKeyId === right.pepperKeyId &&
|
||||
left.materialDigest === right.materialDigest &&
|
||||
left.subjectType === right.subjectType &&
|
||||
left.subjectId === right.subjectId &&
|
||||
left.secretDigest === right.secretDigest &&
|
||||
left.notBeforeAtMs === right.notBeforeAtMs &&
|
||||
left.expiresAtMs === right.expiresAtMs
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { ApiCredentialRepository } from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
LocalSecurityAuditQueryUnavailableError,
|
||||
MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE,
|
||||
type ListAuthorizedLocalSecurityAuditCommand,
|
||||
type ListAuthorizedLocalSecurityAuditResult,
|
||||
type LocalSecurityAuditQueryAuthorization,
|
||||
type LocalSecurityAuditQueryRepository,
|
||||
} from '@qinglong/runtime-core/local-security-audit-query';
|
||||
import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import {
|
||||
MAX_EDGE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE,
|
||||
MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE,
|
||||
type LocalSecurityAuditRetentionRepository,
|
||||
} from '@qinglong/runtime-core/local-security-audit-retention';
|
||||
import {
|
||||
InvalidProjectPolicyValueError,
|
||||
assertProjectPolicyProjectId,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
SecurityAuditUnavailableError,
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
InvalidSecurityAuditQueryError,
|
||||
normalizeSecurityAuditQuery,
|
||||
} from '@qinglong/runtime-core/security-audit-query';
|
||||
|
||||
import { LocalSqliteApiCredentialRepository } from './apiCredentialRepository';
|
||||
import {
|
||||
assertLocalSqliteOptions,
|
||||
assertLocalSqlitePathBoundary,
|
||||
openLocalSqliteClient,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteProfile,
|
||||
} from '../storage/config';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
|
||||
import {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '../administration/packageManagement';
|
||||
import {
|
||||
auditLocalSqliteReadiness,
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '../readiness/readiness';
|
||||
import {
|
||||
assertLocalSecurityAuditInstanceOwnerInTransaction,
|
||||
normalizeLocalSecurityAuditInstanceAuthorization,
|
||||
sameLocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from './securityAuditAuthority';
|
||||
import { LocalSqliteSecurityAuditRetentionRepository } from './securityAuditRetention';
|
||||
import {
|
||||
insertLocalSecurityAudit,
|
||||
LOCAL_SECURITY_AUDIT_SELECT,
|
||||
localSecurityAuditFromRow,
|
||||
} from './securityPersistence';
|
||||
import { LocalSqliteSecurityAuthorityStore } from './securityAuthorityStore';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function allowedAudit(
|
||||
value: SecurityAuditRecord,
|
||||
authority: Readonly<LocalSecurityAuditQueryAuthorization>,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
const audit = normalizeSecurityAuditRecord(value);
|
||||
if (
|
||||
audit.operationId !== 'security.audit.list' ||
|
||||
audit.projectId !== authority.authorityProjectId ||
|
||||
audit.subject?.type !== authority.actor.type ||
|
||||
audit.subject.id !== authority.actor.id ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.fence?.projectVersion !== authority.fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== authority.fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'Local security audit query audit is invalid',
|
||||
);
|
||||
}
|
||||
return audit;
|
||||
}
|
||||
|
||||
export class LocalSqliteSecurityAuditQueryRepository
|
||||
implements LocalSecurityAuditQueryRepository
|
||||
{
|
||||
constructor(
|
||||
private readonly authority: LocalSqliteOperationAuthority,
|
||||
private readonly beforeQuery: () => void,
|
||||
) {
|
||||
if (
|
||||
!(authority instanceof LocalSqliteOperationAuthority) ||
|
||||
typeof beforeQuery !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local SQLite security audit query dependencies are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
record(value: SecurityAuditRecord): Promise<void> {
|
||||
return new LocalSqliteSecurityAuthorityStore(this.authority).record(value);
|
||||
}
|
||||
|
||||
listAuthorized(
|
||||
input: ListAuthorizedLocalSecurityAuditCommand,
|
||||
): Promise<ListAuthorizedLocalSecurityAuditResult> {
|
||||
const query = normalizeSecurityAuditQuery(input.query);
|
||||
if (query.limit > MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE) {
|
||||
throw new InvalidSecurityAuditQueryError('local query limit exceeds 64');
|
||||
}
|
||||
const authorityInput = normalizeLocalSecurityAuditInstanceAuthorization(
|
||||
input.authorization,
|
||||
);
|
||||
const audit = allowedAudit(input.audit, authorityInput);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
assertLocalSecurityAuditInstanceOwnerInTransaction(
|
||||
this.authority,
|
||||
authorityInput,
|
||||
this.beforeQuery,
|
||||
() => new LocalSecurityAuditQueryAuthorizationFenceConflictError(),
|
||||
);
|
||||
const subject = query.filter.subject;
|
||||
const before = query.before;
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE (? IS NULL OR "project_id" = ?)
|
||||
AND (? IS NULL OR "subject_type" = ?)
|
||||
AND (? IS NULL OR "subject_id" = ?)
|
||||
AND (? IS NULL OR "outcome" = ?)
|
||||
AND (
|
||||
? IS NULL OR "occurred_at_ms" < ?
|
||||
OR ("occurred_at_ms" = ? AND "event_id" < ?)
|
||||
)
|
||||
ORDER BY "occurred_at_ms" DESC, "event_id" DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
query.filter.projectId ?? null,
|
||||
query.filter.projectId ?? null,
|
||||
subject?.type ?? null,
|
||||
subject?.type ?? null,
|
||||
subject?.id ?? null,
|
||||
subject?.id ?? null,
|
||||
query.filter.outcome ?? null,
|
||||
query.filter.outcome ?? null,
|
||||
before?.occurredAtMs ?? null,
|
||||
before?.occurredAtMs ?? null,
|
||||
before?.occurredAtMs ?? null,
|
||||
before?.eventId ?? null,
|
||||
query.limit + 1,
|
||||
) as Row[];
|
||||
const hasMore = rows.length > query.limit;
|
||||
const records = Object.freeze(
|
||||
rows
|
||||
.slice(0, query.limit)
|
||||
.map((row) => localSecurityAuditFromRow(row)),
|
||||
);
|
||||
const last = records.at(-1);
|
||||
insertLocalSecurityAudit(client, audit);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
records,
|
||||
nextCursor:
|
||||
hasMore && last
|
||||
? Object.freeze({
|
||||
occurredAtMs: last.occurredAtMs,
|
||||
eventId: last.eventId,
|
||||
})
|
||||
: null,
|
||||
audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError ||
|
||||
error instanceof InvalidSecurityAuditQueryError ||
|
||||
error instanceof InvalidProjectPolicyValueError ||
|
||||
error instanceof SecurityAuditUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecurityAuditQueryUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalSecurityAuditQueryUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalSqliteSecurityAuditQueryDatabase {
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
readonly ownerPepper: Pick<LocalOwnerPepperRepository, 'resolveKey'>;
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly securityAuditQuery: LocalSecurityAuditQueryRepository;
|
||||
readonly securityAuditRetention: LocalSecurityAuditRetentionRepository;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function openLocalSqliteSecurityAuditQueryDatabase(
|
||||
options: LocalSqliteDatabaseOptions,
|
||||
): Promise<LocalSqliteSecurityAuditQueryDatabase> {
|
||||
assertLocalSqliteOptions(options);
|
||||
assertLocalSqlitePathBoundary(options.databasePath, false);
|
||||
const client = openLocalSqliteClient(options, false);
|
||||
try {
|
||||
const readiness = await auditLocalSqliteReadiness(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
||||
let activeFence:
|
||||
| Readonly<LocalSqliteAuthenticatedUserCredentialFence>
|
||||
| undefined;
|
||||
const projectPolicy: ProjectPolicyRepository = Object.freeze({
|
||||
resolve: (
|
||||
...[projectId, subject]: Parameters<ProjectPolicyRepository['resolve']>
|
||||
) => securityAuthority.resolve(projectId, subject),
|
||||
append: (...[command]: Parameters<ProjectPolicyRepository['append']>) =>
|
||||
securityAuthority.append(command),
|
||||
});
|
||||
const confirmFence = () => {
|
||||
if (!activeFence) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
authority,
|
||||
activeFence,
|
||||
);
|
||||
};
|
||||
const securityAuditQuery = new LocalSqliteSecurityAuditQueryRepository(
|
||||
authority,
|
||||
confirmFence,
|
||||
);
|
||||
const securityAuditRetention =
|
||||
new LocalSqliteSecurityAuditRetentionRepository(
|
||||
authority,
|
||||
confirmFence,
|
||||
options.profile === 'edge'
|
||||
? MAX_EDGE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE
|
||||
: MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE,
|
||||
);
|
||||
let closePromise: Promise<void> | undefined;
|
||||
return Object.freeze({
|
||||
profile: options.profile,
|
||||
readiness,
|
||||
apiCredentials: new LocalSqliteApiCredentialRepository(authority),
|
||||
ownerPepper: new LocalSqliteOwnerPepperRepository(authority),
|
||||
projectPolicy,
|
||||
securityAuditQuery,
|
||||
securityAuditRetention,
|
||||
securityAudit: securityAuthority,
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
) {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
|
||||
if (
|
||||
activeFence &&
|
||||
!sameLocalSqliteAuthenticatedUserCredentialFence(activeFence, fence)
|
||||
) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
activeFence = Object.freeze({ ...fence });
|
||||
},
|
||||
close() {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = authority.close();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isOpen) client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
InvalidLocalSecurityAuditRetentionValueError,
|
||||
LocalSecurityAuditCompactionMutationConflictError,
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
LocalSecurityAuditRetentionUnavailableError,
|
||||
MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
localSecurityAuditCompactionPayload,
|
||||
type CompactAuthorizedLocalSecurityAuditCommand,
|
||||
type CompactAuthorizedLocalSecurityAuditResult,
|
||||
type LocalSecurityAuditCompactionRecord,
|
||||
type LocalSecurityAuditRetentionRepository,
|
||||
} from '@qinglong/runtime-core/local-security-audit-retention';
|
||||
import { InvalidProjectPolicyValueError } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
SecurityAuditUnavailableError,
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import {
|
||||
assertLocalSecurityAuditInstanceOwnerInTransaction,
|
||||
normalizeLocalSecurityAuditInstanceAuthorization,
|
||||
} from './securityAuditAuthority';
|
||||
import {
|
||||
insertLocalSecurityAudit,
|
||||
LOCAL_SECURITY_AUDIT_JOIN_SELECT,
|
||||
LOCAL_SECURITY_AUDIT_SELECT,
|
||||
localSecurityAuditFromRow,
|
||||
sameSecurityAuditSemantic,
|
||||
} from './securityPersistence';
|
||||
import { LocalSqliteSecurityAuthorityStore } from './securityAuthorityStore';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
const COMPACTION_SELECT = `
|
||||
compaction."mutation_id" AS "compactionMutationId",
|
||||
compaction."request_id" AS "compactionRequestId",
|
||||
compaction."authority_project_id" AS "compactionAuthorityProjectId",
|
||||
compaction."retention_ms" AS "compactionRetentionMs",
|
||||
compaction."eligible_before_ms" AS "compactionEligibleBeforeMs",
|
||||
compaction."batch_limit" AS "compactionBatchLimit",
|
||||
compaction."deleted_count" AS "compactionDeletedCount",
|
||||
compaction."deleted_payload_bytes" AS "compactionDeletedPayloadBytes",
|
||||
compaction."first_occurred_at_ms" AS "compactionFirstOccurredAtMs",
|
||||
compaction."first_event_id" AS "compactionFirstEventId",
|
||||
compaction."last_occurred_at_ms" AS "compactionLastOccurredAtMs",
|
||||
compaction."last_event_id" AS "compactionLastEventId",
|
||||
compaction."records_digest" AS "compactionRecordsDigest",
|
||||
compaction."created_at_ms" AS "compactionCreatedAtMs"
|
||||
`;
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | null {
|
||||
const value = row[key];
|
||||
if (value === null) return null;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value === null) return null;
|
||||
return text(row, key);
|
||||
}
|
||||
|
||||
function compactionFromRow(
|
||||
row: Row,
|
||||
): Readonly<LocalSecurityAuditCompactionRecord> {
|
||||
const deletedCount = integer(row, 'compactionDeletedCount');
|
||||
const firstOccurredAtMs = optionalInteger(row, 'compactionFirstOccurredAtMs');
|
||||
const firstEventId = optionalText(row, 'compactionFirstEventId');
|
||||
const lastOccurredAtMs = optionalInteger(row, 'compactionLastOccurredAtMs');
|
||||
const lastEventId = optionalText(row, 'compactionLastEventId');
|
||||
if (
|
||||
(deletedCount === 0 &&
|
||||
(firstOccurredAtMs !== null ||
|
||||
firstEventId !== null ||
|
||||
lastOccurredAtMs !== null ||
|
||||
lastEventId !== null)) ||
|
||||
(deletedCount > 0 &&
|
||||
(firstOccurredAtMs === null ||
|
||||
firstEventId === null ||
|
||||
lastOccurredAtMs === null ||
|
||||
lastEventId === null))
|
||||
) {
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
mutationId: text(row, 'compactionMutationId'),
|
||||
requestId: text(row, 'compactionRequestId'),
|
||||
authorityProjectId: text(row, 'compactionAuthorityProjectId'),
|
||||
retentionMs: integer(row, 'compactionRetentionMs'),
|
||||
eligibleBeforeMs: integer(row, 'compactionEligibleBeforeMs'),
|
||||
batchLimit: integer(row, 'compactionBatchLimit'),
|
||||
deletedCount,
|
||||
deletedPayloadBytes: integer(row, 'compactionDeletedPayloadBytes'),
|
||||
first:
|
||||
firstOccurredAtMs === null
|
||||
? null
|
||||
: Object.freeze({
|
||||
occurredAtMs: firstOccurredAtMs,
|
||||
eventId: firstEventId!,
|
||||
}),
|
||||
last:
|
||||
lastOccurredAtMs === null
|
||||
? null
|
||||
: Object.freeze({
|
||||
occurredAtMs: lastOccurredAtMs,
|
||||
eventId: lastEventId!,
|
||||
}),
|
||||
recordsDigest: text(row, 'compactionRecordsDigest'),
|
||||
createdAtMs: integer(row, 'compactionCreatedAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
function command(
|
||||
input: CompactAuthorizedLocalSecurityAuditCommand,
|
||||
maxBatchSize: number,
|
||||
): Readonly<CompactAuthorizedLocalSecurityAuditCommand> {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
Object.keys(input).sort().join(',') !==
|
||||
[
|
||||
'audit',
|
||||
'authorization',
|
||||
'eligibleBeforeMs',
|
||||
'limit',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'retentionMs',
|
||||
]
|
||||
.sort()
|
||||
.join(',') ||
|
||||
!UUID_V4_PATTERN.test(input.mutationId) ||
|
||||
!REQUEST_ID_PATTERN.test(input.requestId) ||
|
||||
!Number.isSafeInteger(input.retentionMs) ||
|
||||
input.retentionMs < MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
|
||||
input.retentionMs > MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
|
||||
!Number.isSafeInteger(input.eligibleBeforeMs) ||
|
||||
input.eligibleBeforeMs < 0 ||
|
||||
!Number.isSafeInteger(input.limit) ||
|
||||
input.limit < 1 ||
|
||||
input.limit > maxBatchSize
|
||||
) {
|
||||
throw new InvalidLocalSecurityAuditRetentionValueError(
|
||||
'compaction command shape is invalid',
|
||||
);
|
||||
}
|
||||
const authorization = normalizeLocalSecurityAuditInstanceAuthorization(
|
||||
input.authorization,
|
||||
);
|
||||
const audit = normalizeSecurityAuditRecord(input.audit);
|
||||
if (
|
||||
audit.eventId !== input.mutationId ||
|
||||
audit.requestId !== input.requestId ||
|
||||
audit.operationId !== 'security.audit.compact' ||
|
||||
audit.projectId !== authorization.authorityProjectId ||
|
||||
audit.subject?.type !== authorization.actor.type ||
|
||||
audit.subject.id !== authorization.actor.id ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.reasons.length !== 1 ||
|
||||
audit.reasons[0] !== 'instance_authority_security_audit_compaction' ||
|
||||
audit.fence?.projectVersion !== authorization.fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== authorization.fence.bindingVersion ||
|
||||
input.eligibleBeforeMs + input.retentionMs > audit.occurredAtMs
|
||||
) {
|
||||
throw new InvalidLocalSecurityAuditRetentionValueError(
|
||||
'compaction audit or retention fence is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...input,
|
||||
authorization,
|
||||
audit,
|
||||
});
|
||||
}
|
||||
|
||||
function sameCommand(
|
||||
record: Readonly<LocalSecurityAuditCompactionRecord>,
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
input: Readonly<CompactAuthorizedLocalSecurityAuditCommand>,
|
||||
): boolean {
|
||||
return (
|
||||
record.mutationId === input.mutationId &&
|
||||
record.requestId === input.requestId &&
|
||||
record.authorityProjectId === input.authorization.authorityProjectId &&
|
||||
record.retentionMs === input.retentionMs &&
|
||||
record.eligibleBeforeMs === input.eligibleBeforeMs &&
|
||||
record.batchLimit === input.limit &&
|
||||
sameSecurityAuditSemantic(audit, input.audit)
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalSqliteSecurityAuditRetentionRepository
|
||||
implements LocalSecurityAuditRetentionRepository
|
||||
{
|
||||
constructor(
|
||||
private readonly authority: LocalSqliteOperationAuthority,
|
||||
private readonly beforeCompaction: () => void,
|
||||
private readonly maxBatchSize: number,
|
||||
) {
|
||||
if (
|
||||
!(authority instanceof LocalSqliteOperationAuthority) ||
|
||||
typeof beforeCompaction !== 'function' ||
|
||||
!Number.isSafeInteger(maxBatchSize) ||
|
||||
maxBatchSize < 1 ||
|
||||
maxBatchSize > 512
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local SQLite security audit retention dependencies are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
record(value: SecurityAuditRecord): Promise<void> {
|
||||
return new LocalSqliteSecurityAuthorityStore(this.authority).record(value);
|
||||
}
|
||||
|
||||
resolveCompaction(
|
||||
mutationId: string,
|
||||
): Promise<Readonly<LocalSecurityAuditCompactionRecord> | null> {
|
||||
if (!UUID_V4_PATTERN.test(mutationId)) {
|
||||
throw new InvalidLocalSecurityAuditRetentionValueError(
|
||||
'mutation identity is invalid',
|
||||
);
|
||||
}
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`SELECT ${COMPACTION_SELECT}
|
||||
FROM "QingLong3SecurityAuditCompactions" AS compaction
|
||||
WHERE compaction."mutation_id" = ?`,
|
||||
)
|
||||
.get(mutationId) as Row | undefined;
|
||||
return row ? compactionFromRow(row) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLocalSecurityAuditRetentionValueError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalSecurityAuditRetentionUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
compactAuthorized(
|
||||
input: CompactAuthorizedLocalSecurityAuditCommand,
|
||||
): Promise<CompactAuthorizedLocalSecurityAuditResult> {
|
||||
const value = command(input, this.maxBatchSize);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
assertLocalSecurityAuditInstanceOwnerInTransaction(
|
||||
this.authority,
|
||||
value.authorization,
|
||||
this.beforeCompaction,
|
||||
() =>
|
||||
new LocalSecurityAuditRetentionAuthorizationFenceConflictError(),
|
||||
);
|
||||
const replay = client
|
||||
.prepare(
|
||||
`SELECT ${COMPACTION_SELECT},
|
||||
${LOCAL_SECURITY_AUDIT_JOIN_SELECT}
|
||||
FROM "QingLong3SecurityAuditCompactions" AS compaction
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = compaction."audit_event_id"
|
||||
WHERE compaction."mutation_id" = ?`,
|
||||
)
|
||||
.get(value.mutationId) as Row | undefined;
|
||||
if (replay) {
|
||||
const record = compactionFromRow(replay);
|
||||
const audit = localSecurityAuditFromRow(replay);
|
||||
if (!sameCommand(record, audit, value)) {
|
||||
throw new LocalSecurityAuditCompactionMutationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
record,
|
||||
audit,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents" AS candidate
|
||||
WHERE candidate."occurred_at_ms" < ?
|
||||
AND (
|
||||
candidate."outcome" <> 'allowed'
|
||||
OR candidate."operation_id" IN (
|
||||
'identity.inspect',
|
||||
'credential.inspect',
|
||||
'policy.project.inspect',
|
||||
'policy.project.list',
|
||||
'policy.role_binding.inspect',
|
||||
'policy.role_binding.list',
|
||||
'security.audit.list'
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3ApiCredentialAdministrationMutations" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3ApiCredentialDeliveryAcknowledgements" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3IdentityAdministrationMutations" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "QingLong3LegacyAdoptions" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "QingLong3LocalIdentityProvisionings" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3LocalOwnerBootstrapChallenges" AS ref
|
||||
WHERE ref."issue_audit_event_id" = candidate."event_id"
|
||||
OR ref."claim_audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3LocalOwnerCredentialRecoveries" AS ref
|
||||
WHERE ref."issue_audit_event_id" = candidate."event_id"
|
||||
OR ref."complete_audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3LocalOwnerDeliveryAcknowledgementGc" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3LocalOwnerPepperMaterialGc" AS ref
|
||||
WHERE ref."prepare_audit_event_id" = candidate."event_id"
|
||||
OR ref."complete_audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageAdmissionReceipts" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3ProjectAdministrationMutations" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ToolExecutionAuditReceipts" AS ref
|
||||
WHERE ref."event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ToolExecutionStartBarriers" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3SecurityAuditCompactions" AS ref
|
||||
WHERE ref."audit_event_id" = candidate."event_id"
|
||||
)
|
||||
ORDER BY candidate."occurred_at_ms" ASC,
|
||||
candidate."event_id" ASC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(value.eligibleBeforeMs, value.limit) as Row[];
|
||||
const records = Object.freeze(
|
||||
rows.map((row) => localSecurityAuditFromRow(row)),
|
||||
);
|
||||
const payload = localSecurityAuditCompactionPayload(records);
|
||||
const firstRecord = records.at(0);
|
||||
const lastRecord = records.at(-1);
|
||||
const record: Readonly<LocalSecurityAuditCompactionRecord> =
|
||||
Object.freeze({
|
||||
mutationId: value.mutationId,
|
||||
requestId: value.requestId,
|
||||
authorityProjectId: value.authorization.authorityProjectId,
|
||||
retentionMs: value.retentionMs,
|
||||
eligibleBeforeMs: value.eligibleBeforeMs,
|
||||
batchLimit: value.limit,
|
||||
deletedCount: records.length,
|
||||
deletedPayloadBytes: payload.payloadBytes,
|
||||
first: firstRecord
|
||||
? Object.freeze({
|
||||
occurredAtMs: firstRecord.occurredAtMs,
|
||||
eventId: firstRecord.eventId,
|
||||
})
|
||||
: null,
|
||||
last: lastRecord
|
||||
? Object.freeze({
|
||||
occurredAtMs: lastRecord.occurredAtMs,
|
||||
eventId: lastRecord.eventId,
|
||||
})
|
||||
: null,
|
||||
recordsDigest: payload.recordsDigest,
|
||||
createdAtMs: value.audit.occurredAtMs,
|
||||
});
|
||||
insertLocalSecurityAudit(client, value.audit);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditCompactions" (
|
||||
"mutation_id", "request_id", "authority_project_id",
|
||||
"retention_ms", "eligible_before_ms", "batch_limit",
|
||||
"deleted_count", "deleted_payload_bytes",
|
||||
"first_occurred_at_ms", "first_event_id",
|
||||
"last_occurred_at_ms", "last_event_id",
|
||||
"records_digest", "audit_event_id", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
record.mutationId,
|
||||
record.requestId,
|
||||
record.authorityProjectId,
|
||||
record.retentionMs,
|
||||
record.eligibleBeforeMs,
|
||||
record.batchLimit,
|
||||
record.deletedCount,
|
||||
record.deletedPayloadBytes,
|
||||
record.first?.occurredAtMs ?? null,
|
||||
record.first?.eventId ?? null,
|
||||
record.last?.occurredAtMs ?? null,
|
||||
record.last?.eventId ?? null,
|
||||
record.recordsDigest,
|
||||
value.audit.eventId,
|
||||
record.createdAtMs,
|
||||
);
|
||||
if (records.length > 0) {
|
||||
const placeholders = records.map(() => '?').join(',');
|
||||
const deleted = client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" IN (${placeholders})`,
|
||||
)
|
||||
.run(...records.map((candidate) => candidate.eventId));
|
||||
if (deleted.changes !== records.length) {
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
record,
|
||||
audit: value.audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof InvalidLocalSecurityAuditRetentionValueError ||
|
||||
error instanceof
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
LocalSecurityAuditCompactionMutationConflictError ||
|
||||
error instanceof SecurityAuditUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalSecurityAuditRetentionUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretVersionConflictError,
|
||||
MAX_LOCAL_SECRET_BATCH_SIZE,
|
||||
assertLocalSecretExpectedVersion,
|
||||
assertLocalSecretMutationId,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretProjectId,
|
||||
createLocalSecretRef,
|
||||
normalizeLocalSecretEnvelope,
|
||||
type AppendLocalSecretEnvelopeCommand,
|
||||
type AppendLocalSecretEnvelopeResult,
|
||||
type LocalSecretEnvelope,
|
||||
type LocalSecretEnvelopeRepository,
|
||||
type LocalSecretReference,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
type AppendAuthorizedLocalSecretEnvelopeCommand,
|
||||
type AppendAuthorizedLocalSecretEnvelopeResult,
|
||||
type LocalSecretAdministrationMutation,
|
||||
type LocalSecretAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/local-secret-administration';
|
||||
import {
|
||||
ProjectPolicyUnavailableError,
|
||||
ProjectRoleBindingMutationConflictError,
|
||||
ProjectRoleBindingVersionConflictError,
|
||||
assertExpectedProjectRoleBindingVersion,
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySnapshot,
|
||||
normalizeProjectPolicySubject,
|
||||
normalizeProjectRoleBinding,
|
||||
type AppendProjectRoleBindingCommand,
|
||||
type AppendProjectRoleBindingResult,
|
||||
type ProjectPolicyRepository,
|
||||
type ProjectPolicySnapshot,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
SecurityAuditUnavailableError,
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
RunRepositoryBusyError,
|
||||
RunRepositoryConstraintError,
|
||||
RunRepositoryError,
|
||||
RunRepositoryOperationError,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import {
|
||||
insertLocalSecurityAudit,
|
||||
LOCAL_PROJECT_SELECT,
|
||||
LOCAL_ROLE_BINDING_SELECT,
|
||||
LOCAL_SECRET_JOIN_SELECT,
|
||||
LOCAL_SECRET_SELECT,
|
||||
LOCAL_SECURITY_AUDIT_JOIN_SELECT,
|
||||
LOCAL_SECURITY_AUDIT_SELECT,
|
||||
localProjectFromRow,
|
||||
localRoleBindingFromRow,
|
||||
localSecretEnvelopeFromRow,
|
||||
localSecurityAuditFromRow,
|
||||
mapSqliteError,
|
||||
queryRows,
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
sameSecurityAuditSemantic,
|
||||
singleRow,
|
||||
type QueryRow,
|
||||
} from './securityPersistence';
|
||||
|
||||
export interface LocalSqliteSecurityAuthorityStoreOptions {
|
||||
readonly beforeAuthorizedLocalSecretMutation?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-private owner for Project Policy, Security Audit and Local Secret
|
||||
* persistence. All operations reuse one LocalSqliteOperationAuthority; this
|
||||
* class neither creates nor closes the underlying connection.
|
||||
*/
|
||||
export class LocalSqliteSecurityAuthorityStore
|
||||
implements
|
||||
ProjectPolicyRepository,
|
||||
SecurityAuditSink,
|
||||
LocalSecretEnvelopeRepository,
|
||||
LocalSecretAdministrationRepository
|
||||
{
|
||||
private readonly client: DatabaseSync;
|
||||
private readonly beforeAuthorizedLocalSecretMutation:
|
||||
| (() => void)
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
private readonly authority: LocalSqliteOperationAuthority,
|
||||
options: LocalSqliteSecurityAuthorityStoreOptions = {},
|
||||
) {
|
||||
if (
|
||||
!(authority instanceof LocalSqliteOperationAuthority) ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => key !== 'beforeAuthorizedLocalSecretMutation',
|
||||
) ||
|
||||
(options.beforeAuthorizedLocalSecretMutation !== undefined &&
|
||||
typeof options.beforeAuthorizedLocalSecretMutation !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local SQLite Security authority dependencies are invalid',
|
||||
);
|
||||
}
|
||||
this.client = authority.client;
|
||||
this.beforeAuthorizedLocalSecretMutation =
|
||||
options.beforeAuthorizedLocalSecretMutation;
|
||||
}
|
||||
|
||||
private enqueue<T>(work: () => Promise<T>): Promise<T> {
|
||||
return this.authority.enqueue(work, (reason) =>
|
||||
reason === 'busy'
|
||||
? new RunRepositoryBusyError()
|
||||
: new RunRepositoryOperationError(
|
||||
new Error('Local SQLite Run repository is closed'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve(
|
||||
projectId: string,
|
||||
subjectValue: Parameters<ProjectPolicyRepository['resolve']>[1],
|
||||
): Promise<Readonly<ProjectPolicySnapshot> | null> {
|
||||
assertProjectPolicyProjectId(projectId);
|
||||
const subject = normalizeProjectPolicySubject(subjectValue);
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
const projectRow = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_PROJECT_SELECT}
|
||||
FROM "QingLong3Projects" WHERE "id" = ? LIMIT 2`,
|
||||
[projectId],
|
||||
),
|
||||
);
|
||||
if (!projectRow) return null;
|
||||
const bindingRow = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_ROLE_BINDING_SELECT}
|
||||
FROM "QingLong3ProjectRoleBindings"
|
||||
WHERE "project_id" = ? AND "subject_type" = ?
|
||||
AND "subject_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
[projectId, subject.type, subject.id],
|
||||
),
|
||||
);
|
||||
return normalizeProjectPolicySnapshot({
|
||||
project: localProjectFromRow(projectRow),
|
||||
...(bindingRow
|
||||
? { binding: localRoleBindingFromRow(bindingRow) }
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
append(
|
||||
command: AppendProjectRoleBindingCommand,
|
||||
): Promise<AppendProjectRoleBindingResult> {
|
||||
assertExpectedProjectRoleBindingVersion(command.expectedCurrentVersion);
|
||||
const binding = normalizeProjectRoleBinding(command.binding);
|
||||
if (binding.version !== command.expectedCurrentVersion + 1) {
|
||||
return Promise.reject(new ProjectRoleBindingVersionConflictError());
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
let began = false;
|
||||
try {
|
||||
this.client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const replayRow = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_ROLE_BINDING_SELECT}
|
||||
FROM "QingLong3ProjectRoleBindings"
|
||||
WHERE "project_id" = ? AND "subject_type" = ?
|
||||
AND "subject_id" = ? AND "mutation_id" = ?
|
||||
LIMIT 2`,
|
||||
[
|
||||
binding.projectId,
|
||||
binding.subject.type,
|
||||
binding.subject.id,
|
||||
binding.mutationId,
|
||||
],
|
||||
),
|
||||
);
|
||||
if (replayRow) {
|
||||
const existing = localRoleBindingFromRow(replayRow);
|
||||
if (JSON.stringify(existing) !== JSON.stringify(binding)) {
|
||||
throw new ProjectRoleBindingMutationConflictError();
|
||||
}
|
||||
this.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
binding: existing,
|
||||
});
|
||||
}
|
||||
const current = this.client
|
||||
.prepare(
|
||||
`SELECT MAX("version") AS "version"
|
||||
FROM "QingLong3ProjectRoleBindings"
|
||||
WHERE "project_id" = ? AND "subject_type" = ?
|
||||
AND "subject_id" = ?`,
|
||||
)
|
||||
.get(binding.projectId, binding.subject.type, binding.subject.id) as
|
||||
| QueryRow
|
||||
| undefined;
|
||||
const currentVersion =
|
||||
current?.version === null || current?.version === undefined
|
||||
? 0
|
||||
: requiredInteger(current, 'version');
|
||||
if (currentVersion !== command.expectedCurrentVersion) {
|
||||
throw new ProjectRoleBindingVersionConflictError();
|
||||
}
|
||||
const project = this.client
|
||||
.prepare(`SELECT "id" FROM "QingLong3Projects" WHERE "id" = ?`)
|
||||
.get(binding.projectId);
|
||||
if (!project) throw new ProjectPolicyUnavailableError();
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version",
|
||||
"state", "role", "mutation_id", "changed_by_type",
|
||||
"changed_by_id", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
binding.projectId,
|
||||
binding.subject.type,
|
||||
binding.subject.id,
|
||||
binding.version,
|
||||
binding.state,
|
||||
binding.role ?? null,
|
||||
binding.mutationId,
|
||||
binding.changedBy.type,
|
||||
binding.changedBy.id,
|
||||
binding.createdAtMs,
|
||||
);
|
||||
this.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
binding,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && this.client.isTransaction) {
|
||||
try {
|
||||
this.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original failure.
|
||||
}
|
||||
}
|
||||
if (
|
||||
error instanceof ProjectRoleBindingVersionConflictError ||
|
||||
error instanceof ProjectRoleBindingMutationConflictError ||
|
||||
error instanceof ProjectPolicyUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
record(value: SecurityAuditRecord): Promise<void> {
|
||||
const audit = normalizeSecurityAuditRecord(value);
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
insertLocalSecurityAudit(this.client, audit);
|
||||
} catch {
|
||||
try {
|
||||
const row = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ? LIMIT 2`,
|
||||
[audit.eventId],
|
||||
),
|
||||
);
|
||||
if (
|
||||
row &&
|
||||
sameSecurityAuditSemantic(localSecurityAuditFromRow(row), audit)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Collapse storage and corruption failures to one low-sensitive error.
|
||||
}
|
||||
throw new SecurityAuditUnavailableError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveLocalSecretAdministrationMutation(
|
||||
projectId: string,
|
||||
name: string,
|
||||
mutationId: string,
|
||||
): Promise<Readonly<LocalSecretAdministrationMutation> | null> {
|
||||
assertLocalSecretProjectId(projectId);
|
||||
assertLocalSecretName(name);
|
||||
assertLocalSecretMutationId(mutationId);
|
||||
return this.enqueue(async () => {
|
||||
const row = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_SECRET_JOIN_SELECT}, ${LOCAL_SECURITY_AUDIT_JOIN_SELECT}
|
||||
FROM "QingLong3LocalSecretEnvelopes" AS secret
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = secret."mutation_id"
|
||||
WHERE secret."project_id" = ? AND secret."secret_name" = ?
|
||||
AND secret."mutation_id" = ?
|
||||
LIMIT 2`,
|
||||
[projectId, name, mutationId],
|
||||
),
|
||||
);
|
||||
if (!row) return null;
|
||||
return Object.freeze({
|
||||
envelope: localSecretEnvelopeFromRow(row),
|
||||
audit: localSecurityAuditFromRow(row),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
appendAuthorizedLocalSecretEnvelope(
|
||||
command: AppendAuthorizedLocalSecretEnvelopeCommand,
|
||||
): Promise<AppendAuthorizedLocalSecretEnvelopeResult> {
|
||||
assertLocalSecretExpectedVersion(command.expectedCurrentVersion);
|
||||
const envelope = normalizeLocalSecretEnvelope(command.envelope);
|
||||
const subject = normalizeProjectPolicySubject(command.subject);
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
const fence = command.fence;
|
||||
if (
|
||||
!fence ||
|
||||
typeof fence !== 'object' ||
|
||||
Array.isArray(fence) ||
|
||||
Object.keys(fence).sort().join(',') !== 'bindingVersion,projectVersion' ||
|
||||
!Number.isSafeInteger(fence.projectVersion) ||
|
||||
fence.projectVersion < 1 ||
|
||||
!Number.isSafeInteger(fence.bindingVersion) ||
|
||||
(fence.bindingVersion as number) < 1 ||
|
||||
envelope.version !== command.expectedCurrentVersion + 1 ||
|
||||
audit.eventId !== envelope.mutationId ||
|
||||
audit.projectId !== envelope.projectId ||
|
||||
audit.subject?.type !== subject.type ||
|
||||
audit.subject?.id !== subject.id ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.fence?.projectVersion !== fence.projectVersion ||
|
||||
audit.fence?.bindingVersion !== fence.bindingVersion ||
|
||||
(audit.operationId !== 'secret.create' &&
|
||||
audit.operationId !== 'secret.rotate')
|
||||
) {
|
||||
return Promise.reject(new LocalSecretMutationConflictError());
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
let began = false;
|
||||
try {
|
||||
this.client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
if (this.beforeAuthorizedLocalSecretMutation) {
|
||||
try {
|
||||
this.beforeAuthorizedLocalSecretMutation();
|
||||
} catch {
|
||||
throw new LocalSecretAuthorizationFenceConflictError();
|
||||
}
|
||||
}
|
||||
const replay = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_SECRET_JOIN_SELECT}, ${LOCAL_SECURITY_AUDIT_JOIN_SELECT}
|
||||
FROM "QingLong3LocalSecretEnvelopes" AS secret
|
||||
JOIN "QingLong3SecurityAuditEvents" AS audit
|
||||
ON audit."event_id" = secret."mutation_id"
|
||||
WHERE secret."project_id" = ? AND secret."secret_name" = ?
|
||||
AND secret."mutation_id" = ?
|
||||
LIMIT 2`,
|
||||
[envelope.projectId, envelope.name, envelope.mutationId],
|
||||
),
|
||||
);
|
||||
if (replay) {
|
||||
const existingEnvelope = localSecretEnvelopeFromRow(replay);
|
||||
const existingAudit = localSecurityAuditFromRow(replay);
|
||||
if (!sameSecurityAuditSemantic(existingAudit, audit)) {
|
||||
throw new LocalSecretMutationConflictError();
|
||||
}
|
||||
this.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
envelope: existingEnvelope,
|
||||
audit: existingAudit,
|
||||
});
|
||||
}
|
||||
const occupiedAudit = this.client
|
||||
.prepare(
|
||||
`SELECT "event_id" FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ? LIMIT 1`,
|
||||
)
|
||||
.get(audit.eventId);
|
||||
if (occupiedAudit) throw new LocalSecretMutationConflictError();
|
||||
|
||||
const projectRow = this.client
|
||||
.prepare(
|
||||
`SELECT "version", "status" FROM "QingLong3Projects"
|
||||
WHERE "id" = ? LIMIT 1`,
|
||||
)
|
||||
.get(envelope.projectId) as QueryRow | undefined;
|
||||
if (
|
||||
!projectRow ||
|
||||
requiredInteger(projectRow, 'version') !== fence.projectVersion ||
|
||||
requiredString(projectRow, 'status') !== 'active'
|
||||
) {
|
||||
throw new LocalSecretAuthorizationFenceConflictError();
|
||||
}
|
||||
const bindingRow = this.client
|
||||
.prepare(
|
||||
`SELECT "version", "state", "role"
|
||||
FROM "QingLong3ProjectRoleBindings"
|
||||
WHERE "project_id" = ? AND "subject_type" = ?
|
||||
AND "subject_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(envelope.projectId, subject.type, subject.id) as
|
||||
| QueryRow
|
||||
| undefined;
|
||||
if (
|
||||
!bindingRow ||
|
||||
requiredInteger(bindingRow, 'version') !== fence.bindingVersion ||
|
||||
requiredString(bindingRow, 'state') !== 'active' ||
|
||||
!['owner', 'admin'].includes(requiredString(bindingRow, 'role'))
|
||||
) {
|
||||
throw new LocalSecretAuthorizationFenceConflictError();
|
||||
}
|
||||
const current = this.client
|
||||
.prepare(
|
||||
`SELECT MAX("version") AS "version"
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE "project_id" = ? AND "secret_name" = ?`,
|
||||
)
|
||||
.get(envelope.projectId, envelope.name) as QueryRow | undefined;
|
||||
const currentVersion =
|
||||
current?.version === null || current?.version === undefined
|
||||
? 0
|
||||
: requiredInteger(current, 'version');
|
||||
if (currentVersion !== command.expectedCurrentVersion) {
|
||||
throw new LocalSecretVersionConflictError();
|
||||
}
|
||||
const nonce = Buffer.from(envelope.nonce, 'base64url');
|
||||
const ciphertext = Buffer.from(envelope.ciphertext, 'base64url');
|
||||
const authTag = Buffer.from(envelope.authTag, 'base64url');
|
||||
try {
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes" (
|
||||
"project_id", "secret_name", "version", "mutation_id",
|
||||
"key_id", "algorithm", "nonce", "ciphertext", "auth_tag",
|
||||
"created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
envelope.projectId,
|
||||
envelope.name,
|
||||
envelope.version,
|
||||
envelope.mutationId,
|
||||
envelope.keyId,
|
||||
envelope.algorithm,
|
||||
nonce,
|
||||
ciphertext,
|
||||
authTag,
|
||||
envelope.createdAtMs,
|
||||
);
|
||||
} finally {
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
}
|
||||
insertLocalSecurityAudit(this.client, audit);
|
||||
this.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
envelope,
|
||||
audit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && this.client.isTransaction) {
|
||||
try {
|
||||
this.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original failure.
|
||||
}
|
||||
}
|
||||
if (
|
||||
error instanceof LocalSecretAuthorizationFenceConflictError ||
|
||||
error instanceof LocalSecretVersionConflictError ||
|
||||
error instanceof LocalSecretMutationConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof RunRepositoryError) throw error;
|
||||
throw mapSqliteError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendLocalSecretEnvelope(
|
||||
command: AppendLocalSecretEnvelopeCommand,
|
||||
): Promise<AppendLocalSecretEnvelopeResult> {
|
||||
assertLocalSecretExpectedVersion(command.expectedCurrentVersion);
|
||||
const envelope = normalizeLocalSecretEnvelope(command.envelope);
|
||||
if (envelope.version !== command.expectedCurrentVersion + 1) {
|
||||
return Promise.reject(new LocalSecretVersionConflictError());
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
let began = false;
|
||||
try {
|
||||
this.client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const replay = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_SECRET_SELECT}
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE "project_id" = ? AND "secret_name" = ?
|
||||
AND "mutation_id" = ?
|
||||
LIMIT 2`,
|
||||
[envelope.projectId, envelope.name, envelope.mutationId],
|
||||
),
|
||||
);
|
||||
if (replay) {
|
||||
const existing = localSecretEnvelopeFromRow(replay);
|
||||
this.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
envelope: existing,
|
||||
});
|
||||
}
|
||||
const current = this.client
|
||||
.prepare(
|
||||
`SELECT MAX("version") AS "version"
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE "project_id" = ? AND "secret_name" = ?`,
|
||||
)
|
||||
.get(envelope.projectId, envelope.name) as QueryRow | undefined;
|
||||
const currentVersion =
|
||||
current?.version === null || current?.version === undefined
|
||||
? 0
|
||||
: requiredInteger(current, 'version');
|
||||
if (currentVersion !== command.expectedCurrentVersion) {
|
||||
throw new LocalSecretVersionConflictError();
|
||||
}
|
||||
const nonce = Buffer.from(envelope.nonce, 'base64url');
|
||||
const ciphertext = Buffer.from(envelope.ciphertext, 'base64url');
|
||||
const authTag = Buffer.from(envelope.authTag, 'base64url');
|
||||
try {
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes" (
|
||||
"project_id", "secret_name", "version", "mutation_id",
|
||||
"key_id", "algorithm", "nonce", "ciphertext", "auth_tag",
|
||||
"created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
envelope.projectId,
|
||||
envelope.name,
|
||||
envelope.version,
|
||||
envelope.mutationId,
|
||||
envelope.keyId,
|
||||
envelope.algorithm,
|
||||
nonce,
|
||||
ciphertext,
|
||||
authTag,
|
||||
envelope.createdAtMs,
|
||||
);
|
||||
} finally {
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
}
|
||||
this.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'inserted' as const,
|
||||
envelope,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && this.client.isTransaction) {
|
||||
try {
|
||||
this.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original failure; close discards a broken handle.
|
||||
}
|
||||
}
|
||||
if (error instanceof LocalSecretVersionConflictError) throw error;
|
||||
if (error instanceof RunRepositoryError) throw error;
|
||||
throw mapSqliteError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findLocalSecretEnvelopeByMutation(
|
||||
projectId: string,
|
||||
name: string,
|
||||
mutationId: string,
|
||||
): Promise<LocalSecretEnvelope | null> {
|
||||
assertLocalSecretProjectId(projectId);
|
||||
assertLocalSecretName(name);
|
||||
assertLocalSecretMutationId(mutationId);
|
||||
return this.enqueue(async () => {
|
||||
const row = singleRow(
|
||||
queryRows(
|
||||
this.client,
|
||||
`SELECT ${LOCAL_SECRET_SELECT}
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE "project_id" = ? AND "secret_name" = ?
|
||||
AND "mutation_id" = ?
|
||||
LIMIT 2`,
|
||||
[projectId, name, mutationId],
|
||||
),
|
||||
);
|
||||
return row ? localSecretEnvelopeFromRow(row) : null;
|
||||
});
|
||||
}
|
||||
|
||||
resolveLocalSecretEnvelopes(
|
||||
references: readonly LocalSecretReference[],
|
||||
): Promise<readonly (LocalSecretEnvelope | null)[]> {
|
||||
if (
|
||||
!Array.isArray(references) ||
|
||||
references.length > MAX_LOCAL_SECRET_BATCH_SIZE
|
||||
) {
|
||||
return Promise.reject(new RangeError('Local Secret batch is too large'));
|
||||
}
|
||||
const normalized = references.map((reference) => {
|
||||
createLocalSecretRef(reference);
|
||||
return Object.freeze({ ...reference });
|
||||
});
|
||||
if (normalized.length === 0) return Promise.resolve(Object.freeze([]));
|
||||
return this.enqueue(async () => {
|
||||
const values: (string | number | null)[] = [];
|
||||
const requested = normalized.map((reference, position) => {
|
||||
values.push(
|
||||
position,
|
||||
reference.projectId,
|
||||
reference.name,
|
||||
reference.version ?? null,
|
||||
);
|
||||
return '(?, ?, ?, ?)';
|
||||
});
|
||||
const rows = queryRows(
|
||||
this.client,
|
||||
`WITH requested(position, project_id, secret_name, requested_version) AS (
|
||||
VALUES ${requested.join(', ')}
|
||||
)
|
||||
SELECT requested.position AS "position",
|
||||
envelope."project_id" AS "projectId",
|
||||
envelope."secret_name" AS "name",
|
||||
envelope."version" AS "version",
|
||||
envelope."mutation_id" AS "mutationId",
|
||||
envelope."key_id" AS "keyId",
|
||||
envelope."algorithm" AS "algorithm",
|
||||
envelope."nonce" AS "nonce",
|
||||
envelope."ciphertext" AS "ciphertext",
|
||||
envelope."auth_tag" AS "authTag",
|
||||
envelope."created_at_ms" AS "createdAtMs"
|
||||
FROM requested
|
||||
LEFT JOIN "QingLong3LocalSecretEnvelopes" AS envelope
|
||||
ON envelope."project_id" = requested.project_id
|
||||
AND envelope."secret_name" = requested.secret_name
|
||||
AND envelope."version" = COALESCE(
|
||||
requested.requested_version,
|
||||
(SELECT MAX(current."version")
|
||||
FROM "QingLong3LocalSecretEnvelopes" AS current
|
||||
WHERE current."project_id" = requested.project_id
|
||||
AND current."secret_name" = requested.secret_name)
|
||||
)
|
||||
ORDER BY requested.position`,
|
||||
values,
|
||||
);
|
||||
if (rows.length !== normalized.length) {
|
||||
throw new RunRepositoryConstraintError(
|
||||
'Local SQLite Secret batch result is incomplete',
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
rows.map((row, position) => {
|
||||
if (requiredInteger(row, 'position') !== position) {
|
||||
throw new RunRepositoryConstraintError(
|
||||
'Local SQLite Secret batch order is invalid',
|
||||
);
|
||||
}
|
||||
return row.version === null ? null : localSecretEnvelopeFromRow(row);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
normalizeLocalSecretEnvelope,
|
||||
type LocalSecretEnvelope,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
normalizeProjectRecord,
|
||||
normalizeProjectRoleBinding,
|
||||
type ProjectRecord,
|
||||
type ProjectRoleBindingRecord,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
RunRepositoryBusyError,
|
||||
RunRepositoryConstraintError,
|
||||
RunRepositoryError,
|
||||
RunRepositoryOperationError,
|
||||
} from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
import {
|
||||
createSqlitePersistencePrimitives,
|
||||
sqliteDriverErrorCode,
|
||||
sqliteDriverErrorNumber,
|
||||
type SqliteQueryRow,
|
||||
type SqliteQueryValue,
|
||||
} from '../storage/sqlitePersistence';
|
||||
|
||||
export type QueryRow = SqliteQueryRow;
|
||||
|
||||
export function mapSqliteError(error: unknown): RunRepositoryError {
|
||||
if (error instanceof RunRepositoryError) return error;
|
||||
const baseCode = (sqliteDriverErrorNumber(error) ?? 0) & 0xff;
|
||||
if (baseCode === 5 || baseCode === 6) {
|
||||
return new RunRepositoryBusyError(error);
|
||||
}
|
||||
if (
|
||||
baseCode === 19 ||
|
||||
sqliteDriverErrorCode(error) === 'ERR_SQLITE_CONSTRAINT'
|
||||
) {
|
||||
return new RunRepositoryConstraintError(
|
||||
'Local SQLite Run repository constraint violation',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return new RunRepositoryOperationError(error);
|
||||
}
|
||||
|
||||
const SECURITY_SQLITE_PERSISTENCE = createSqlitePersistencePrimitives({
|
||||
invalidRowValue: (property) =>
|
||||
new RunRepositoryConstraintError(
|
||||
`Local SQLite Run row has an invalid ${property}`,
|
||||
),
|
||||
invalidJson: (property) =>
|
||||
new RunRepositoryConstraintError(
|
||||
`Local SQLite Run row has invalid ${property} JSON`,
|
||||
),
|
||||
unsupportedRowValue: (property) =>
|
||||
new RunRepositoryConstraintError(
|
||||
`Local SQLite Run row has an unsupported ${property}`,
|
||||
),
|
||||
duplicateIdentityRows: () =>
|
||||
new RunRepositoryConstraintError(
|
||||
'Local SQLite Run repository returned duplicate identity rows',
|
||||
),
|
||||
mapDriverError: mapSqliteError,
|
||||
});
|
||||
|
||||
export function optionalInteger(
|
||||
row: QueryRow,
|
||||
property: string,
|
||||
): number | undefined {
|
||||
return SECURITY_SQLITE_PERSISTENCE.optionalInteger(row, property);
|
||||
}
|
||||
|
||||
export function optionalString(
|
||||
row: QueryRow,
|
||||
property: string,
|
||||
): string | undefined {
|
||||
return SECURITY_SQLITE_PERSISTENCE.optionalString(row, property);
|
||||
}
|
||||
|
||||
export function requiredBlob(row: QueryRow, property: string): Buffer {
|
||||
try {
|
||||
return SECURITY_SQLITE_PERSISTENCE.requiredBlob(row, property);
|
||||
} catch (error) {
|
||||
if (error instanceof RunRepositoryConstraintError) {
|
||||
throw new RunRepositoryConstraintError(
|
||||
`Local SQLite row has an invalid ${property}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function requiredInteger(row: QueryRow, property: string): number {
|
||||
return SECURITY_SQLITE_PERSISTENCE.requiredInteger(row, property);
|
||||
}
|
||||
|
||||
export function requiredJson(row: QueryRow, property: string): unknown {
|
||||
return SECURITY_SQLITE_PERSISTENCE.requiredJson(row, property);
|
||||
}
|
||||
|
||||
export function requiredString(row: QueryRow, property: string): string {
|
||||
return SECURITY_SQLITE_PERSISTENCE.requiredString(row, property);
|
||||
}
|
||||
|
||||
export function queryRows(
|
||||
client: DatabaseSync,
|
||||
sql: string,
|
||||
values: readonly SqliteQueryValue[] = [],
|
||||
): QueryRow[] {
|
||||
return SECURITY_SQLITE_PERSISTENCE.queryRows(client, sql, values);
|
||||
}
|
||||
|
||||
export function singleRow(rows: QueryRow[]): QueryRow | null {
|
||||
return SECURITY_SQLITE_PERSISTENCE.singleRow(rows);
|
||||
}
|
||||
|
||||
export function localSecretEnvelopeFromRow(row: QueryRow): LocalSecretEnvelope {
|
||||
const nonce = requiredBlob(row, 'nonce');
|
||||
const ciphertext = requiredBlob(row, 'ciphertext');
|
||||
const authTag = requiredBlob(row, 'authTag');
|
||||
try {
|
||||
return normalizeLocalSecretEnvelope({
|
||||
projectId: requiredString(row, 'projectId'),
|
||||
name: requiredString(row, 'name'),
|
||||
version: requiredInteger(row, 'version'),
|
||||
mutationId: requiredString(row, 'mutationId'),
|
||||
keyId: requiredString(row, 'keyId'),
|
||||
algorithm: requiredString(row, 'algorithm') as 'aes-256-gcm',
|
||||
nonce: nonce.toString('base64url'),
|
||||
ciphertext: ciphertext.toString('base64url'),
|
||||
authTag: authTag.toString('base64url'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
});
|
||||
} finally {
|
||||
nonce.fill(0);
|
||||
ciphertext.fill(0);
|
||||
authTag.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export const LOCAL_SECRET_SELECT = `
|
||||
"project_id" AS "projectId",
|
||||
"secret_name" AS "name",
|
||||
"version" AS "version",
|
||||
"mutation_id" AS "mutationId",
|
||||
"key_id" AS "keyId",
|
||||
"algorithm" AS "algorithm",
|
||||
"nonce" AS "nonce",
|
||||
"ciphertext" AS "ciphertext",
|
||||
"auth_tag" AS "authTag",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
`;
|
||||
|
||||
export const LOCAL_PROJECT_SELECT = `
|
||||
"id" AS "id",
|
||||
"name" AS "name",
|
||||
"slug" AS "slug",
|
||||
"status" AS "status",
|
||||
"version" AS "version",
|
||||
"created_at_ms" AS "createdAtMs",
|
||||
"updated_at_ms" AS "updatedAtMs"
|
||||
`;
|
||||
|
||||
export const LOCAL_ROLE_BINDING_SELECT = `
|
||||
"project_id" AS "projectId",
|
||||
"subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId",
|
||||
"version" AS "version",
|
||||
"state" AS "state",
|
||||
"role" AS "role",
|
||||
"mutation_id" AS "mutationId",
|
||||
"changed_by_type" AS "changedByType",
|
||||
"changed_by_id" AS "changedById",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
`;
|
||||
|
||||
export const LOCAL_SECURITY_AUDIT_SELECT = `
|
||||
"event_id" AS "eventId",
|
||||
"request_id" AS "requestId",
|
||||
"operation_id" AS "operationId",
|
||||
"project_id" AS "projectId",
|
||||
"subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId",
|
||||
"authentication_id" AS "authenticationId",
|
||||
"outcome" AS "outcome",
|
||||
"reasons_json" AS "reasonsJson",
|
||||
"fence_project_version" AS "fenceProjectVersion",
|
||||
"fence_binding_version" AS "fenceBindingVersion",
|
||||
"occurred_at_ms" AS "occurredAtMs"
|
||||
`;
|
||||
|
||||
export const LOCAL_SECRET_JOIN_SELECT = `
|
||||
secret."project_id" AS "projectId",
|
||||
secret."secret_name" AS "name",
|
||||
secret."version" AS "version",
|
||||
secret."mutation_id" AS "mutationId",
|
||||
secret."key_id" AS "keyId",
|
||||
secret."algorithm" AS "algorithm",
|
||||
secret."nonce" AS "nonce",
|
||||
secret."ciphertext" AS "ciphertext",
|
||||
secret."auth_tag" AS "authTag",
|
||||
secret."created_at_ms" AS "createdAtMs"
|
||||
`;
|
||||
|
||||
export const LOCAL_SECURITY_AUDIT_JOIN_SELECT = `
|
||||
audit."event_id" AS "eventId",
|
||||
audit."request_id" AS "requestId",
|
||||
audit."operation_id" AS "operationId",
|
||||
audit."project_id" AS "auditProjectId",
|
||||
audit."subject_type" AS "subjectType",
|
||||
audit."subject_id" AS "subjectId",
|
||||
audit."authentication_id" AS "authenticationId",
|
||||
audit."outcome" AS "outcome",
|
||||
audit."reasons_json" AS "reasonsJson",
|
||||
audit."fence_project_version" AS "fenceProjectVersion",
|
||||
audit."fence_binding_version" AS "fenceBindingVersion",
|
||||
audit."occurred_at_ms" AS "occurredAtMs"
|
||||
`;
|
||||
|
||||
export function localProjectFromRow(row: QueryRow): Readonly<ProjectRecord> {
|
||||
return normalizeProjectRecord({
|
||||
id: requiredString(row, 'id'),
|
||||
name: requiredString(row, 'name'),
|
||||
slug: requiredString(row, 'slug'),
|
||||
status: requiredString(row, 'status') as ProjectRecord['status'],
|
||||
version: requiredInteger(row, 'version'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
updatedAtMs: requiredInteger(row, 'updatedAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function localRoleBindingFromRow(
|
||||
row: QueryRow,
|
||||
): Readonly<ProjectRoleBindingRecord> {
|
||||
const state = requiredString(
|
||||
row,
|
||||
'state',
|
||||
) as ProjectRoleBindingRecord['state'];
|
||||
return normalizeProjectRoleBinding({
|
||||
projectId: requiredString(row, 'projectId'),
|
||||
subject: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'subjectType',
|
||||
) as ProjectRoleBindingRecord['subject']['type'],
|
||||
id: requiredString(row, 'subjectId'),
|
||||
},
|
||||
version: requiredInteger(row, 'version'),
|
||||
state,
|
||||
...(state === 'active'
|
||||
? {
|
||||
role: requiredString(row, 'role') as NonNullable<
|
||||
ProjectRoleBindingRecord['role']
|
||||
>,
|
||||
}
|
||||
: {}),
|
||||
mutationId: requiredString(row, 'mutationId'),
|
||||
changedBy: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'changedByType',
|
||||
) as ProjectRoleBindingRecord['changedBy']['type'],
|
||||
id: requiredString(row, 'changedById'),
|
||||
},
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function localSecurityAuditFromRow(
|
||||
row: QueryRow,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
const subjectType = optionalString(row, 'subjectType');
|
||||
const subjectId = optionalString(row, 'subjectId');
|
||||
const authenticationId = optionalString(row, 'authenticationId');
|
||||
const fenceProjectVersion = optionalInteger(row, 'fenceProjectVersion');
|
||||
const fenceBindingVersion = optionalInteger(row, 'fenceBindingVersion');
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: requiredString(row, 'eventId'),
|
||||
requestId: requiredString(row, 'requestId'),
|
||||
operationId: requiredString(row, 'operationId'),
|
||||
projectId:
|
||||
optionalString(row, 'auditProjectId') ??
|
||||
optionalString(row, 'projectId') ??
|
||||
null,
|
||||
subject:
|
||||
subjectType && subjectId
|
||||
? {
|
||||
type: subjectType as NonNullable<
|
||||
SecurityAuditRecord['subject']
|
||||
>['type'],
|
||||
id: subjectId,
|
||||
}
|
||||
: null,
|
||||
authenticationId: authenticationId ?? null,
|
||||
outcome: requiredString(row, 'outcome') as SecurityAuditRecord['outcome'],
|
||||
reasons: requiredJson(row, 'reasonsJson') as readonly string[],
|
||||
fence:
|
||||
fenceProjectVersion === undefined
|
||||
? null
|
||||
: {
|
||||
projectVersion: fenceProjectVersion,
|
||||
bindingVersion: fenceBindingVersion ?? null,
|
||||
},
|
||||
occurredAtMs: requiredInteger(row, 'occurredAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function sameSecurityAuditSemantic(
|
||||
left: Readonly<SecurityAuditRecord>,
|
||||
right: Readonly<SecurityAuditRecord>,
|
||||
): boolean {
|
||||
const { occurredAtMs: _leftTime, ...leftSemantic } = left;
|
||||
const { occurredAtMs: _rightTime, ...rightSemantic } = right;
|
||||
return JSON.stringify(leftSemantic) === JSON.stringify(rightSemantic);
|
||||
}
|
||||
|
||||
export function insertLocalSecurityAudit(
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user