feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,49 @@
import type { SecurityPolicyFence, SecuritySubject } from '../security';
import type { SecurityAuditRecord, SecurityAuditSink } from './securityAudit';
import type {
SecurityAuditQuery,
SecurityAuditQueryPage,
} from './securityAuditQuery';
export const MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE = 64;
export interface LocalSecurityAuditQueryAuthorization {
readonly authorityProjectId: string;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
}
export interface ListAuthorizedLocalSecurityAuditCommand {
readonly query: SecurityAuditQuery;
readonly authorization: LocalSecurityAuditQueryAuthorization;
readonly audit: SecurityAuditRecord;
}
export interface ListAuthorizedLocalSecurityAuditResult
extends SecurityAuditQueryPage {
readonly audit: Readonly<SecurityAuditRecord>;
}
export interface LocalSecurityAuditQueryRepository extends SecurityAuditSink {
listAuthorized(
command: ListAuthorizedLocalSecurityAuditCommand,
): Promise<ListAuthorizedLocalSecurityAuditResult>;
}
export class LocalSecurityAuditQueryAuthorizationFenceConflictError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_AUTHORIZATION_FENCE_CONFLICT';
constructor() {
super('Local security audit query authorization fence changed');
this.name = 'LocalSecurityAuditQueryAuthorizationFenceConflictError';
}
}
export class LocalSecurityAuditQueryUnavailableError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_UNAVAILABLE';
constructor() {
super('Local security audit query is unavailable');
this.name = 'LocalSecurityAuditQueryUnavailableError';
}
}
@@ -0,0 +1,131 @@
import { createHash } from 'node:crypto';
import type { SecurityPolicyFence, SecuritySubject } from '../security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
type SecurityAuditSink,
} from './securityAudit';
import type { SecurityAuditQueryCursor } from './securityAuditQuery';
export const MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
export const MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS =
10 * 365 * 24 * 60 * 60 * 1_000;
export const MAX_EDGE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE = 64;
export const MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE = 512;
export const MAX_LOCAL_SECURITY_AUDIT_COMPACTION_PAYLOAD_BYTES =
16 * 1024 * 1024;
export interface LocalSecurityAuditRetentionAuthorization {
readonly authorityProjectId: string;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
}
export interface LocalSecurityAuditCompactionRecord {
readonly mutationId: string;
readonly requestId: string;
readonly authorityProjectId: string;
readonly retentionMs: number;
readonly eligibleBeforeMs: number;
readonly batchLimit: number;
readonly deletedCount: number;
readonly deletedPayloadBytes: number;
readonly first: Readonly<SecurityAuditQueryCursor> | null;
readonly last: Readonly<SecurityAuditQueryCursor> | null;
readonly recordsDigest: string;
readonly createdAtMs: number;
}
export interface CompactAuthorizedLocalSecurityAuditCommand {
readonly mutationId: string;
readonly requestId: string;
readonly retentionMs: number;
readonly eligibleBeforeMs: number;
readonly limit: number;
readonly authorization: LocalSecurityAuditRetentionAuthorization;
readonly audit: SecurityAuditRecord;
}
export interface CompactAuthorizedLocalSecurityAuditResult {
readonly status: 'inserted' | 'existing';
readonly record: Readonly<LocalSecurityAuditCompactionRecord>;
readonly audit: Readonly<SecurityAuditRecord>;
}
export interface LocalSecurityAuditRetentionRepository
extends SecurityAuditSink {
resolveCompaction(
mutationId: string,
): Promise<Readonly<LocalSecurityAuditCompactionRecord> | null>;
compactAuthorized(
command: CompactAuthorizedLocalSecurityAuditCommand,
): Promise<CompactAuthorizedLocalSecurityAuditResult>;
}
export class InvalidLocalSecurityAuditRetentionValueError extends TypeError {
constructor(message: string) {
super(`Local security audit retention value is invalid: ${message}`);
this.name = 'InvalidLocalSecurityAuditRetentionValueError';
}
}
export class LocalSecurityAuditRetentionAuthorizationFenceConflictError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_AUTHORIZATION_FENCE_CONFLICT';
constructor() {
super('Local security audit retention authorization fence changed');
this.name = 'LocalSecurityAuditRetentionAuthorizationFenceConflictError';
}
}
export class LocalSecurityAuditCompactionMutationConflictError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_COMPACTION_MUTATION_CONFLICT';
constructor() {
super('Local security audit compaction mutation conflicts');
this.name = 'LocalSecurityAuditCompactionMutationConflictError';
}
}
export class LocalSecurityAuditRetentionUnavailableError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_UNAVAILABLE';
constructor() {
super('Local security audit retention is unavailable');
this.name = 'LocalSecurityAuditRetentionUnavailableError';
}
}
export function localSecurityAuditCompactionPayload(
records: readonly Readonly<SecurityAuditRecord>[],
): Readonly<{ recordsDigest: string; payloadBytes: number }> {
if (!Array.isArray(records)) {
throw new InvalidLocalSecurityAuditRetentionValueError(
'records must be an array',
);
}
let normalized: Readonly<SecurityAuditRecord>[];
try {
normalized = records.map((record) => normalizeSecurityAuditRecord(record));
} catch {
throw new InvalidLocalSecurityAuditRetentionValueError(
'records contain an invalid audit value',
);
}
const payload = JSON.stringify(normalized);
const payloadBytes =
normalized.length === 0 ? 0 : Buffer.byteLength(payload, 'utf8');
if (payloadBytes > MAX_LOCAL_SECURITY_AUDIT_COMPACTION_PAYLOAD_BYTES) {
throw new InvalidLocalSecurityAuditRetentionValueError(
'records exceed the payload limit',
);
}
return Object.freeze({
recordsDigest: createHash('sha256')
.update('qinglong.local-security-audit-compaction.records.v1\0', 'utf8')
.update(payload, 'utf8')
.digest('hex'),
payloadBytes,
});
}
@@ -0,0 +1,191 @@
import type { SecurityPolicyFence, SecuritySubject } from '../security';
import { SECURITY_SUBJECT_TYPES } from '../security';
export const SECURITY_AUDIT_OUTCOMES = [
'authentication_rejected',
'authentication_unavailable',
'authorization_unavailable',
'denied',
'approval_required',
'allowed',
] as const;
export type SecurityAuditOutcome = (typeof SECURITY_AUDIT_OUTCOMES)[number];
export interface SecurityAuditRecord {
readonly eventId: string;
readonly requestId: string;
readonly operationId: string;
readonly projectId: string | null;
readonly subject: SecuritySubject | null;
readonly authenticationId: string | null;
readonly outcome: SecurityAuditOutcome;
readonly reasons: readonly string[];
readonly fence: SecurityPolicyFence | null;
readonly occurredAtMs: number;
}
export interface SecurityAuditSink {
record(record: SecurityAuditRecord): void | Promise<void>;
}
export class InvalidSecurityAuditValueError extends TypeError {
constructor(message: string) {
super(`Security audit value is invalid: ${message}`);
this.name = 'InvalidSecurityAuditValueError';
}
}
export class SecurityAuditUnavailableError extends Error {
readonly code = 'SECURITY_AUDIT_UNAVAILABLE';
constructor() {
super('Security audit storage is unavailable');
this.name = 'SecurityAuditUnavailableError';
}
}
const UUID_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 OPERATION_PATTERN = /^[a-z][a-z0-9_.:-]{0,127}$/;
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const AUTHENTICATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
const SUBJECT_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
function exactKeys(
value: object,
expected: readonly string[],
name: string,
): void {
const keys = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
keys.length !== canonical.length ||
keys.some((key, index) => key !== canonical[index])
) {
throw new InvalidSecurityAuditValueError(`${name} shape is invalid`);
}
}
function normalizeSubject(
value: SecuritySubject | null,
): Readonly<SecuritySubject> | null {
if (value === null) return null;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidSecurityAuditValueError('subject is invalid');
}
exactKeys(value, ['type', 'id'], 'subject');
if (
!SECURITY_SUBJECT_TYPES.includes(value.type) ||
typeof value.id !== 'string' ||
value.id.length < 1 ||
value.id.length > 255 ||
SUBJECT_ID_CONTROL_PATTERN.test(value.id)
) {
throw new InvalidSecurityAuditValueError('subject is invalid');
}
return Object.freeze({ type: value.type, id: value.id });
}
function normalizeFence(
value: SecurityPolicyFence | null,
): Readonly<SecurityPolicyFence> | null {
if (value === null) return null;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidSecurityAuditValueError('fence is invalid');
}
exactKeys(value, ['projectVersion', 'bindingVersion'], 'fence');
if (
!Number.isSafeInteger(value.projectVersion) ||
value.projectVersion < 1 ||
(value.bindingVersion !== null &&
(!Number.isSafeInteger(value.bindingVersion) || value.bindingVersion < 1))
) {
throw new InvalidSecurityAuditValueError('fence is invalid');
}
return Object.freeze({
projectVersion: value.projectVersion,
bindingVersion: value.bindingVersion,
});
}
export function normalizeSecurityAuditRecord(
value: SecurityAuditRecord,
): Readonly<SecurityAuditRecord> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidSecurityAuditValueError('record must be an object');
}
exactKeys(
value,
[
'eventId',
'requestId',
'operationId',
'projectId',
'subject',
'authenticationId',
'outcome',
'reasons',
'fence',
'occurredAtMs',
],
'record',
);
if (!UUID_PATTERN.test(value.eventId)) {
throw new InvalidSecurityAuditValueError('eventId is invalid');
}
if (!REQUEST_ID_PATTERN.test(value.requestId)) {
throw new InvalidSecurityAuditValueError('requestId is invalid');
}
if (!OPERATION_PATTERN.test(value.operationId)) {
throw new InvalidSecurityAuditValueError('operationId is invalid');
}
if (value.projectId !== null && !PROJECT_PATTERN.test(value.projectId)) {
throw new InvalidSecurityAuditValueError('projectId is invalid');
}
const subject = normalizeSubject(value.subject);
if (
(value.authenticationId === null) !== (subject === null) ||
(value.authenticationId !== null &&
!AUTHENTICATION_ID_PATTERN.test(value.authenticationId))
) {
throw new InvalidSecurityAuditValueError('authenticationId is invalid');
}
if (!SECURITY_AUDIT_OUTCOMES.includes(value.outcome)) {
throw new InvalidSecurityAuditValueError('outcome is invalid');
}
const preAuthentication =
value.outcome === 'authentication_rejected' ||
value.outcome === 'authentication_unavailable';
if (preAuthentication !== (subject === null)) {
throw new InvalidSecurityAuditValueError('outcome identity is invalid');
}
if (
!Array.isArray(value.reasons) ||
value.reasons.length < 1 ||
value.reasons.length > 8 ||
value.reasons.some(
(reason) => typeof reason !== 'string' || !REASON_PATTERN.test(reason),
)
) {
throw new InvalidSecurityAuditValueError('reasons are invalid');
}
const fence = normalizeFence(value.fence);
if (!Number.isSafeInteger(value.occurredAtMs) || value.occurredAtMs < 0) {
throw new InvalidSecurityAuditValueError('occurredAtMs is invalid');
}
return Object.freeze({
eventId: value.eventId,
requestId: value.requestId,
operationId: value.operationId,
projectId: value.projectId,
subject,
authenticationId: value.authenticationId,
outcome: value.outcome,
reasons: Object.freeze([...value.reasons]),
fence,
occurredAtMs: value.occurredAtMs,
});
}
@@ -0,0 +1,163 @@
import {
SECURITY_AUDIT_OUTCOMES,
type SecurityAuditOutcome,
type SecurityAuditRecord,
} from './securityAudit';
import { SECURITY_SUBJECT_TYPES, type SecuritySubject } from '../security';
export const MAX_SECURITY_AUDIT_QUERY_PAGE_SIZE = 200;
export interface SecurityAuditQueryCursor {
readonly occurredAtMs: number;
readonly eventId: string;
}
export interface SecurityAuditQueryFilter {
readonly projectId?: string;
readonly subject?: SecuritySubject;
readonly outcome?: SecurityAuditOutcome;
}
export interface SecurityAuditQuery {
readonly limit: number;
readonly before?: SecurityAuditQueryCursor;
readonly filter: SecurityAuditQueryFilter;
}
export interface SecurityAuditQueryPage {
readonly records: readonly Readonly<SecurityAuditRecord>[];
readonly nextCursor: Readonly<SecurityAuditQueryCursor> | null;
}
export interface SecurityAuditQueryRepository {
list(query: SecurityAuditQuery): Promise<SecurityAuditQueryPage>;
}
export class InvalidSecurityAuditQueryError extends TypeError {
constructor(message: string) {
super(`Security audit query is invalid: ${message}`);
this.name = 'InvalidSecurityAuditQueryError';
}
}
export class SecurityAuditQueryUnavailableError extends Error {
readonly code = 'SECURITY_AUDIT_QUERY_UNAVAILABLE';
constructor() {
super('Security audit query is unavailable');
this.name = 'SecurityAuditQueryUnavailableError';
}
}
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SUBJECT_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
function exactKeys(
value: object,
expected: readonly string[],
name: string,
): void {
const keys = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
keys.length !== canonical.length ||
keys.some((key, index) => key !== canonical[index])
) {
throw new InvalidSecurityAuditQueryError(`${name} shape is invalid`);
}
}
export function normalizeSecurityAuditQuery(
value: SecurityAuditQuery,
): Readonly<SecurityAuditQuery> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidSecurityAuditQueryError('query must be an object');
}
const topKeys = ['limit', 'filter'];
if (value.before !== undefined) topKeys.push('before');
exactKeys(value, topKeys, 'query');
if (
!Number.isSafeInteger(value.limit) ||
value.limit < 1 ||
value.limit > MAX_SECURITY_AUDIT_QUERY_PAGE_SIZE
) {
throw new InvalidSecurityAuditQueryError('limit is invalid');
}
let before: Readonly<SecurityAuditQueryCursor> | undefined;
if (value.before !== undefined) {
if (
!value.before ||
typeof value.before !== 'object' ||
Array.isArray(value.before)
) {
throw new InvalidSecurityAuditQueryError('before is invalid');
}
exactKeys(value.before, ['occurredAtMs', 'eventId'], 'before');
if (
!Number.isSafeInteger(value.before.occurredAtMs) ||
value.before.occurredAtMs < 0 ||
!UUID_PATTERN.test(value.before.eventId)
) {
throw new InvalidSecurityAuditQueryError('before is invalid');
}
before = Object.freeze({ ...value.before });
}
if (
!value.filter ||
typeof value.filter !== 'object' ||
Array.isArray(value.filter)
) {
throw new InvalidSecurityAuditQueryError('filter is invalid');
}
const filterKeys = Object.keys(value.filter);
if (
filterKeys.some((key) => !['projectId', 'subject', 'outcome'].includes(key))
) {
throw new InvalidSecurityAuditQueryError('filter shape is invalid');
}
const filter: {
projectId?: string;
subject?: Readonly<SecuritySubject>;
outcome?: SecurityAuditOutcome;
} = {};
if (value.filter.projectId !== undefined) {
if (!PROJECT_PATTERN.test(value.filter.projectId)) {
throw new InvalidSecurityAuditQueryError('projectId is invalid');
}
filter.projectId = value.filter.projectId;
}
if (value.filter.subject !== undefined) {
const candidate = value.filter.subject;
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate)
) {
throw new InvalidSecurityAuditQueryError('subject is invalid');
}
exactKeys(candidate, ['type', 'id'], 'subject');
if (
!SECURITY_SUBJECT_TYPES.includes(candidate.type) ||
typeof candidate.id !== 'string' ||
candidate.id.length < 1 ||
candidate.id.length > 255 ||
SUBJECT_ID_CONTROL_PATTERN.test(candidate.id)
) {
throw new InvalidSecurityAuditQueryError('subject is invalid');
}
filter.subject = Object.freeze({ type: candidate.type, id: candidate.id });
}
if (value.filter.outcome !== undefined) {
if (!SECURITY_AUDIT_OUTCOMES.includes(value.filter.outcome)) {
throw new InvalidSecurityAuditQueryError('outcome is invalid');
}
filter.outcome = value.filter.outcome;
}
return Object.freeze({
limit: value.limit,
...(before ? { before } : {}),
filter: Object.freeze(filter),
});
}