mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
// PostgreSQL administration authority for versioned API credential mutations.
|
||||
import {
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
ApiCredentialAdministrationSubjectNotFoundError,
|
||||
ApiCredentialAdministrationUnavailableError,
|
||||
ApiCredentialAdministrationVersionConflictError,
|
||||
normalizeAppendApiCredentialCommand,
|
||||
normalizeApiCredentialAdministrationMutationId,
|
||||
type ApiCredentialAdministrationRepository,
|
||||
type ApiCredentialMutationRecord,
|
||||
type AppendApiCredentialCommand,
|
||||
type AppendApiCredentialResult,
|
||||
type ResolvedApiCredentialMutation,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRecord,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
ADMINISTRATION_AUDIT_SELECT,
|
||||
auditFromRow,
|
||||
configureAdministrationTransaction,
|
||||
insertAdministrationAudit,
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
retryableAdministrationError,
|
||||
rollbackAdministrationTransaction,
|
||||
sameAdministrationReplayAudit,
|
||||
type AdministrationAuditRow,
|
||||
} from '../repository/administrationSupport';
|
||||
|
||||
interface CredentialMutationRow extends AdministrationAuditRow {
|
||||
mutationId: unknown;
|
||||
operation: unknown;
|
||||
credentialId: unknown;
|
||||
credentialVersion: unknown;
|
||||
expectedPreviousVersion: unknown;
|
||||
state: unknown;
|
||||
subjectType: unknown;
|
||||
subjectId: unknown;
|
||||
subjectStatus: unknown;
|
||||
pepperKeyId: unknown;
|
||||
secretDigest: unknown;
|
||||
createdAtMs: unknown;
|
||||
notBeforeAtMs: unknown;
|
||||
expiresAtMs: unknown;
|
||||
changedByType: unknown;
|
||||
changedById: unknown;
|
||||
}
|
||||
|
||||
interface CurrentCredentialRow extends Record<string, unknown> {
|
||||
credentialId: unknown;
|
||||
version: unknown;
|
||||
state: unknown;
|
||||
subjectType: unknown;
|
||||
subjectId: unknown;
|
||||
}
|
||||
|
||||
interface SubjectRow extends Record<string, unknown> {
|
||||
status: unknown;
|
||||
}
|
||||
|
||||
const MAX_TRANSACTION_ATTEMPTS = 3;
|
||||
const MUTATION_SELECT = `
|
||||
SELECT
|
||||
mutation.mutation_id AS "mutationId",
|
||||
mutation.operation,
|
||||
mutation.credential_id AS "credentialId",
|
||||
mutation.credential_version AS "credentialVersion",
|
||||
mutation.expected_previous_version AS "expectedPreviousVersion",
|
||||
mutation.state,
|
||||
mutation.subject_type AS "subjectType",
|
||||
mutation.subject_id AS "subjectId",
|
||||
mutation.subject_status AS "subjectStatus",
|
||||
mutation.changed_by_type AS "changedByType",
|
||||
mutation.changed_by_id AS "changedById",
|
||||
mutation.created_at_ms AS "createdAtMs",
|
||||
credential.pepper_key_id AS "pepperKeyId",
|
||||
credential.secret_digest AS "secretDigest",
|
||||
credential.not_before_at_ms AS "notBeforeAtMs",
|
||||
credential.expires_at_ms AS "expiresAtMs",
|
||||
${ADMINISTRATION_AUDIT_SELECT}
|
||||
FROM "ql3"."api_credential_mutations" AS mutation
|
||||
JOIN "ql3"."api_credentials" AS credential
|
||||
ON credential.credential_id = mutation.credential_id
|
||||
AND credential.version = mutation.credential_version
|
||||
JOIN "ql3"."security_audit_events" AS audit
|
||||
ON audit.event_id = mutation.audit_event_id
|
||||
WHERE mutation.mutation_id = $1
|
||||
LIMIT 2
|
||||
`.trim();
|
||||
|
||||
function storedMutation(row: CredentialMutationRow): {
|
||||
credential: Readonly<ApiCredentialRecord>;
|
||||
mutation: Readonly<ApiCredentialMutationRecord>;
|
||||
audit: ReturnType<typeof auditFromRow>;
|
||||
} {
|
||||
const audit = auditFromRow(row);
|
||||
const mutation: ApiCredentialMutationRecord = {
|
||||
mutationId: requiredString(row, 'mutationId'),
|
||||
operation: requiredString(
|
||||
row,
|
||||
'operation',
|
||||
) as ApiCredentialMutationRecord['operation'],
|
||||
credentialId: requiredString(row, 'credentialId'),
|
||||
credentialVersion: requiredInteger(row, 'credentialVersion'),
|
||||
expectedPreviousVersion: requiredInteger(row, 'expectedPreviousVersion'),
|
||||
changedBy: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'changedByType',
|
||||
) as ApiCredentialMutationRecord['changedBy']['type'],
|
||||
id: requiredString(row, 'changedById'),
|
||||
},
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
};
|
||||
const credential = normalizeApiCredentialRecord({
|
||||
credentialId: mutation.credentialId,
|
||||
version: mutation.credentialVersion,
|
||||
pepperKeyId: requiredString(row, 'pepperKeyId'),
|
||||
state: requiredString(row, 'state') as ApiCredentialRecord['state'],
|
||||
subject: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'subjectType',
|
||||
) as ApiCredentialRecord['subject']['type'],
|
||||
id: requiredString(row, 'subjectId'),
|
||||
},
|
||||
subjectStatus: requiredString(
|
||||
row,
|
||||
'subjectStatus',
|
||||
) as ApiCredentialRecord['subjectStatus'],
|
||||
secretDigest: requiredString(row, 'secretDigest'),
|
||||
createdAtMs: mutation.createdAtMs,
|
||||
notBeforeAtMs: requiredInteger(row, 'notBeforeAtMs'),
|
||||
expiresAtMs: requiredInteger(row, 'expiresAtMs'),
|
||||
});
|
||||
const normalized = normalizeAppendApiCredentialCommand({
|
||||
expectedCurrentVersion: mutation.expectedPreviousVersion,
|
||||
credential,
|
||||
mutation,
|
||||
audit,
|
||||
});
|
||||
return {
|
||||
credential: normalized.credential,
|
||||
mutation: normalized.mutation,
|
||||
audit: normalized.audit,
|
||||
};
|
||||
}
|
||||
|
||||
function sameValue(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function sameCredentialForReplay(
|
||||
left: Readonly<ApiCredentialRecord>,
|
||||
right: Readonly<ApiCredentialRecord>,
|
||||
): boolean {
|
||||
const {
|
||||
secretDigest: _leftSecretDigest,
|
||||
createdAtMs: _leftCreatedAtMs,
|
||||
...leftSemantic
|
||||
} = left;
|
||||
const {
|
||||
secretDigest: _rightSecretDigest,
|
||||
createdAtMs: _rightCreatedAtMs,
|
||||
...rightSemantic
|
||||
} = right;
|
||||
return sameValue(leftSemantic, rightSemantic);
|
||||
}
|
||||
|
||||
function sameMutationForReplay(
|
||||
left: Readonly<ApiCredentialMutationRecord>,
|
||||
right: Readonly<ApiCredentialMutationRecord>,
|
||||
): boolean {
|
||||
const { createdAtMs: _leftCreatedAtMs, ...leftSemantic } = left;
|
||||
const { createdAtMs: _rightCreatedAtMs, ...rightSemantic } = right;
|
||||
return sameValue(leftSemantic, rightSemantic);
|
||||
}
|
||||
|
||||
async function resolveStoredMutation(
|
||||
queryable: Pick<PostgresClient, 'query'>,
|
||||
mutationId: string,
|
||||
): Promise<ResolvedApiCredentialMutation | null> {
|
||||
const result = await queryable.query<CredentialMutationRow>(MUTATION_SELECT, [
|
||||
mutationId,
|
||||
]);
|
||||
if (result.rows.length > 1) {
|
||||
throw new ApiCredentialAdministrationUnavailableError();
|
||||
}
|
||||
if (!result.rows[0]) return null;
|
||||
try {
|
||||
return Object.freeze(storedMutation(result.rows[0]));
|
||||
} catch {
|
||||
throw new ApiCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function replay(
|
||||
client: PostgresClient,
|
||||
command: Readonly<AppendApiCredentialCommand>,
|
||||
): Promise<AppendApiCredentialResult | null> {
|
||||
const stored = await resolveStoredMutation(
|
||||
client,
|
||||
command.mutation.mutationId,
|
||||
);
|
||||
if (!stored) return null;
|
||||
if (
|
||||
!sameCredentialForReplay(stored.credential, command.credential) ||
|
||||
!sameMutationForReplay(stored.mutation, command.mutation) ||
|
||||
!sameAdministrationReplayAudit(stored.audit, command.audit)
|
||||
) {
|
||||
throw new ApiCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credential: stored.credential,
|
||||
mutation: stored.mutation,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresApiCredentialAdministrationRepository
|
||||
implements ApiCredentialAdministrationRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL API credential administration pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMutation(
|
||||
requestedMutationId: string,
|
||||
): Promise<ResolvedApiCredentialMutation | null> {
|
||||
const mutationId =
|
||||
normalizeApiCredentialAdministrationMutationId(requestedMutationId);
|
||||
try {
|
||||
return await resolveStoredMutation(this.pool, mutationId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApiCredentialAdministrationMutationConflictError ||
|
||||
error instanceof ApiCredentialAdministrationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ApiCredentialAdministrationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
async append(
|
||||
input: AppendApiCredentialCommand,
|
||||
): Promise<AppendApiCredentialResult> {
|
||||
const command = normalizeAppendApiCredentialCommand(input);
|
||||
for (let attempt = 0; attempt < MAX_TRANSACTION_ATTEMPTS; attempt += 1) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch {
|
||||
throw new ApiCredentialAdministrationUnavailableError();
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[`ql3-api-credential:${command.credential.credentialId}`],
|
||||
);
|
||||
const existing = await replay(client, command);
|
||||
if (existing) {
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const subjectResult = await client.query<SubjectRow>(
|
||||
`SELECT status
|
||||
FROM "ql3"."identity_subjects"
|
||||
WHERE subject_type = $1 AND subject_id = $2
|
||||
FOR SHARE`,
|
||||
[command.credential.subject.type, command.credential.subject.id],
|
||||
);
|
||||
if (subjectResult.rows.length !== 1) {
|
||||
throw new ApiCredentialAdministrationSubjectNotFoundError();
|
||||
}
|
||||
const subjectStatus = requiredString(subjectResult.rows[0]!, 'status');
|
||||
if (
|
||||
subjectStatus !== command.credential.subjectStatus ||
|
||||
(command.mutation.operation !== 'revoke' &&
|
||||
subjectStatus !== 'active')
|
||||
) {
|
||||
throw new ApiCredentialAdministrationSubjectNotFoundError();
|
||||
}
|
||||
|
||||
const currentResult = await client.query<CurrentCredentialRow>(
|
||||
`SELECT
|
||||
credential_id AS "credentialId", version, state,
|
||||
subject_type AS "subjectType", subject_id AS "subjectId"
|
||||
FROM "ql3"."api_credentials"
|
||||
WHERE credential_id = $1
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[command.credential.credentialId],
|
||||
);
|
||||
if (currentResult.rows.length > 1) {
|
||||
throw new ApiCredentialAdministrationUnavailableError();
|
||||
}
|
||||
const current = currentResult.rows[0];
|
||||
const currentVersion = current
|
||||
? requiredInteger(current, 'version')
|
||||
: 0;
|
||||
if (currentVersion !== command.expectedCurrentVersion) {
|
||||
throw new ApiCredentialAdministrationVersionConflictError();
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
(requiredString(current, 'subjectType') !==
|
||||
command.credential.subject.type ||
|
||||
requiredString(current, 'subjectId') !==
|
||||
command.credential.subject.id)
|
||||
) {
|
||||
throw new ApiCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
|
||||
await insertAdministrationAudit(client, command.audit);
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."api_credentials" (
|
||||
credential_id, version, state, subject_type, subject_id,
|
||||
pepper_key_id, secret_digest, created_at_ms, not_before_at_ms,
|
||||
expires_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
command.credential.credentialId,
|
||||
command.credential.version,
|
||||
command.credential.state,
|
||||
command.credential.subject.type,
|
||||
command.credential.subject.id,
|
||||
command.credential.pepperKeyId,
|
||||
command.credential.secretDigest,
|
||||
command.credential.createdAtMs,
|
||||
command.credential.notBeforeAtMs,
|
||||
command.credential.expiresAtMs,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."api_credential_mutations" (
|
||||
mutation_id, operation, credential_id, credential_version,
|
||||
expected_previous_version, state, subject_type, subject_id,
|
||||
subject_status, changed_by_type, changed_by_id, audit_event_id,
|
||||
created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $1, $12)`,
|
||||
[
|
||||
command.mutation.mutationId,
|
||||
command.mutation.operation,
|
||||
command.mutation.credentialId,
|
||||
command.mutation.credentialVersion,
|
||||
command.mutation.expectedPreviousVersion,
|
||||
command.credential.state,
|
||||
command.credential.subject.type,
|
||||
command.credential.subject.id,
|
||||
command.credential.subjectStatus,
|
||||
command.mutation.changedBy.type,
|
||||
command.mutation.changedBy.id,
|
||||
command.mutation.createdAtMs,
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'inserted',
|
||||
credential: command.credential,
|
||||
mutation: command.mutation,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof ApiCredentialAdministrationSubjectNotFoundError ||
|
||||
error instanceof ApiCredentialAdministrationVersionConflictError ||
|
||||
error instanceof ApiCredentialAdministrationMutationConflictError ||
|
||||
error instanceof ApiCredentialAdministrationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
attempt < MAX_TRANSACTION_ATTEMPTS - 1 &&
|
||||
retryableAdministrationError(error)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw new ApiCredentialAdministrationUnavailableError();
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw new ApiCredentialAdministrationUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// PostgreSQL runtime authority for resolving API credentials and identities.
|
||||
import {
|
||||
ApiCredentialUnavailableError,
|
||||
assertApiCredentialId,
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRecord,
|
||||
type ApiCredentialRepository,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
interface ApiCredentialRow extends Record<string, unknown> {
|
||||
credentialId: unknown;
|
||||
version: unknown;
|
||||
state: unknown;
|
||||
subjectType: unknown;
|
||||
subjectId: unknown;
|
||||
subjectStatus: unknown;
|
||||
pepperKeyId: unknown;
|
||||
secretDigest: unknown;
|
||||
createdAtMs: unknown;
|
||||
notBeforeAtMs: unknown;
|
||||
expiresAtMs: unknown;
|
||||
}
|
||||
|
||||
const RESOLVE_SQL = `
|
||||
SELECT
|
||||
credential.credential_id AS "credentialId",
|
||||
credential.version,
|
||||
credential.state,
|
||||
credential.subject_type AS "subjectType",
|
||||
credential.subject_id AS "subjectId",
|
||||
subject.status AS "subjectStatus",
|
||||
credential.pepper_key_id AS "pepperKeyId",
|
||||
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 "ql3"."api_credentials" AS credential
|
||||
JOIN "ql3"."identity_subjects" AS subject
|
||||
ON subject.subject_type = credential.subject_type
|
||||
AND subject.subject_id = credential.subject_id
|
||||
WHERE credential.credential_id = $1
|
||||
ORDER BY credential.version DESC
|
||||
LIMIT 1
|
||||
`.trim();
|
||||
|
||||
function requiredString(row: ApiCredentialRow, name: string): string {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new ApiCredentialUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredInteger(row: ApiCredentialRow, name: string): number {
|
||||
const value = row[name];
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw new ApiCredentialUnavailableError();
|
||||
}
|
||||
|
||||
function recordFromRow(row: ApiCredentialRow): Readonly<ApiCredentialRecord> {
|
||||
try {
|
||||
return normalizeApiCredentialRecord({
|
||||
credentialId: requiredString(row, 'credentialId'),
|
||||
version: requiredInteger(row, 'version'),
|
||||
pepperKeyId: requiredString(row, 'pepperKeyId'),
|
||||
state: requiredString(row, 'state') as ApiCredentialRecord['state'],
|
||||
subject: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'subjectType',
|
||||
) as ApiCredentialRecord['subject']['type'],
|
||||
id: requiredString(row, 'subjectId'),
|
||||
},
|
||||
subjectStatus: requiredString(
|
||||
row,
|
||||
'subjectStatus',
|
||||
) as ApiCredentialRecord['subjectStatus'],
|
||||
secretDigest: requiredString(row, 'secretDigest'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
notBeforeAtMs: requiredInteger(row, 'notBeforeAtMs'),
|
||||
expiresAtMs: requiredInteger(row, 'expiresAtMs'),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiCredentialUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresApiCredentialRepository
|
||||
implements ApiCredentialRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new TypeError('PostgreSQL API credential pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(
|
||||
credentialId: string,
|
||||
): Promise<Readonly<ApiCredentialRecord> | null> {
|
||||
assertApiCredentialId(credentialId);
|
||||
try {
|
||||
const result = await this.pool.query<ApiCredentialRow>(RESOLVE_SQL, [
|
||||
credentialId,
|
||||
]);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw new ApiCredentialUnavailableError();
|
||||
return recordFromRow(result.rows[0]!);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiCredentialUnavailableError) throw error;
|
||||
throw new ApiCredentialUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// PostgreSQL administration authority for versioned identity mutations.
|
||||
import {
|
||||
IdentityAdministrationMutationConflictError,
|
||||
IdentityAdministrationUnavailableError,
|
||||
IdentityAdministrationVersionConflictError,
|
||||
normalizeAppendIdentitySubjectCommand,
|
||||
normalizeIdentityAdministrationMutationId,
|
||||
normalizeIdentityAdministrationSubject,
|
||||
normalizeIdentitySubjectRecord,
|
||||
type AppendIdentitySubjectCommand,
|
||||
type AppendIdentitySubjectResult,
|
||||
type IdentityAdministrationRepository,
|
||||
type IdentitySubjectMutationRecord,
|
||||
type IdentitySubjectRecord,
|
||||
type ResolvedIdentitySubjectMutation,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
ADMINISTRATION_AUDIT_SELECT,
|
||||
auditFromRow,
|
||||
configureAdministrationTransaction,
|
||||
insertAdministrationAudit,
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
retryableAdministrationError,
|
||||
rollbackAdministrationTransaction,
|
||||
sameAdministrationReplayAudit,
|
||||
type AdministrationAuditRow,
|
||||
} from '../repository/administrationSupport';
|
||||
|
||||
interface IdentityMutationRow extends AdministrationAuditRow {
|
||||
mutationId: unknown;
|
||||
operation: unknown;
|
||||
subjectType: unknown;
|
||||
subjectId: unknown;
|
||||
subjectVersion: unknown;
|
||||
expectedPreviousVersion: unknown;
|
||||
status: unknown;
|
||||
changedByType: unknown;
|
||||
changedById: unknown;
|
||||
identityCreatedAtMs: unknown;
|
||||
createdAtMs: unknown;
|
||||
}
|
||||
|
||||
interface IdentityRow extends Record<string, unknown> {
|
||||
subjectType: unknown;
|
||||
subjectId: unknown;
|
||||
status: unknown;
|
||||
version: unknown;
|
||||
createdAtMs: unknown;
|
||||
updatedAtMs: unknown;
|
||||
}
|
||||
|
||||
const MAX_TRANSACTION_ATTEMPTS = 3;
|
||||
const MUTATION_SELECT = `
|
||||
SELECT
|
||||
mutation.mutation_id AS "mutationId",
|
||||
mutation.operation,
|
||||
mutation.subject_type AS "subjectType",
|
||||
mutation.subject_id AS "subjectId",
|
||||
mutation.subject_version AS "subjectVersion",
|
||||
mutation.expected_previous_version AS "expectedPreviousVersion",
|
||||
mutation.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",
|
||||
${ADMINISTRATION_AUDIT_SELECT}
|
||||
FROM "ql3"."identity_subject_mutations" AS mutation
|
||||
JOIN "ql3"."security_audit_events" AS audit
|
||||
ON audit.event_id = mutation.audit_event_id
|
||||
WHERE mutation.mutation_id = $1
|
||||
LIMIT 2
|
||||
`.trim();
|
||||
|
||||
function identityFromRow(row: IdentityRow): Readonly<IdentitySubjectRecord> {
|
||||
return normalizeIdentitySubjectRecord({
|
||||
subject: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'subjectType',
|
||||
) as IdentitySubjectRecord['subject']['type'],
|
||||
id: requiredString(row, 'subjectId'),
|
||||
},
|
||||
status: requiredString(row, 'status') as IdentitySubjectRecord['status'],
|
||||
version: requiredInteger(row, 'version'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
updatedAtMs: requiredInteger(row, 'updatedAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
function storedMutation(row: IdentityMutationRow): {
|
||||
identity: Readonly<IdentitySubjectRecord>;
|
||||
mutation: Readonly<IdentitySubjectMutationRecord>;
|
||||
audit: ReturnType<typeof auditFromRow>;
|
||||
} {
|
||||
const audit = auditFromRow(row);
|
||||
const mutation: IdentitySubjectMutationRecord = {
|
||||
mutationId: requiredString(row, 'mutationId'),
|
||||
operation: requiredString(
|
||||
row,
|
||||
'operation',
|
||||
) as IdentitySubjectMutationRecord['operation'],
|
||||
subject: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'subjectType',
|
||||
) as IdentitySubjectMutationRecord['subject']['type'],
|
||||
id: requiredString(row, 'subjectId'),
|
||||
},
|
||||
subjectVersion: requiredInteger(row, 'subjectVersion'),
|
||||
expectedPreviousVersion: requiredInteger(row, 'expectedPreviousVersion'),
|
||||
status: requiredString(
|
||||
row,
|
||||
'status',
|
||||
) as IdentitySubjectMutationRecord['status'],
|
||||
changedBy: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'changedByType',
|
||||
) as IdentitySubjectMutationRecord['changedBy']['type'],
|
||||
id: requiredString(row, 'changedById'),
|
||||
},
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
};
|
||||
const normalized = normalizeAppendIdentitySubjectCommand({
|
||||
expectedCurrentVersion: mutation.expectedPreviousVersion,
|
||||
mutation,
|
||||
audit,
|
||||
});
|
||||
return {
|
||||
mutation: normalized.mutation,
|
||||
audit: normalized.audit,
|
||||
identity: normalizeIdentitySubjectRecord({
|
||||
subject: normalized.mutation.subject,
|
||||
status: normalized.mutation.status,
|
||||
version: normalized.mutation.subjectVersion,
|
||||
createdAtMs: requiredInteger(row, 'identityCreatedAtMs'),
|
||||
updatedAtMs: normalized.mutation.createdAtMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function sameMutation(
|
||||
left: Readonly<IdentitySubjectMutationRecord>,
|
||||
right: Readonly<IdentitySubjectMutationRecord>,
|
||||
): boolean {
|
||||
const { createdAtMs: _leftCreatedAtMs, ...leftSemantic } = left;
|
||||
const { createdAtMs: _rightCreatedAtMs, ...rightSemantic } = right;
|
||||
return JSON.stringify(leftSemantic) === JSON.stringify(rightSemantic);
|
||||
}
|
||||
|
||||
async function resolveStoredMutation(
|
||||
queryable: Pick<PostgresClient, 'query'>,
|
||||
mutationId: string,
|
||||
): Promise<ResolvedIdentitySubjectMutation | null> {
|
||||
const result = await queryable.query<IdentityMutationRow>(MUTATION_SELECT, [
|
||||
mutationId,
|
||||
]);
|
||||
if (result.rows.length > 1)
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
if (!result.rows[0]) return null;
|
||||
try {
|
||||
return Object.freeze(storedMutation(result.rows[0]));
|
||||
} catch {
|
||||
throw new IdentityAdministrationMutationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function replay(
|
||||
client: PostgresClient,
|
||||
command: Readonly<AppendIdentitySubjectCommand>,
|
||||
): Promise<AppendIdentitySubjectResult | null> {
|
||||
const stored = await resolveStoredMutation(
|
||||
client,
|
||||
command.mutation.mutationId,
|
||||
);
|
||||
if (!stored) return null;
|
||||
if (
|
||||
!sameMutation(stored.mutation, command.mutation) ||
|
||||
!sameAdministrationReplayAudit(stored.audit, command.audit)
|
||||
) {
|
||||
throw new IdentityAdministrationMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
identity: stored.identity,
|
||||
mutation: stored.mutation,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresIdentityAdministrationRepository
|
||||
implements IdentityAdministrationRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL Identity administration pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(
|
||||
requestedSubject: Parameters<
|
||||
IdentityAdministrationRepository['resolve']
|
||||
>[0],
|
||||
): Promise<Readonly<IdentitySubjectRecord> | null> {
|
||||
const subject = normalizeIdentityAdministrationSubject(requestedSubject);
|
||||
try {
|
||||
const result = await this.pool.query<IdentityRow>(
|
||||
`SELECT
|
||||
subject_type AS "subjectType", subject_id AS "subjectId", status,
|
||||
version, created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs"
|
||||
FROM "ql3"."identity_subjects"
|
||||
WHERE subject_type = $1 AND subject_id = $2
|
||||
LIMIT 2`,
|
||||
[subject.type, subject.id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) {
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
}
|
||||
return identityFromRow(result.rows[0]!);
|
||||
} catch (error) {
|
||||
if (error instanceof IdentityAdministrationUnavailableError) throw error;
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMutation(
|
||||
requestedMutationId: string,
|
||||
): Promise<ResolvedIdentitySubjectMutation | null> {
|
||||
const mutationId =
|
||||
normalizeIdentityAdministrationMutationId(requestedMutationId);
|
||||
try {
|
||||
return await resolveStoredMutation(this.pool, mutationId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof IdentityAdministrationMutationConflictError ||
|
||||
error instanceof IdentityAdministrationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
async append(
|
||||
input: AppendIdentitySubjectCommand,
|
||||
): Promise<AppendIdentitySubjectResult> {
|
||||
const command = normalizeAppendIdentitySubjectCommand(input);
|
||||
for (let attempt = 0; attempt < MAX_TRANSACTION_ATTEMPTS; attempt += 1) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch {
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[
|
||||
`ql3-identity:${command.mutation.subject.type}:${command.mutation.subject.id}`,
|
||||
],
|
||||
);
|
||||
const existing = await replay(client, command);
|
||||
if (existing) {
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const currentResult = await client.query<IdentityRow>(
|
||||
`SELECT
|
||||
subject_type AS "subjectType", subject_id AS "subjectId", status,
|
||||
version, created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs"
|
||||
FROM "ql3"."identity_subjects"
|
||||
WHERE subject_type = $1 AND subject_id = $2
|
||||
FOR UPDATE`,
|
||||
[command.mutation.subject.type, command.mutation.subject.id],
|
||||
);
|
||||
if (currentResult.rows.length > 1) {
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
}
|
||||
const current = currentResult.rows[0]
|
||||
? identityFromRow(currentResult.rows[0])
|
||||
: null;
|
||||
if ((current?.version ?? 0) !== command.expectedCurrentVersion) {
|
||||
throw new IdentityAdministrationVersionConflictError();
|
||||
}
|
||||
|
||||
await insertAdministrationAudit(client, command.audit);
|
||||
let identity: Readonly<IdentitySubjectRecord>;
|
||||
if (current === null) {
|
||||
const inserted = await client.query<IdentityRow>(
|
||||
`INSERT INTO "ql3"."identity_subjects" (
|
||||
subject_type, subject_id, status, version, created_at_ms,
|
||||
updated_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $5)
|
||||
RETURNING subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
status, version, created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs"`,
|
||||
[
|
||||
command.mutation.subject.type,
|
||||
command.mutation.subject.id,
|
||||
command.mutation.status,
|
||||
command.mutation.subjectVersion,
|
||||
command.mutation.createdAtMs,
|
||||
],
|
||||
);
|
||||
if (inserted.rows.length !== 1) {
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
}
|
||||
identity = identityFromRow(inserted.rows[0]!);
|
||||
} else {
|
||||
const updated = await client.query<IdentityRow>(
|
||||
`UPDATE "ql3"."identity_subjects"
|
||||
SET status = $3, version = $4, updated_at_ms = $5
|
||||
WHERE subject_type = $1 AND subject_id = $2 AND version = $6
|
||||
RETURNING subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
status, version, created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs"`,
|
||||
[
|
||||
command.mutation.subject.type,
|
||||
command.mutation.subject.id,
|
||||
command.mutation.status,
|
||||
command.mutation.subjectVersion,
|
||||
command.mutation.createdAtMs,
|
||||
command.expectedCurrentVersion,
|
||||
],
|
||||
);
|
||||
if (updated.rows.length !== 1) {
|
||||
throw new IdentityAdministrationVersionConflictError();
|
||||
}
|
||||
identity = identityFromRow(updated.rows[0]!);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."identity_subject_mutations" (
|
||||
mutation_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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $1, $10, $11)`,
|
||||
[
|
||||
command.mutation.mutationId,
|
||||
command.mutation.operation,
|
||||
command.mutation.subject.type,
|
||||
command.mutation.subject.id,
|
||||
command.mutation.subjectVersion,
|
||||
command.mutation.expectedPreviousVersion,
|
||||
command.mutation.status,
|
||||
command.mutation.changedBy.type,
|
||||
command.mutation.changedBy.id,
|
||||
identity.createdAtMs,
|
||||
command.mutation.createdAtMs,
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'inserted',
|
||||
identity,
|
||||
mutation: command.mutation,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof IdentityAdministrationVersionConflictError ||
|
||||
error instanceof IdentityAdministrationMutationConflictError ||
|
||||
error instanceof IdentityAdministrationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
attempt < MAX_TRANSACTION_ATTEMPTS - 1 &&
|
||||
retryableAdministrationError(error)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw new IdentityAdministrationUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
// PostgreSQL authority for project policy snapshots and role bindings.
|
||||
import {
|
||||
ProjectPolicyProjectNotFoundError,
|
||||
ProjectPolicyUnavailableError,
|
||||
ProjectRoleBindingMutationConflictError,
|
||||
ProjectRoleBindingVersionConflictError,
|
||||
assertProjectPolicyProjectId,
|
||||
assertExpectedProjectRoleBindingVersion,
|
||||
normalizeProjectPolicySnapshot,
|
||||
normalizeProjectPolicySubject,
|
||||
normalizeProjectRoleBinding,
|
||||
type AppendProjectRoleBindingCommand,
|
||||
type AppendProjectRoleBindingResult,
|
||||
type ProjectPolicyRepository,
|
||||
type ProjectPolicySnapshot,
|
||||
type ProjectRoleBindingRecord,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
PostgresQueryResult,
|
||||
} from '@qinglong/runtime-core';
|
||||
|
||||
type QueryRow = Record<string, unknown>;
|
||||
|
||||
interface SnapshotRow extends QueryRow {
|
||||
projectId: unknown;
|
||||
projectName: unknown;
|
||||
projectSlug: unknown;
|
||||
projectStatus: unknown;
|
||||
projectVersion: unknown;
|
||||
projectCreatedAtMs: unknown;
|
||||
projectUpdatedAtMs: unknown;
|
||||
bindingProjectId: unknown;
|
||||
bindingSubjectType: unknown;
|
||||
bindingSubjectId: unknown;
|
||||
bindingVersion: unknown;
|
||||
bindingState: unknown;
|
||||
bindingRole: unknown;
|
||||
bindingMutationId: unknown;
|
||||
bindingChangedByType: unknown;
|
||||
bindingChangedById: unknown;
|
||||
bindingCreatedAtMs: unknown;
|
||||
}
|
||||
|
||||
interface BindingRow extends QueryRow {
|
||||
projectId: unknown;
|
||||
subjectType: unknown;
|
||||
subjectId: unknown;
|
||||
version: unknown;
|
||||
state: unknown;
|
||||
role: unknown;
|
||||
mutationId: unknown;
|
||||
changedByType: unknown;
|
||||
changedById: unknown;
|
||||
createdAtMs: unknown;
|
||||
}
|
||||
|
||||
const MAX_TRANSACTION_ATTEMPTS = 3;
|
||||
const RETRYABLE_SQL_STATES = new Set(['40001', '40P01', '55P03']);
|
||||
const SNAPSHOT_SQL = `
|
||||
SELECT
|
||||
project.id AS "projectId",
|
||||
project.name AS "projectName",
|
||||
project.slug AS "projectSlug",
|
||||
project.status AS "projectStatus",
|
||||
project.version AS "projectVersion",
|
||||
project.created_at_ms AS "projectCreatedAtMs",
|
||||
project.updated_at_ms AS "projectUpdatedAtMs",
|
||||
binding.project_id AS "bindingProjectId",
|
||||
binding.subject_type AS "bindingSubjectType",
|
||||
binding.subject_id AS "bindingSubjectId",
|
||||
binding.version AS "bindingVersion",
|
||||
binding.state AS "bindingState",
|
||||
binding.role AS "bindingRole",
|
||||
binding.mutation_id AS "bindingMutationId",
|
||||
binding.changed_by_type AS "bindingChangedByType",
|
||||
binding.changed_by_id AS "bindingChangedById",
|
||||
binding.created_at_ms AS "bindingCreatedAtMs"
|
||||
FROM "ql3"."projects" AS project
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM "ql3"."project_role_bindings" AS candidate
|
||||
WHERE candidate.project_id = project.id
|
||||
AND candidate.subject_type = $2
|
||||
AND candidate.subject_id = $3
|
||||
ORDER BY candidate.version DESC
|
||||
LIMIT 1
|
||||
) AS binding ON TRUE
|
||||
WHERE project.id = $1
|
||||
LIMIT 2
|
||||
`.trim();
|
||||
const BINDING_SELECT = `
|
||||
SELECT
|
||||
project_id AS "projectId",
|
||||
subject_type AS "subjectType",
|
||||
subject_id AS "subjectId",
|
||||
version,
|
||||
state,
|
||||
role,
|
||||
mutation_id AS "mutationId",
|
||||
changed_by_type AS "changedByType",
|
||||
changed_by_id AS "changedById",
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "ql3"."project_role_bindings"
|
||||
`.trim();
|
||||
|
||||
function requiredString(row: QueryRow, name: string): string {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredInteger(row: QueryRow, name: string): number {
|
||||
const value = row[name];
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
|
||||
function optionalString(row: QueryRow, name: string): string | undefined {
|
||||
const value = row[name];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return requiredString(row, name);
|
||||
}
|
||||
|
||||
function bindingFromRow(row: BindingRow): Readonly<ProjectRoleBindingRecord> {
|
||||
try {
|
||||
const role = optionalString(row, 'role');
|
||||
return normalizeProjectRoleBinding({
|
||||
projectId: requiredString(row, 'projectId'),
|
||||
subject: {
|
||||
type: requiredString(
|
||||
row,
|
||||
'subjectType',
|
||||
) as ProjectRoleBindingRecord['subject']['type'],
|
||||
id: requiredString(row, 'subjectId'),
|
||||
},
|
||||
version: requiredInteger(row, 'version'),
|
||||
state: requiredString(row, 'state') as ProjectRoleBindingRecord['state'],
|
||||
...(role
|
||||
? { role: 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'),
|
||||
});
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotFromRow(row: SnapshotRow): Readonly<ProjectPolicySnapshot> {
|
||||
const bindingValues = [
|
||||
row.bindingProjectId,
|
||||
row.bindingSubjectType,
|
||||
row.bindingSubjectId,
|
||||
row.bindingVersion,
|
||||
row.bindingState,
|
||||
row.bindingMutationId,
|
||||
row.bindingChangedByType,
|
||||
row.bindingChangedById,
|
||||
row.bindingCreatedAtMs,
|
||||
];
|
||||
const noBinding = bindingValues.every((value) => value === null);
|
||||
if (!noBinding && bindingValues.some((value) => value === null)) {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
try {
|
||||
return normalizeProjectPolicySnapshot({
|
||||
project: {
|
||||
id: requiredString(row, 'projectId'),
|
||||
name: requiredString(row, 'projectName'),
|
||||
slug: requiredString(row, 'projectSlug'),
|
||||
status: requiredString(
|
||||
row,
|
||||
'projectStatus',
|
||||
) as ProjectPolicySnapshot['project']['status'],
|
||||
version: requiredInteger(row, 'projectVersion'),
|
||||
createdAtMs: requiredInteger(row, 'projectCreatedAtMs'),
|
||||
updatedAtMs: requiredInteger(row, 'projectUpdatedAtMs'),
|
||||
},
|
||||
...(noBinding
|
||||
? {}
|
||||
: {
|
||||
binding: bindingFromRow({
|
||||
projectId: row.bindingProjectId,
|
||||
subjectType: row.bindingSubjectType,
|
||||
subjectId: row.bindingSubjectId,
|
||||
version: row.bindingVersion,
|
||||
state: row.bindingState,
|
||||
role: row.bindingRole,
|
||||
mutationId: row.bindingMutationId,
|
||||
changedByType: row.bindingChangedByType,
|
||||
changedById: row.bindingChangedById,
|
||||
createdAtMs: row.bindingCreatedAtMs,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function sameBinding(
|
||||
left: Readonly<ProjectRoleBindingRecord>,
|
||||
right: Readonly<ProjectRoleBindingRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectId === right.projectId &&
|
||||
left.subject.type === right.subject.type &&
|
||||
left.subject.id === right.subject.id &&
|
||||
left.version === right.version &&
|
||||
left.state === right.state &&
|
||||
left.role === right.role &&
|
||||
left.mutationId === right.mutationId &&
|
||||
left.changedBy.type === right.changedBy.type &&
|
||||
left.changedBy.id === right.changedBy.id &&
|
||||
left.createdAtMs === right.createdAtMs
|
||||
);
|
||||
}
|
||||
|
||||
function sqlState(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
async function configureTransaction(client: PostgresClient): Promise<void> {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(`SELECT set_config('statement_timeout', $1, true)`, [
|
||||
'5000ms',
|
||||
]);
|
||||
await client.query(`SELECT set_config('lock_timeout', $1, true)`, ['1000ms']);
|
||||
await client.query(
|
||||
`SELECT set_config('idle_in_transaction_session_timeout', $1, true)`,
|
||||
['10000ms'],
|
||||
);
|
||||
}
|
||||
|
||||
async function rollback(client: PostgresClient): Promise<void> {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the primary transaction failure; release discards broken clients.
|
||||
}
|
||||
}
|
||||
|
||||
/** PostgreSQL implementation of the shared append-only Project Policy port. */
|
||||
export class PostgresProjectPolicyRepository
|
||||
implements ProjectPolicyRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL Project Policy pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(
|
||||
projectId: string,
|
||||
requestedSubject: Parameters<ProjectPolicyRepository['resolve']>[1],
|
||||
): Promise<Readonly<ProjectPolicySnapshot> | null> {
|
||||
assertProjectPolicyProjectId(projectId);
|
||||
const subject = normalizeProjectPolicySubject(requestedSubject);
|
||||
let result: PostgresQueryResult<SnapshotRow>;
|
||||
try {
|
||||
result = await this.pool.query<SnapshotRow>(SNAPSHOT_SQL, [
|
||||
projectId,
|
||||
subject.type,
|
||||
subject.id,
|
||||
]);
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw new ProjectPolicyUnavailableError();
|
||||
return snapshotFromRow(result.rows[0]!);
|
||||
}
|
||||
|
||||
async append(
|
||||
command: AppendProjectRoleBindingCommand,
|
||||
): Promise<AppendProjectRoleBindingResult> {
|
||||
if (!command || typeof command !== 'object' || Array.isArray(command)) {
|
||||
throw new TypeError('Project role binding command is invalid');
|
||||
}
|
||||
assertExpectedProjectRoleBindingVersion(command.expectedCurrentVersion);
|
||||
const binding = normalizeProjectRoleBinding(command.binding);
|
||||
if (binding.version !== command.expectedCurrentVersion + 1) {
|
||||
throw new ProjectRoleBindingVersionConflictError();
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < MAX_TRANSACTION_ATTEMPTS; attempt += 1) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configureTransaction(client);
|
||||
began = true;
|
||||
const project = await client.query(
|
||||
'SELECT id FROM "ql3"."projects" WHERE id = $1 FOR UPDATE',
|
||||
[binding.projectId],
|
||||
);
|
||||
if (project.rows.length === 0) {
|
||||
throw new ProjectPolicyProjectNotFoundError();
|
||||
}
|
||||
if (project.rows.length !== 1)
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
|
||||
const replay = await client.query<BindingRow>(
|
||||
`${BINDING_SELECT} WHERE project_id = $1 AND mutation_id = $2 LIMIT 2`,
|
||||
[binding.projectId, binding.mutationId],
|
||||
);
|
||||
if (replay.rows.length > 1) throw new ProjectPolicyUnavailableError();
|
||||
if (replay.rows[0]) {
|
||||
const previous = bindingFromRow(replay.rows[0]);
|
||||
if (!sameBinding(previous, binding)) {
|
||||
throw new ProjectRoleBindingMutationConflictError();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing', binding: previous });
|
||||
}
|
||||
|
||||
const current = await client.query<{ version: unknown }>(
|
||||
`SELECT version FROM "ql3"."project_role_bindings"
|
||||
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3
|
||||
ORDER BY version DESC LIMIT 1`,
|
||||
[binding.projectId, binding.subject.type, binding.subject.id],
|
||||
);
|
||||
if (current.rows.length > 1) throw new ProjectPolicyUnavailableError();
|
||||
const currentVersion = current.rows[0]
|
||||
? requiredInteger(current.rows[0], 'version')
|
||||
: 0;
|
||||
if (currentVersion !== command.expectedCurrentVersion) {
|
||||
throw new ProjectRoleBindingVersionConflictError();
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."project_role_bindings" (
|
||||
project_id, subject_type, subject_id, version, state, role,
|
||||
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
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,
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'inserted', binding });
|
||||
} catch (error) {
|
||||
if (began) await rollback(client);
|
||||
if (
|
||||
error instanceof ProjectPolicyProjectNotFoundError ||
|
||||
error instanceof ProjectRoleBindingVersionConflictError ||
|
||||
error instanceof ProjectRoleBindingMutationConflictError ||
|
||||
error instanceof ProjectPolicyUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
attempt < MAX_TRANSACTION_ATTEMPTS - 1 &&
|
||||
(RETRYABLE_SQL_STATES.has(sqlState(error) ?? '') ||
|
||||
sqlState(error) === '23505')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// PostgreSQL administration authority for querying security audit events.
|
||||
import {
|
||||
SecurityAuditQueryUnavailableError,
|
||||
normalizeSecurityAuditQuery,
|
||||
type SecurityAuditQuery,
|
||||
type SecurityAuditQueryPage,
|
||||
type SecurityAuditQueryRepository,
|
||||
} from '@qinglong/runtime-core/security-audit-query';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
type AdministrationRow,
|
||||
} from '../repository/administrationSupport';
|
||||
|
||||
interface AuditRow extends AdministrationRow {
|
||||
eventId: unknown;
|
||||
requestId: unknown;
|
||||
operationId: unknown;
|
||||
projectId: unknown;
|
||||
subjectType: unknown;
|
||||
subjectId: unknown;
|
||||
authenticationId: unknown;
|
||||
outcome: unknown;
|
||||
reasons: unknown;
|
||||
projectVersion: unknown;
|
||||
bindingVersion: unknown;
|
||||
occurredAtMs: unknown;
|
||||
}
|
||||
|
||||
function optionalString(row: AuditRow, name: string): string | null {
|
||||
const value = row[name];
|
||||
if (value === null) return null;
|
||||
return requiredString(row, name);
|
||||
}
|
||||
|
||||
function optionalInteger(row: AuditRow, name: string): number | null {
|
||||
const value = row[name];
|
||||
if (value === null) return null;
|
||||
return requiredInteger(row, name);
|
||||
}
|
||||
|
||||
function recordFromRow(row: AuditRow): Readonly<SecurityAuditRecord> {
|
||||
const subjectType = optionalString(row, 'subjectType');
|
||||
const subjectId = optionalString(row, 'subjectId');
|
||||
if ((subjectType === null) !== (subjectId === null)) {
|
||||
throw new SecurityAuditQueryUnavailableError();
|
||||
}
|
||||
const projectVersion = optionalInteger(row, 'projectVersion');
|
||||
const bindingVersion = optionalInteger(row, 'bindingVersion');
|
||||
if (!Array.isArray(row.reasons)) {
|
||||
throw new SecurityAuditQueryUnavailableError();
|
||||
}
|
||||
try {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: requiredString(row, 'eventId'),
|
||||
requestId: requiredString(row, 'requestId'),
|
||||
operationId: requiredString(row, 'operationId'),
|
||||
projectId: optionalString(row, 'projectId'),
|
||||
subject:
|
||||
subjectType === null
|
||||
? null
|
||||
: {
|
||||
type: subjectType as NonNullable<
|
||||
SecurityAuditRecord['subject']
|
||||
>['type'],
|
||||
id: subjectId!,
|
||||
},
|
||||
authenticationId: optionalString(row, 'authenticationId'),
|
||||
outcome: requiredString(row, 'outcome') as SecurityAuditRecord['outcome'],
|
||||
reasons: row.reasons as string[],
|
||||
fence:
|
||||
projectVersion === null ? null : { projectVersion, bindingVersion },
|
||||
occurredAtMs: requiredInteger(row, 'occurredAtMs'),
|
||||
});
|
||||
} catch {
|
||||
throw new SecurityAuditQueryUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresSecurityAuditQueryRepository
|
||||
implements SecurityAuditQueryRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new TypeError('PostgreSQL security audit query pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async list(input: SecurityAuditQuery): Promise<SecurityAuditQueryPage> {
|
||||
const query = normalizeSecurityAuditQuery(input);
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
const parameter = (value: unknown): string => {
|
||||
values.push(value);
|
||||
return `$${values.length}`;
|
||||
};
|
||||
if (query.before) {
|
||||
const occurred = parameter(query.before.occurredAtMs);
|
||||
const event = parameter(query.before.eventId);
|
||||
conditions.push(
|
||||
`(occurred_at_ms, event_id) < (${occurred}, ${event}::uuid)`,
|
||||
);
|
||||
}
|
||||
if (query.filter.projectId !== undefined) {
|
||||
conditions.push(`project_id = ${parameter(query.filter.projectId)}`);
|
||||
}
|
||||
if (query.filter.subject !== undefined) {
|
||||
conditions.push(
|
||||
`subject_type = ${parameter(
|
||||
query.filter.subject.type,
|
||||
)} AND subject_id = ${parameter(query.filter.subject.id)}`,
|
||||
);
|
||||
}
|
||||
if (query.filter.outcome !== undefined) {
|
||||
conditions.push(`outcome = ${parameter(query.filter.outcome)}`);
|
||||
}
|
||||
const limit = parameter(query.limit);
|
||||
let result;
|
||||
try {
|
||||
result = await this.pool.query<AuditRow>(
|
||||
`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, reasons,
|
||||
project_version AS "projectVersion",
|
||||
binding_version AS "bindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
${conditions.length === 0 ? '' : `WHERE ${conditions.join(' AND ')}`}
|
||||
ORDER BY occurred_at_ms DESC, event_id DESC
|
||||
LIMIT ${limit}`,
|
||||
values,
|
||||
);
|
||||
} catch {
|
||||
throw new SecurityAuditQueryUnavailableError();
|
||||
}
|
||||
let records: readonly Readonly<SecurityAuditRecord>[];
|
||||
try {
|
||||
records = Object.freeze(result.rows.map(recordFromRow));
|
||||
} catch {
|
||||
throw new SecurityAuditQueryUnavailableError();
|
||||
}
|
||||
const last = records.at(-1);
|
||||
return Object.freeze({
|
||||
records,
|
||||
nextCursor:
|
||||
records.length === query.limit && last
|
||||
? Object.freeze({
|
||||
occurredAtMs: last.occurredAtMs,
|
||||
eventId: last.eventId,
|
||||
})
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// PostgreSQL runtime sink for immutable security audit events.
|
||||
import {
|
||||
SecurityAuditUnavailableError,
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
const INSERT_SQL = `
|
||||
INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id,
|
||||
request_id,
|
||||
operation_id,
|
||||
project_id,
|
||||
subject_type,
|
||||
subject_id,
|
||||
authentication_id,
|
||||
outcome,
|
||||
reasons,
|
||||
project_version,
|
||||
binding_version,
|
||||
occurred_at_ms
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)
|
||||
`.trim();
|
||||
|
||||
export class PostgresSecurityAuditRepository implements SecurityAuditSink {
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new TypeError('PostgreSQL security audit pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async record(value: SecurityAuditRecord): Promise<void> {
|
||||
let record: Readonly<SecurityAuditRecord>;
|
||||
try {
|
||||
record = normalizeSecurityAuditRecord(value);
|
||||
} catch {
|
||||
throw new SecurityAuditUnavailableError();
|
||||
}
|
||||
try {
|
||||
await this.pool.query(INSERT_SQL, [
|
||||
record.eventId,
|
||||
record.requestId,
|
||||
record.operationId,
|
||||
record.projectId,
|
||||
record.subject?.type ?? null,
|
||||
record.subject?.id ?? null,
|
||||
record.authenticationId,
|
||||
record.outcome,
|
||||
JSON.stringify(record.reasons),
|
||||
record.fence?.projectVersion ?? null,
|
||||
record.fence?.bindingVersion ?? null,
|
||||
record.occurredAtMs,
|
||||
]);
|
||||
} catch {
|
||||
throw new SecurityAuditUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user