mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { SECURITY_SUBJECT_TYPES, type SecuritySubject } from '../security';
|
||||
|
||||
export const API_CREDENTIAL_STATES = ['active', 'revoked'] as const;
|
||||
export const API_CREDENTIAL_SUBJECT_TYPES = [
|
||||
'user',
|
||||
'api_app',
|
||||
'mcp_client',
|
||||
'agent',
|
||||
] as const;
|
||||
export const API_CREDENTIAL_SUBJECT_STATUSES = ['active', 'disabled'] as const;
|
||||
|
||||
export type ApiCredentialState = (typeof API_CREDENTIAL_STATES)[number];
|
||||
export type ApiCredentialSubjectType =
|
||||
(typeof API_CREDENTIAL_SUBJECT_TYPES)[number];
|
||||
export type ApiCredentialSubjectStatus =
|
||||
(typeof API_CREDENTIAL_SUBJECT_STATUSES)[number];
|
||||
|
||||
export interface ApiCredentialRecord {
|
||||
readonly credentialId: string;
|
||||
readonly version: number;
|
||||
readonly pepperKeyId: string;
|
||||
readonly state: ApiCredentialState;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly subjectStatus: ApiCredentialSubjectStatus;
|
||||
readonly secretDigest: string;
|
||||
readonly createdAtMs: number;
|
||||
readonly notBeforeAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface ApiCredentialRepository {
|
||||
resolve(credentialId: string): Promise<Readonly<ApiCredentialRecord> | null>;
|
||||
}
|
||||
|
||||
export class InvalidApiCredentialValueError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`API credential value is invalid: ${message}`);
|
||||
this.name = 'InvalidApiCredentialValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiCredentialUnavailableError extends Error {
|
||||
readonly code = 'API_CREDENTIAL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('API credential storage is unavailable');
|
||||
this.name = 'ApiCredentialUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const CREDENTIAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
||||
const PEPPER_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
||||
const SUBJECT_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const SECRET_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const MAX_VERSION = 2_147_483_647;
|
||||
|
||||
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 InvalidApiCredentialValueError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(name: string, value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new InvalidApiCredentialValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function assertApiCredentialId(value: string): void {
|
||||
if (typeof value !== 'string' || !CREDENTIAL_ID_PATTERN.test(value)) {
|
||||
throw new InvalidApiCredentialValueError('credentialId is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export const LEGACY_API_CREDENTIAL_PEPPER_KEY_ID = 'legacy-v1';
|
||||
|
||||
export function assertApiCredentialPepperKeyId(value: string): void {
|
||||
if (typeof value !== 'string' || !PEPPER_KEY_ID_PATTERN.test(value)) {
|
||||
throw new InvalidApiCredentialValueError('pepperKeyId is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApiCredentialRecord(
|
||||
value: ApiCredentialRecord,
|
||||
): Readonly<ApiCredentialRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidApiCredentialValueError('record must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'credentialId',
|
||||
'version',
|
||||
'pepperKeyId',
|
||||
'state',
|
||||
'subject',
|
||||
'subjectStatus',
|
||||
'secretDigest',
|
||||
'createdAtMs',
|
||||
'notBeforeAtMs',
|
||||
'expiresAtMs',
|
||||
],
|
||||
'record',
|
||||
);
|
||||
assertApiCredentialId(value.credentialId);
|
||||
assertApiCredentialPepperKeyId(value.pepperKeyId);
|
||||
if (
|
||||
!Number.isSafeInteger(value.version) ||
|
||||
value.version < 1 ||
|
||||
value.version > MAX_VERSION
|
||||
) {
|
||||
throw new InvalidApiCredentialValueError('version is invalid');
|
||||
}
|
||||
if (!API_CREDENTIAL_STATES.includes(value.state)) {
|
||||
throw new InvalidApiCredentialValueError('state is invalid');
|
||||
}
|
||||
if (
|
||||
!value.subject ||
|
||||
typeof value.subject !== 'object' ||
|
||||
Array.isArray(value.subject)
|
||||
) {
|
||||
throw new InvalidApiCredentialValueError('subject is invalid');
|
||||
}
|
||||
exactKeys(value.subject, ['type', 'id'], 'subject');
|
||||
if (
|
||||
!SECURITY_SUBJECT_TYPES.includes(value.subject.type) ||
|
||||
!API_CREDENTIAL_SUBJECT_TYPES.includes(
|
||||
value.subject.type as ApiCredentialSubjectType,
|
||||
) ||
|
||||
typeof value.subject.id !== 'string' ||
|
||||
value.subject.id.length < 1 ||
|
||||
value.subject.id.length > 255 ||
|
||||
SUBJECT_ID_CONTROL_PATTERN.test(value.subject.id)
|
||||
) {
|
||||
throw new InvalidApiCredentialValueError('subject is invalid');
|
||||
}
|
||||
if (!API_CREDENTIAL_SUBJECT_STATUSES.includes(value.subjectStatus)) {
|
||||
throw new InvalidApiCredentialValueError('subjectStatus is invalid');
|
||||
}
|
||||
if (!SECRET_DIGEST_PATTERN.test(value.secretDigest)) {
|
||||
throw new InvalidApiCredentialValueError('secretDigest is invalid');
|
||||
}
|
||||
const createdAtMs = timestamp('createdAtMs', value.createdAtMs);
|
||||
const notBeforeAtMs = timestamp('notBeforeAtMs', value.notBeforeAtMs);
|
||||
const expiresAtMs = timestamp('expiresAtMs', value.expiresAtMs);
|
||||
if (notBeforeAtMs < createdAtMs || expiresAtMs <= notBeforeAtMs) {
|
||||
throw new InvalidApiCredentialValueError('lifetime is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
credentialId: value.credentialId,
|
||||
version: value.version,
|
||||
pepperKeyId: value.pepperKeyId,
|
||||
state: value.state,
|
||||
subject: Object.freeze({
|
||||
type: value.subject.type,
|
||||
id: value.subject.id,
|
||||
}),
|
||||
subjectStatus: value.subjectStatus,
|
||||
secretDigest: value.secretDigest,
|
||||
createdAtMs,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
});
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRecord,
|
||||
} from './apiCredential';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '../audit/securityAudit';
|
||||
import type { SecuritySubject } from '../security';
|
||||
|
||||
export const API_CREDENTIAL_ADMINISTRATION_OPERATIONS = [
|
||||
'issue',
|
||||
'rotate',
|
||||
'revoke',
|
||||
] as const;
|
||||
export const REVOKED_API_CREDENTIAL_DIGEST = '0'.repeat(64);
|
||||
|
||||
export type ApiCredentialAdministrationOperation =
|
||||
(typeof API_CREDENTIAL_ADMINISTRATION_OPERATIONS)[number];
|
||||
|
||||
export interface ApiCredentialMutationRecord {
|
||||
readonly mutationId: string;
|
||||
readonly operation: ApiCredentialAdministrationOperation;
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
readonly expectedPreviousVersion: number;
|
||||
readonly changedBy: SecuritySubject;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendApiCredentialCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly credential: ApiCredentialRecord;
|
||||
readonly mutation: ApiCredentialMutationRecord;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendApiCredentialResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly credential: Readonly<ApiCredentialRecord>;
|
||||
readonly mutation: Readonly<ApiCredentialMutationRecord>;
|
||||
}
|
||||
|
||||
export interface ResolvedApiCredentialMutation {
|
||||
readonly credential: Readonly<ApiCredentialRecord>;
|
||||
readonly mutation: Readonly<ApiCredentialMutationRecord>;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface ApiCredentialAdministrationRepository {
|
||||
resolveMutation(
|
||||
mutationId: string,
|
||||
): Promise<ResolvedApiCredentialMutation | null>;
|
||||
append(
|
||||
command: AppendApiCredentialCommand,
|
||||
): Promise<AppendApiCredentialResult>;
|
||||
}
|
||||
|
||||
export class InvalidApiCredentialAdministrationValueError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`API credential administration value is invalid: ${message}`);
|
||||
this.name = 'InvalidApiCredentialAdministrationValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiCredentialAdministrationSubjectNotFoundError extends Error {
|
||||
readonly code = 'API_CREDENTIAL_ADMINISTRATION_SUBJECT_NOT_FOUND';
|
||||
|
||||
constructor() {
|
||||
super('API credential administration subject was not found');
|
||||
this.name = 'ApiCredentialAdministrationSubjectNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiCredentialAdministrationVersionConflictError extends Error {
|
||||
readonly code = 'API_CREDENTIAL_ADMINISTRATION_VERSION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('API credential administration version conflict');
|
||||
this.name = 'ApiCredentialAdministrationVersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiCredentialAdministrationMutationConflictError extends Error {
|
||||
readonly code = 'API_CREDENTIAL_ADMINISTRATION_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('API credential administration mutation conflict');
|
||||
this.name = 'ApiCredentialAdministrationMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiCredentialAdministrationUnavailableError extends Error {
|
||||
readonly code = 'API_CREDENTIAL_ADMINISTRATION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('API credential administration storage is unavailable');
|
||||
this.name = 'ApiCredentialAdministrationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
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 SUBJECT_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const MAX_VERSION = 2_147_483_647;
|
||||
|
||||
export function normalizeApiCredentialAdministrationMutationId(
|
||||
value: string,
|
||||
): string {
|
||||
if (typeof value !== 'string' || !UUID_PATTERN.test(value)) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
'mutationId is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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 InvalidApiCredentialAdministrationValueError(
|
||||
`${name} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function version(name: string, value: number, allowZero = false): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < (allowZero ? 0 : 1) ||
|
||||
value > MAX_VERSION
|
||||
) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
`${name} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function changedBy(value: SecuritySubject): Readonly<SecuritySubject> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
'changedBy is invalid',
|
||||
);
|
||||
}
|
||||
exactKeys(value, ['type', 'id'], 'changedBy');
|
||||
if (
|
||||
(value.type !== 'user' && value.type !== 'system') ||
|
||||
typeof value.id !== 'string' ||
|
||||
value.id.length < 1 ||
|
||||
value.id.length > 255 ||
|
||||
SUBJECT_ID_CONTROL_PATTERN.test(value.id)
|
||||
) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
'changedBy is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ type: value.type, id: value.id });
|
||||
}
|
||||
|
||||
export function normalizeAppendApiCredentialCommand(
|
||||
value: AppendApiCredentialCommand,
|
||||
): Readonly<AppendApiCredentialCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
'command must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
['expectedCurrentVersion', 'credential', 'mutation', 'audit'],
|
||||
'command',
|
||||
);
|
||||
const expectedCurrentVersion = version(
|
||||
'expectedCurrentVersion',
|
||||
value.expectedCurrentVersion,
|
||||
true,
|
||||
);
|
||||
const credential = normalizeApiCredentialRecord(value.credential);
|
||||
const mutation = value.mutation;
|
||||
if (!mutation || typeof mutation !== 'object' || Array.isArray(mutation)) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
'mutation must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
mutation,
|
||||
[
|
||||
'mutationId',
|
||||
'operation',
|
||||
'credentialId',
|
||||
'credentialVersion',
|
||||
'expectedPreviousVersion',
|
||||
'changedBy',
|
||||
'createdAtMs',
|
||||
],
|
||||
'mutation',
|
||||
);
|
||||
normalizeApiCredentialAdministrationMutationId(mutation.mutationId);
|
||||
if (!API_CREDENTIAL_ADMINISTRATION_OPERATIONS.includes(mutation.operation)) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
'operation is invalid',
|
||||
);
|
||||
}
|
||||
const credentialVersion = version(
|
||||
'credentialVersion',
|
||||
mutation.credentialVersion,
|
||||
);
|
||||
const expectedPreviousVersion = version(
|
||||
'expectedPreviousVersion',
|
||||
mutation.expectedPreviousVersion,
|
||||
true,
|
||||
);
|
||||
const actor = changedBy(mutation.changedBy);
|
||||
if (
|
||||
expectedPreviousVersion !== expectedCurrentVersion ||
|
||||
credentialVersion !== expectedCurrentVersion + 1 ||
|
||||
credential.credentialId !== mutation.credentialId ||
|
||||
credential.version !== credentialVersion ||
|
||||
credential.createdAtMs !== mutation.createdAtMs ||
|
||||
!Number.isSafeInteger(mutation.createdAtMs) ||
|
||||
mutation.createdAtMs < 0 ||
|
||||
(mutation.operation === 'issue' &&
|
||||
(expectedCurrentVersion !== 0 || credential.state !== 'active')) ||
|
||||
(mutation.operation === 'rotate' &&
|
||||
(expectedCurrentVersion < 1 || credential.state !== 'active')) ||
|
||||
(mutation.operation === 'revoke' &&
|
||||
(expectedCurrentVersion < 1 ||
|
||||
credential.state !== 'revoked' ||
|
||||
credential.secretDigest !== REVOKED_API_CREDENTIAL_DIGEST ||
|
||||
credential.notBeforeAtMs !== credential.createdAtMs ||
|
||||
credential.expiresAtMs !== credential.createdAtMs + 1))
|
||||
) {
|
||||
throw new InvalidApiCredentialAdministrationValueError(
|
||||
'credential transition is invalid',
|
||||
);
|
||||
}
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
audit.eventId !== mutation.mutationId ||
|
||||
audit.operationId !== `credential.${mutation.operation}` ||
|
||||
audit.subject?.type !== actor.type ||
|
||||
audit.subject.id !== actor.id ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.reasons.length !== 1 ||
|
||||
audit.reasons[0] !== 'credential_admin' ||
|
||||
audit.fence !== null ||
|
||||
audit.occurredAtMs !== mutation.createdAtMs
|
||||
) {
|
||||
throw new InvalidApiCredentialAdministrationValueError('audit is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
expectedCurrentVersion,
|
||||
credential,
|
||||
mutation: Object.freeze({
|
||||
mutationId: mutation.mutationId,
|
||||
operation: mutation.operation,
|
||||
credentialId: mutation.credentialId,
|
||||
credentialVersion,
|
||||
expectedPreviousVersion,
|
||||
changedBy: actor,
|
||||
createdAtMs: mutation.createdAtMs,
|
||||
}),
|
||||
audit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
import { assertApiCredentialId } from './apiCredential';
|
||||
|
||||
export const API_CREDENTIAL_SECRET_BYTES = 32;
|
||||
export const API_CREDENTIAL_DIGEST_DOMAIN = 'qinglong-api-credential-v1\0';
|
||||
|
||||
const BASE64URL_32_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
|
||||
export class InvalidApiCredentialTokenValueError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`API credential token value is invalid: ${message}`);
|
||||
this.name = 'InvalidApiCredentialTokenValueError';
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCanonical(name: string, value: string): Buffer {
|
||||
if (typeof value !== 'string' || !BASE64URL_32_PATTERN.test(value)) {
|
||||
throw new InvalidApiCredentialTokenValueError(
|
||||
`${name} must be canonical base64url for 32 bytes`,
|
||||
);
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
decoded.byteLength !== API_CREDENTIAL_SECRET_BYTES ||
|
||||
decoded.toString('base64url') !== value
|
||||
) {
|
||||
decoded.fill(0);
|
||||
throw new InvalidApiCredentialTokenValueError(
|
||||
`${name} must be canonical base64url for 32 bytes`,
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function assertApiCredentialPepper(value: string): void {
|
||||
const decoded = decodeCanonical('pepper', value);
|
||||
decoded.fill(0);
|
||||
}
|
||||
|
||||
export function assertApiCredentialSecret(value: string): void {
|
||||
const decoded = decodeCanonical('secret', value);
|
||||
decoded.fill(0);
|
||||
}
|
||||
|
||||
export function apiCredentialSecretDigest(
|
||||
pepperBase64Url: string,
|
||||
credentialId: string,
|
||||
secretBase64Url: string,
|
||||
): string {
|
||||
try {
|
||||
assertApiCredentialId(credentialId);
|
||||
} catch {
|
||||
throw new InvalidApiCredentialTokenValueError('credentialId is invalid');
|
||||
}
|
||||
const pepper = decodeCanonical('pepper', pepperBase64Url);
|
||||
const secret = decodeCanonical('secret', secretBase64Url);
|
||||
let result: Buffer | undefined;
|
||||
try {
|
||||
result = createHmac('sha256', pepper)
|
||||
.update(API_CREDENTIAL_DIGEST_DOMAIN, 'utf8')
|
||||
.update(credentialId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(secret)
|
||||
.digest();
|
||||
return result.toString('hex');
|
||||
} finally {
|
||||
result?.fill(0);
|
||||
pepper.fill(0);
|
||||
secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatApiCredentialToken(
|
||||
credentialId: string,
|
||||
secretBase64Url: string,
|
||||
): string {
|
||||
try {
|
||||
assertApiCredentialId(credentialId);
|
||||
assertApiCredentialSecret(secretBase64Url);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidApiCredentialTokenValueError) throw error;
|
||||
throw new InvalidApiCredentialTokenValueError('credentialId is invalid');
|
||||
}
|
||||
return `ql3c_${credentialId}_${secretBase64Url}`;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '../audit/securityAudit';
|
||||
import { SECURITY_SUBJECT_TYPES, type SecuritySubject } from '../security';
|
||||
|
||||
export const IDENTITY_SUBJECT_STATES = ['active', 'disabled'] as const;
|
||||
export const IDENTITY_ADMINISTRATION_OPERATIONS = [
|
||||
'register',
|
||||
'enable',
|
||||
'disable',
|
||||
] as const;
|
||||
|
||||
export type IdentitySubjectState = (typeof IDENTITY_SUBJECT_STATES)[number];
|
||||
export type IdentityAdministrationOperation =
|
||||
(typeof IDENTITY_ADMINISTRATION_OPERATIONS)[number];
|
||||
|
||||
export interface IdentitySubjectRecord {
|
||||
readonly subject: SecuritySubject;
|
||||
readonly status: IdentitySubjectState;
|
||||
readonly version: number;
|
||||
readonly createdAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface IdentitySubjectMutationRecord {
|
||||
readonly mutationId: string;
|
||||
readonly operation: IdentityAdministrationOperation;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly subjectVersion: number;
|
||||
readonly expectedPreviousVersion: number;
|
||||
readonly status: IdentitySubjectState;
|
||||
readonly changedBy: SecuritySubject;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendIdentitySubjectCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly mutation: IdentitySubjectMutationRecord;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendIdentitySubjectResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly identity: Readonly<IdentitySubjectRecord>;
|
||||
readonly mutation: Readonly<IdentitySubjectMutationRecord>;
|
||||
}
|
||||
|
||||
export interface ResolvedIdentitySubjectMutation {
|
||||
readonly identity: Readonly<IdentitySubjectRecord>;
|
||||
readonly mutation: Readonly<IdentitySubjectMutationRecord>;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface IdentityAdministrationRepository {
|
||||
resolve(
|
||||
subject: SecuritySubject,
|
||||
): Promise<Readonly<IdentitySubjectRecord> | null>;
|
||||
resolveMutation(
|
||||
mutationId: string,
|
||||
): Promise<ResolvedIdentitySubjectMutation | null>;
|
||||
append(
|
||||
command: AppendIdentitySubjectCommand,
|
||||
): Promise<AppendIdentitySubjectResult>;
|
||||
}
|
||||
|
||||
export class InvalidIdentityAdministrationValueError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Identity administration value is invalid: ${message}`);
|
||||
this.name = 'InvalidIdentityAdministrationValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export class IdentityAdministrationVersionConflictError extends Error {
|
||||
readonly code = 'IDENTITY_ADMINISTRATION_VERSION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Identity administration version conflict');
|
||||
this.name = 'IdentityAdministrationVersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class IdentityAdministrationMutationConflictError extends Error {
|
||||
readonly code = 'IDENTITY_ADMINISTRATION_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Identity administration mutation conflict');
|
||||
this.name = 'IdentityAdministrationMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class IdentityAdministrationUnavailableError extends Error {
|
||||
readonly code = 'IDENTITY_ADMINISTRATION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Identity administration storage is unavailable');
|
||||
this.name = 'IdentityAdministrationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
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 SUBJECT_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const MAX_VERSION = 2_147_483_647;
|
||||
|
||||
export function normalizeIdentityAdministrationMutationId(
|
||||
value: string,
|
||||
): string {
|
||||
if (typeof value !== 'string' || !UUID_PATTERN.test(value)) {
|
||||
throw new InvalidIdentityAdministrationValueError('mutationId is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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 InvalidIdentityAdministrationValueError(
|
||||
`${name} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function subject(
|
||||
name: string,
|
||||
value: SecuritySubject,
|
||||
adminActor = false,
|
||||
): Readonly<SecuritySubject> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidIdentityAdministrationValueError(`${name} is invalid`);
|
||||
}
|
||||
exactKeys(value, ['type', 'id'], name);
|
||||
if (
|
||||
!SECURITY_SUBJECT_TYPES.includes(value.type) ||
|
||||
(adminActor && value.type !== 'user' && value.type !== 'system') ||
|
||||
typeof value.id !== 'string' ||
|
||||
value.id.length < 1 ||
|
||||
value.id.length > 255 ||
|
||||
SUBJECT_ID_CONTROL_PATTERN.test(value.id)
|
||||
) {
|
||||
throw new InvalidIdentityAdministrationValueError(`${name} is invalid`);
|
||||
}
|
||||
return Object.freeze({ type: value.type, id: value.id });
|
||||
}
|
||||
|
||||
export function normalizeIdentityAdministrationSubject(
|
||||
value: SecuritySubject,
|
||||
): Readonly<SecuritySubject> {
|
||||
return subject('identity subject', value);
|
||||
}
|
||||
|
||||
function version(name: string, value: number, allowZero = false): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < (allowZero ? 0 : 1) ||
|
||||
value > MAX_VERSION
|
||||
) {
|
||||
throw new InvalidIdentityAdministrationValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeIdentitySubjectRecord(
|
||||
value: IdentitySubjectRecord,
|
||||
): Readonly<IdentitySubjectRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidIdentityAdministrationValueError(
|
||||
'identity must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
['subject', 'status', 'version', 'createdAtMs', 'updatedAtMs'],
|
||||
'identity',
|
||||
);
|
||||
const normalizedSubject = subject('identity subject', value.subject);
|
||||
if (!IDENTITY_SUBJECT_STATES.includes(value.status)) {
|
||||
throw new InvalidIdentityAdministrationValueError('status is invalid');
|
||||
}
|
||||
const normalizedVersion = version('version', value.version);
|
||||
if (
|
||||
!Number.isSafeInteger(value.createdAtMs) ||
|
||||
value.createdAtMs < 0 ||
|
||||
!Number.isSafeInteger(value.updatedAtMs) ||
|
||||
value.updatedAtMs < value.createdAtMs
|
||||
) {
|
||||
throw new InvalidIdentityAdministrationValueError(
|
||||
'identity lifetime is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
subject: normalizedSubject,
|
||||
status: value.status,
|
||||
version: normalizedVersion,
|
||||
createdAtMs: value.createdAtMs,
|
||||
updatedAtMs: value.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAppendIdentitySubjectCommand(
|
||||
value: AppendIdentitySubjectCommand,
|
||||
): Readonly<AppendIdentitySubjectCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidIdentityAdministrationValueError(
|
||||
'command must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(value, ['expectedCurrentVersion', 'mutation', 'audit'], 'command');
|
||||
const expectedCurrentVersion = version(
|
||||
'expectedCurrentVersion',
|
||||
value.expectedCurrentVersion,
|
||||
true,
|
||||
);
|
||||
const mutation = value.mutation;
|
||||
if (!mutation || typeof mutation !== 'object' || Array.isArray(mutation)) {
|
||||
throw new InvalidIdentityAdministrationValueError(
|
||||
'mutation must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
mutation,
|
||||
[
|
||||
'mutationId',
|
||||
'operation',
|
||||
'subject',
|
||||
'subjectVersion',
|
||||
'expectedPreviousVersion',
|
||||
'status',
|
||||
'changedBy',
|
||||
'createdAtMs',
|
||||
],
|
||||
'mutation',
|
||||
);
|
||||
normalizeIdentityAdministrationMutationId(mutation.mutationId);
|
||||
if (!IDENTITY_ADMINISTRATION_OPERATIONS.includes(mutation.operation)) {
|
||||
throw new InvalidIdentityAdministrationValueError('operation is invalid');
|
||||
}
|
||||
const normalizedSubject = subject('mutation subject', mutation.subject);
|
||||
const changedBy = subject('changedBy', mutation.changedBy, true);
|
||||
const subjectVersion = version('subjectVersion', mutation.subjectVersion);
|
||||
const expectedPreviousVersion = version(
|
||||
'expectedPreviousVersion',
|
||||
mutation.expectedPreviousVersion,
|
||||
true,
|
||||
);
|
||||
if (
|
||||
expectedPreviousVersion !== expectedCurrentVersion ||
|
||||
subjectVersion !== expectedCurrentVersion + 1 ||
|
||||
!IDENTITY_SUBJECT_STATES.includes(mutation.status) ||
|
||||
!Number.isSafeInteger(mutation.createdAtMs) ||
|
||||
mutation.createdAtMs < 0 ||
|
||||
(mutation.operation === 'register' &&
|
||||
(expectedCurrentVersion !== 0 || mutation.status !== 'active')) ||
|
||||
(mutation.operation === 'enable' &&
|
||||
(expectedCurrentVersion < 1 || mutation.status !== 'active')) ||
|
||||
(mutation.operation === 'disable' &&
|
||||
(expectedCurrentVersion < 1 || mutation.status !== 'disabled'))
|
||||
) {
|
||||
throw new InvalidIdentityAdministrationValueError(
|
||||
'mutation transition is invalid',
|
||||
);
|
||||
}
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
audit.eventId !== mutation.mutationId ||
|
||||
audit.operationId !== `identity.${mutation.operation}` ||
|
||||
audit.subject?.type !== changedBy.type ||
|
||||
audit.subject.id !== changedBy.id ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.reasons.length !== 1 ||
|
||||
audit.reasons[0] !== 'identity_admin' ||
|
||||
audit.fence !== null ||
|
||||
audit.occurredAtMs !== mutation.createdAtMs
|
||||
) {
|
||||
throw new InvalidIdentityAdministrationValueError('audit is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
expectedCurrentVersion,
|
||||
mutation: Object.freeze({
|
||||
mutationId: mutation.mutationId,
|
||||
operation: mutation.operation,
|
||||
subject: normalizedSubject,
|
||||
subjectVersion,
|
||||
expectedPreviousVersion,
|
||||
status: mutation.status,
|
||||
changedBy,
|
||||
createdAtMs: mutation.createdAtMs,
|
||||
}),
|
||||
audit,
|
||||
});
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import type { ApiCredentialRecord } from './apiCredential';
|
||||
import type { ApiCredentialMutationRecord } from './apiCredentialAdministration';
|
||||
import type {
|
||||
IdentitySubjectMutationRecord,
|
||||
IdentitySubjectRecord,
|
||||
ResolvedIdentitySubjectMutation,
|
||||
} from './identityAdministration';
|
||||
import type { SecurityPolicyFence, SecuritySubject } from '../security';
|
||||
import type { SecurityAuditRecord } from '../audit/securityAudit';
|
||||
|
||||
export interface LocalIdentityAdministrationAuthorization {
|
||||
readonly projectId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalIdentityCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly mutation: IdentitySubjectMutationRecord;
|
||||
readonly authorization: LocalIdentityAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalIdentityResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly identity: Readonly<IdentitySubjectRecord>;
|
||||
readonly mutation: Readonly<IdentitySubjectMutationRecord>;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalIdentityCommand {
|
||||
readonly target: SecuritySubject;
|
||||
readonly authorization: LocalIdentityAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalIdentityResult {
|
||||
readonly identity: Readonly<IdentitySubjectRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface LocalCredentialDeliveryFact {
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalApiCredentialCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly credential: ApiCredentialRecord;
|
||||
readonly mutation: ApiCredentialMutationRecord;
|
||||
readonly authorization: LocalIdentityAdministrationAuthorization;
|
||||
readonly delivery: Readonly<LocalCredentialDeliveryFact> | null;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalApiCredentialResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly credential: Readonly<ApiCredentialRecord>;
|
||||
readonly mutation: Readonly<ApiCredentialMutationRecord>;
|
||||
readonly delivery: Readonly<LocalCredentialDeliveryFact> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalApiCredentialCommand {
|
||||
readonly credentialId: string;
|
||||
readonly authorization: LocalIdentityAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalApiCredentialResult {
|
||||
readonly credential: Readonly<ApiCredentialRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface LocalCredentialDeliveryAcknowledgementRecord {
|
||||
readonly credentialMutationId: string;
|
||||
readonly acknowledgementMutationId: string;
|
||||
readonly projectId: string;
|
||||
readonly deliveryDigest: string;
|
||||
readonly acknowledgedBy: SecuritySubject;
|
||||
readonly acknowledgedAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalCredentialDeliveryAcknowledgementCommand {
|
||||
readonly acknowledgement: LocalCredentialDeliveryAcknowledgementRecord;
|
||||
readonly authorization: LocalIdentityAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly acknowledgement: Readonly<LocalCredentialDeliveryAcknowledgementRecord>;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface ResolvedLocalIdentitySubjectMutation
|
||||
extends ResolvedIdentitySubjectMutation {
|
||||
readonly projectId: string;
|
||||
}
|
||||
|
||||
export interface ResolvedLocalApiCredentialMutation {
|
||||
readonly projectId: string;
|
||||
readonly credential: Readonly<ApiCredentialRecord>;
|
||||
readonly mutation: Readonly<ApiCredentialMutationRecord>;
|
||||
readonly delivery: Readonly<LocalCredentialDeliveryFact> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface LocalIdentityCredentialAdministrationRepository {
|
||||
resolveAuthorityProjectId(): Promise<string | null>;
|
||||
resolveIdentity(
|
||||
subject: SecuritySubject,
|
||||
): Promise<Readonly<IdentitySubjectRecord> | null>;
|
||||
resolveIdentityMutation(
|
||||
mutationId: string,
|
||||
): Promise<Readonly<ResolvedLocalIdentitySubjectMutation> | null>;
|
||||
appendAuthorizedIdentity(
|
||||
command: AppendAuthorizedLocalIdentityCommand,
|
||||
): Promise<AppendAuthorizedLocalIdentityResult>;
|
||||
inspectAuthorizedIdentity(
|
||||
command: InspectAuthorizedLocalIdentityCommand,
|
||||
): Promise<InspectAuthorizedLocalIdentityResult>;
|
||||
resolveCredentialMutation(
|
||||
mutationId: string,
|
||||
): Promise<Readonly<ResolvedLocalApiCredentialMutation> | null>;
|
||||
appendAuthorizedCredential(
|
||||
command: AppendAuthorizedLocalApiCredentialCommand,
|
||||
): Promise<AppendAuthorizedLocalApiCredentialResult>;
|
||||
inspectAuthorizedCredential(
|
||||
command: InspectAuthorizedLocalApiCredentialCommand,
|
||||
): Promise<InspectAuthorizedLocalApiCredentialResult>;
|
||||
resolveDeliveryAcknowledgement(
|
||||
credentialMutationId: string,
|
||||
): Promise<Readonly<LocalCredentialDeliveryAcknowledgementRecord> | null>;
|
||||
appendAuthorizedDeliveryAcknowledgement(
|
||||
command: AppendAuthorizedLocalCredentialDeliveryAcknowledgementCommand,
|
||||
): Promise<AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult>;
|
||||
record(audit: SecurityAuditRecord): Promise<void>;
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialAuthorizationFenceConflictError extends Error {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential authorization fence changed');
|
||||
this.name = 'LocalIdentityCredentialAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityOwnerBindingConflictError extends Error {
|
||||
readonly code = 'LOCAL_IDENTITY_OWNER_BINDING_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity remains an active Project Owner');
|
||||
this.name = 'LocalIdentityOwnerBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalCredentialOwnerContinuityError extends Error {
|
||||
readonly code = 'LOCAL_CREDENTIAL_OWNER_CONTINUITY_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('An active Project Owner must retain an active credential');
|
||||
this.name = 'LocalCredentialOwnerContinuityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalCredentialDeliveryMutationConflictError extends Error {
|
||||
readonly code = 'LOCAL_CREDENTIAL_DELIVERY_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local credential delivery mutation conflicts with previous use');
|
||||
this.name = 'LocalCredentialDeliveryMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialAdministrationUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential administration is unavailable');
|
||||
this.name = 'LocalIdentityCredentialAdministrationUnavailableError';
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import type {
|
||||
AppendProjectRoleBindingResult,
|
||||
ProjectRecord,
|
||||
ProjectRole,
|
||||
ProjectRoleBindingRecord,
|
||||
ProjectRoleBindingState,
|
||||
ProjectStatus,
|
||||
} from './projectPolicy';
|
||||
import type { SecurityPolicyFence, SecuritySubject } from '../security';
|
||||
import type {
|
||||
SecurityAuditRecord,
|
||||
SecurityAuditSink,
|
||||
} from '../audit/securityAudit';
|
||||
|
||||
export const MAX_LOCAL_PROJECT_QUERY_PAGE_SIZE = 64;
|
||||
export const MAX_LOCAL_PROJECT_ROLE_BINDING_QUERY_PAGE_SIZE = 64;
|
||||
|
||||
export interface LocalProjectQueryCursor {
|
||||
readonly slug: string;
|
||||
readonly projectId: string;
|
||||
}
|
||||
|
||||
export type LocalProjectQueryStatus = ProjectStatus | 'all';
|
||||
|
||||
export interface LocalProjectAdministrationAuthorization {
|
||||
readonly authorityProjectId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectCommand {
|
||||
readonly projectId: string;
|
||||
readonly authorization: LocalProjectAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectResult {
|
||||
readonly project: Readonly<ProjectRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectsCommand {
|
||||
readonly limit: number;
|
||||
readonly status: LocalProjectQueryStatus;
|
||||
readonly after?: LocalProjectQueryCursor;
|
||||
readonly authorization: LocalProjectAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectsResult {
|
||||
readonly projects: readonly Readonly<ProjectRecord>[];
|
||||
readonly nextCursor: Readonly<LocalProjectQueryCursor> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface LocalProjectRoleBindingQueryCursor {
|
||||
readonly subjectType: SecuritySubject['type'];
|
||||
readonly subjectId: string;
|
||||
}
|
||||
|
||||
export type LocalProjectRoleBindingQueryState = ProjectRoleBindingState | 'all';
|
||||
export type LocalProjectRoleBindingQueryRole = ProjectRole | 'all';
|
||||
|
||||
export interface LocalProjectRoleBindingAdministrationAuthorization {
|
||||
readonly projectId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectRoleBindingCommand {
|
||||
readonly target: SecuritySubject;
|
||||
readonly authorization: LocalProjectRoleBindingAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectRoleBindingResult {
|
||||
readonly binding: Readonly<ProjectRoleBindingRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectRoleBindingsCommand {
|
||||
readonly limit: number;
|
||||
readonly state: LocalProjectRoleBindingQueryState;
|
||||
readonly role: LocalProjectRoleBindingQueryRole;
|
||||
readonly after?: LocalProjectRoleBindingQueryCursor;
|
||||
readonly authorization: LocalProjectRoleBindingAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectRoleBindingsResult {
|
||||
readonly bindings: readonly Readonly<ProjectRoleBindingRecord>[];
|
||||
readonly nextCursor: Readonly<LocalProjectRoleBindingQueryCursor> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectRoleBindingCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly binding: ProjectRoleBindingRecord;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectRoleBindingResult
|
||||
extends AppendProjectRoleBindingResult {
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export type LocalProjectAdministrationOperation =
|
||||
| 'create'
|
||||
| 'archive'
|
||||
| 'restore';
|
||||
|
||||
export interface LocalProjectAdministrationMutationRecord {
|
||||
readonly mutationId: string;
|
||||
readonly operation: LocalProjectAdministrationOperation;
|
||||
readonly authorityProjectId: string;
|
||||
readonly project: Readonly<ProjectRecord>;
|
||||
readonly expectedPreviousVersion: number;
|
||||
readonly changedBy: SecuritySubject;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
interface AppendAuthorizedProjectBaseCommand {
|
||||
readonly authorityProjectId: string;
|
||||
readonly projectId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly mutationId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectCreateCommand
|
||||
extends AppendAuthorizedProjectBaseCommand {
|
||||
readonly operation: 'create';
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectTransitionCommand
|
||||
extends AppendAuthorizedProjectBaseCommand {
|
||||
readonly operation: 'archive' | 'restore';
|
||||
}
|
||||
|
||||
export type AppendAuthorizedProjectCommand =
|
||||
| AppendAuthorizedProjectCreateCommand
|
||||
| AppendAuthorizedProjectTransitionCommand;
|
||||
|
||||
export interface AppendAuthorizedProjectResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly project: Readonly<ProjectRecord>;
|
||||
readonly mutation: Readonly<LocalProjectAdministrationMutationRecord>;
|
||||
readonly initialOwnerBinding: Readonly<ProjectRoleBindingRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived policy administration authority. Implementations must revalidate
|
||||
* the actor's Project/RoleBinding fence and atomically commit each query or
|
||||
* mutation with its audit record.
|
||||
*/
|
||||
export interface LocalProjectPolicyAdministrationRepository
|
||||
extends SecurityAuditSink {
|
||||
inspectAuthorizedProjectRoleBinding(
|
||||
command: InspectAuthorizedLocalProjectRoleBindingCommand,
|
||||
): Promise<InspectAuthorizedLocalProjectRoleBindingResult>;
|
||||
listAuthorizedProjectRoleBindings(
|
||||
command: ListAuthorizedLocalProjectRoleBindingsCommand,
|
||||
): Promise<ListAuthorizedLocalProjectRoleBindingsResult>;
|
||||
inspectAuthorizedProject(
|
||||
command: InspectAuthorizedLocalProjectCommand,
|
||||
): Promise<InspectAuthorizedLocalProjectResult>;
|
||||
listAuthorizedProjects(
|
||||
command: ListAuthorizedLocalProjectsCommand,
|
||||
): Promise<ListAuthorizedLocalProjectsResult>;
|
||||
appendAuthorizedProject(
|
||||
command: AppendAuthorizedProjectCommand,
|
||||
): Promise<AppendAuthorizedProjectResult>;
|
||||
appendAuthorizedProjectRoleBinding(
|
||||
command: AppendAuthorizedProjectRoleBindingCommand,
|
||||
): Promise<AppendAuthorizedProjectRoleBindingResult>;
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyAuthorizationFenceConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project policy authorization changed');
|
||||
this.name = 'LocalProjectPolicyAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyLastOwnerError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_LAST_OWNER';
|
||||
|
||||
constructor() {
|
||||
super('Local Project must retain at least one active User owner');
|
||||
this.name = 'LocalProjectPolicyLastOwnerError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyOwnerCredentialRequiredError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_OWNER_CREDENTIAL_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('A new Local Project owner must have an active credential');
|
||||
this.name = 'LocalProjectPolicyOwnerCredentialRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectVersionConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_VERSION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project current version changed');
|
||||
this.name = 'LocalProjectPolicyProjectVersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectMutationConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project mutation conflicts with its previous request');
|
||||
this.name = 'LocalProjectPolicyProjectMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectIdentityConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_IDENTITY_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project identity or slug is already in use');
|
||||
this.name = 'LocalProjectPolicyProjectIdentityConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectCapacityError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_CAPACITY_EXCEEDED';
|
||||
|
||||
constructor() {
|
||||
super('Local Project capacity is exhausted');
|
||||
this.name = 'LocalProjectPolicyProjectCapacityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyAuthorityProjectProtectedError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_AUTHORITY_PROJECT_PROTECTED';
|
||||
|
||||
constructor() {
|
||||
super('Local instance authority Project cannot be archived');
|
||||
this.name = 'LocalProjectPolicyAuthorityProjectProtectedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
SECURITY_SUBJECT_TYPES,
|
||||
normalizeSecurityPolicyDecision,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '../security';
|
||||
|
||||
export const PROJECT_STATUSES = ['active', 'archived'] as const;
|
||||
export const PROJECT_ROLES = ['owner', 'admin', 'operator', 'viewer'] as const;
|
||||
export const PROJECT_ROLE_BINDING_STATES = ['active', 'revoked'] as const;
|
||||
export const STATIC_PROJECT_PERMISSIONS = [
|
||||
'project.read',
|
||||
'project.manage',
|
||||
'task.read',
|
||||
'task.create',
|
||||
'task.update',
|
||||
'task.delete',
|
||||
'trigger.read',
|
||||
'trigger.create',
|
||||
'trigger.update',
|
||||
'run.read',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'model.invoke',
|
||||
'artifact.read',
|
||||
'approval.read',
|
||||
'package.manage',
|
||||
'secret.use',
|
||||
'secret.manage',
|
||||
'worker.manage',
|
||||
'policy.manage',
|
||||
'approval.decide',
|
||||
'approval.recover',
|
||||
] as const;
|
||||
|
||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||
export type ProjectRole = (typeof PROJECT_ROLES)[number];
|
||||
export type ProjectRoleBindingState =
|
||||
(typeof PROJECT_ROLE_BINDING_STATES)[number];
|
||||
export type StaticProjectPermission =
|
||||
(typeof STATIC_PROJECT_PERMISSIONS)[number];
|
||||
export type ProjectPermission = StaticProjectPermission | `tool.call:${string}`;
|
||||
|
||||
export interface ProjectRecord {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly status: ProjectStatus;
|
||||
readonly version: number;
|
||||
readonly createdAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ProjectRoleBindingRecord {
|
||||
readonly projectId: string;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly version: number;
|
||||
readonly state: ProjectRoleBindingState;
|
||||
readonly role?: ProjectRole;
|
||||
readonly mutationId: string;
|
||||
readonly changedBy: SecuritySubject;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface ProjectPolicySnapshot {
|
||||
readonly project: Readonly<ProjectRecord>;
|
||||
readonly binding?: Readonly<ProjectRoleBindingRecord>;
|
||||
}
|
||||
|
||||
export interface ProjectPolicyRequest {
|
||||
readonly subject: SecuritySubject;
|
||||
readonly projectId: string;
|
||||
readonly permission: ProjectPermission;
|
||||
}
|
||||
|
||||
export interface AppendProjectRoleBindingCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly binding: ProjectRoleBindingRecord;
|
||||
}
|
||||
|
||||
export interface AppendProjectRoleBindingResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly binding: Readonly<ProjectRoleBindingRecord>;
|
||||
}
|
||||
|
||||
export interface ProjectPolicyRepository {
|
||||
resolve(
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
): Promise<Readonly<ProjectPolicySnapshot> | null>;
|
||||
append(
|
||||
command: AppendProjectRoleBindingCommand,
|
||||
): Promise<AppendProjectRoleBindingResult>;
|
||||
}
|
||||
|
||||
export const MAX_PROJECT_ROLE_BINDING_VERSION = 2_147_483_647;
|
||||
|
||||
export class InvalidProjectPolicyValueError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Project policy contract is invalid: ${message}`);
|
||||
this.name = 'InvalidProjectPolicyValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectPolicyUnavailableError extends Error {
|
||||
readonly code = 'PROJECT_POLICY_UNAVAILABLE';
|
||||
constructor() {
|
||||
super('Project policy is unavailable');
|
||||
this.name = 'ProjectPolicyUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectPolicyProjectNotFoundError extends Error {
|
||||
readonly code = 'PROJECT_NOT_FOUND';
|
||||
constructor() {
|
||||
super('Project does not exist');
|
||||
this.name = 'ProjectPolicyProjectNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectRoleBindingVersionConflictError extends Error {
|
||||
readonly code = 'PROJECT_ROLE_BINDING_VERSION_CONFLICT';
|
||||
constructor() {
|
||||
super('Project role binding current version changed');
|
||||
this.name = 'ProjectRoleBindingVersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectRoleBindingMutationConflictError extends Error {
|
||||
readonly code = 'PROJECT_ROLE_BINDING_MUTATION_CONFLICT';
|
||||
constructor() {
|
||||
super('Project role binding mutation conflicts with its previous request');
|
||||
this.name = 'ProjectRoleBindingMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/;
|
||||
const MUTATION_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
const TOOL_PERMISSION_PATTERN =
|
||||
/^tool\.call:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
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 InvalidProjectPolicyValueError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: string, maximum: number, name: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximum ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: number, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function version(value: number, name: string): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > MAX_PROJECT_ROLE_BINDING_VERSION
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeProjectPolicySubject(
|
||||
value: SecuritySubject,
|
||||
): Readonly<SecuritySubject> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError('subject must be an object');
|
||||
}
|
||||
exactKeys(value, ['type', 'id'], 'subject');
|
||||
if (!SECURITY_SUBJECT_TYPES.includes(value.type)) {
|
||||
throw new InvalidProjectPolicyValueError('subject type is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
type: value.type,
|
||||
id: boundedText(value.id, 255, 'subject id'),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertProjectPolicyProjectId(value: string): void {
|
||||
boundedText(value, 128, 'project id');
|
||||
}
|
||||
|
||||
export function normalizeProjectPermission(value: string): ProjectPermission {
|
||||
if (
|
||||
STATIC_PROJECT_PERMISSIONS.includes(value as StaticProjectPermission) ||
|
||||
(typeof value === 'string' && TOOL_PERMISSION_PATTERN.test(value))
|
||||
) {
|
||||
return value as ProjectPermission;
|
||||
}
|
||||
throw new InvalidProjectPolicyValueError('permission is invalid');
|
||||
}
|
||||
|
||||
export function normalizeProjectRecord(
|
||||
value: ProjectRecord,
|
||||
): Readonly<ProjectRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError('project must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
['id', 'name', 'slug', 'status', 'version', 'createdAtMs', 'updatedAtMs'],
|
||||
'project',
|
||||
);
|
||||
const createdAtMs = timestamp(value.createdAtMs, 'project createdAtMs');
|
||||
const updatedAtMs = timestamp(value.updatedAtMs, 'project updatedAtMs');
|
||||
if (updatedAtMs < createdAtMs) {
|
||||
throw new InvalidProjectPolicyValueError('project timestamps are invalid');
|
||||
}
|
||||
if (!PROJECT_STATUSES.includes(value.status)) {
|
||||
throw new InvalidProjectPolicyValueError('project status is invalid');
|
||||
}
|
||||
if (!SLUG_PATTERN.test(value.slug)) {
|
||||
throw new InvalidProjectPolicyValueError('project slug is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(value.id);
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
name: boundedText(value.name, 255, 'project name'),
|
||||
slug: value.slug,
|
||||
status: value.status,
|
||||
version: version(value.version, 'project version'),
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeProjectRoleBinding(
|
||||
value: ProjectRoleBindingRecord,
|
||||
): Readonly<ProjectRoleBindingRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError('role binding must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
value.state === 'active'
|
||||
? [
|
||||
'projectId',
|
||||
'subject',
|
||||
'version',
|
||||
'state',
|
||||
'role',
|
||||
'mutationId',
|
||||
'changedBy',
|
||||
'createdAtMs',
|
||||
]
|
||||
: [
|
||||
'projectId',
|
||||
'subject',
|
||||
'version',
|
||||
'state',
|
||||
'mutationId',
|
||||
'changedBy',
|
||||
'createdAtMs',
|
||||
],
|
||||
'role binding',
|
||||
);
|
||||
if (!PROJECT_ROLE_BINDING_STATES.includes(value.state)) {
|
||||
throw new InvalidProjectPolicyValueError('role binding state is invalid');
|
||||
}
|
||||
if (
|
||||
(value.state === 'active' &&
|
||||
(!value.role || !PROJECT_ROLES.includes(value.role))) ||
|
||||
(value.state === 'revoked' && value.role !== undefined)
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError('role binding role is invalid');
|
||||
}
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_PATTERN.test(value.mutationId)
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError('mutation id is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
subject: normalizeProjectPolicySubject(value.subject),
|
||||
version: version(value.version, 'role binding version'),
|
||||
state: value.state,
|
||||
...(value.role ? { role: value.role } : {}),
|
||||
mutationId: value.mutationId,
|
||||
changedBy: normalizeProjectPolicySubject(value.changedBy),
|
||||
createdAtMs: timestamp(value.createdAtMs, 'role binding createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeProjectPolicySnapshot(
|
||||
value: ProjectPolicySnapshot,
|
||||
): Readonly<ProjectPolicySnapshot> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'policy snapshot must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
value.binding ? ['project', 'binding'] : ['project'],
|
||||
'snapshot',
|
||||
);
|
||||
const project = normalizeProjectRecord(value.project);
|
||||
const binding = value.binding
|
||||
? normalizeProjectRoleBinding(value.binding)
|
||||
: undefined;
|
||||
if (binding && binding.projectId !== project.id) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'binding belongs to another project',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ project, ...(binding ? { binding } : {}) });
|
||||
}
|
||||
|
||||
export function assertExpectedProjectRoleBindingVersion(value: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 0 ||
|
||||
value >= MAX_PROJECT_ROLE_BINDING_VERSION
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'expected role binding version is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_PERMISSIONS = new Set<ProjectPermission>([
|
||||
'project.read',
|
||||
'task.read',
|
||||
'trigger.read',
|
||||
'run.read',
|
||||
'artifact.read',
|
||||
'approval.read',
|
||||
]);
|
||||
const OPERATOR_PERMISSIONS = new Set<ProjectPermission>([
|
||||
...READ_ONLY_PERMISSIONS,
|
||||
'task.create',
|
||||
'task.update',
|
||||
'trigger.create',
|
||||
'trigger.update',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'model.invoke',
|
||||
'secret.use',
|
||||
]);
|
||||
const AGENT_APPROVAL_PERMISSIONS = new Set<ProjectPermission>([
|
||||
'project.manage',
|
||||
'task.create',
|
||||
'task.update',
|
||||
'task.delete',
|
||||
'trigger.create',
|
||||
'trigger.update',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'model.invoke',
|
||||
'secret.use',
|
||||
'secret.manage',
|
||||
'package.manage',
|
||||
'worker.manage',
|
||||
'policy.manage',
|
||||
'approval.decide',
|
||||
]);
|
||||
|
||||
function roleAllows(role: ProjectRole, permission: ProjectPermission): boolean {
|
||||
if (role === 'owner') return true;
|
||||
if (role === 'admin')
|
||||
return (
|
||||
permission.startsWith('tool.call:') || permission !== 'project.manage'
|
||||
);
|
||||
if (role === 'operator')
|
||||
return (
|
||||
permission.startsWith('tool.call:') ||
|
||||
OPERATOR_PERMISSIONS.has(permission)
|
||||
);
|
||||
return READ_ONLY_PERMISSIONS.has(permission);
|
||||
}
|
||||
|
||||
/** Evaluates one immutable Project/RoleBinding snapshot and returns its fence. */
|
||||
export class ProjectPolicyEngine {
|
||||
constructor(
|
||||
private readonly repository: Pick<ProjectPolicyRepository, 'resolve'>,
|
||||
) {
|
||||
if (!repository || typeof repository.resolve !== 'function') {
|
||||
throw new TypeError('Project policy repository is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async decide(request: ProjectPolicyRequest): Promise<SecurityPolicyDecision> {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new InvalidProjectPolicyValueError('request must be an object');
|
||||
}
|
||||
exactKeys(request, ['subject', 'projectId', 'permission'], 'request');
|
||||
const subject = normalizeProjectPolicySubject(request.subject);
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
const projectId = request.projectId;
|
||||
const permission = normalizeProjectPermission(request.permission);
|
||||
let snapshot: Readonly<ProjectPolicySnapshot> | null;
|
||||
try {
|
||||
const resolved = await this.repository.resolve(projectId, subject);
|
||||
snapshot = resolved ? normalizeProjectPolicySnapshot(resolved) : null;
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
if (!snapshot) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['project_not_found'],
|
||||
fence: null,
|
||||
});
|
||||
}
|
||||
const fence = {
|
||||
projectVersion: snapshot.project.version,
|
||||
bindingVersion: snapshot.binding?.version ?? null,
|
||||
};
|
||||
if (!snapshot.binding || snapshot.binding.state === 'revoked') {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['subject_unbound'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (
|
||||
snapshot.project.status === 'archived' &&
|
||||
!READ_ONLY_PERMISSIONS.has(permission)
|
||||
) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['project_archived'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (!roleAllows(snapshot.binding.role!, permission)) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['permission_missing'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (
|
||||
subject.type === 'agent' &&
|
||||
(permission.startsWith('tool.call:') ||
|
||||
AGENT_APPROVAL_PERMISSIONS.has(permission))
|
||||
) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'require_approval',
|
||||
reasons: ['agent_action_requires_approval'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
|
||||
async authorize(
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
projectId: string,
|
||||
permission: ProjectPermission,
|
||||
): Promise<SecurityPolicyDecision> {
|
||||
return this.decide({ subject: principal.subject, projectId, permission });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
export const SECURITY_SUBJECT_TYPES = [
|
||||
'user',
|
||||
'api_app',
|
||||
'mcp_client',
|
||||
'agent',
|
||||
'system',
|
||||
'worker',
|
||||
] as const;
|
||||
export const SECURITY_AUTHENTICATION_ASSURANCES = [
|
||||
'single_factor',
|
||||
'multi_factor',
|
||||
'service',
|
||||
'hardware',
|
||||
'local_console',
|
||||
] as const;
|
||||
export const SECURITY_POLICY_EFFECTS = [
|
||||
'allow',
|
||||
'deny',
|
||||
'require_approval',
|
||||
] as const;
|
||||
|
||||
export type SecuritySubjectType = (typeof SECURITY_SUBJECT_TYPES)[number];
|
||||
export type SecurityAuthenticationAssurance =
|
||||
(typeof SECURITY_AUTHENTICATION_ASSURANCES)[number];
|
||||
export type SecurityPolicyEffect = (typeof SECURITY_POLICY_EFFECTS)[number];
|
||||
|
||||
export interface SecuritySubject {
|
||||
readonly type: SecuritySubjectType;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface SecurityPrincipal {
|
||||
readonly subject: SecuritySubject;
|
||||
readonly authenticationId: string;
|
||||
readonly authenticatedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly assurance: SecurityAuthenticationAssurance;
|
||||
}
|
||||
|
||||
export interface SecurityPolicyFence {
|
||||
readonly projectVersion: number;
|
||||
readonly bindingVersion: number | null;
|
||||
}
|
||||
|
||||
export interface SecurityPolicyDecision {
|
||||
readonly effect: SecurityPolicyEffect;
|
||||
readonly reasons: readonly string[];
|
||||
readonly fence: SecurityPolicyFence | null;
|
||||
}
|
||||
|
||||
export class InvalidSecurityContractError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Security contract is invalid: ${message}`);
|
||||
this.name = 'InvalidSecurityContractError';
|
||||
}
|
||||
}
|
||||
|
||||
const AUTHENTICATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SUBJECT_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
name: string,
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (
|
||||
keys.length !== sortedExpected.length ||
|
||||
keys.some((key, index) => key !== sortedExpected[index])
|
||||
) {
|
||||
throw new InvalidSecurityContractError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function positiveVersion(name: string, value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new InvalidSecurityContractError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeSecurityPrincipal(
|
||||
value: SecurityPrincipal,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new InvalidSecurityContractError('current time is invalid');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidSecurityContractError('principal must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'subject',
|
||||
'authenticationId',
|
||||
'authenticatedAtMs',
|
||||
'expiresAtMs',
|
||||
'assurance',
|
||||
],
|
||||
'principal',
|
||||
);
|
||||
const subject = value.subject;
|
||||
if (!subject || typeof subject !== 'object' || Array.isArray(subject)) {
|
||||
throw new InvalidSecurityContractError('subject must be an object');
|
||||
}
|
||||
exactKeys(subject, ['type', 'id'], 'subject');
|
||||
if (!SECURITY_SUBJECT_TYPES.includes(subject.type)) {
|
||||
throw new InvalidSecurityContractError('subject type is invalid');
|
||||
}
|
||||
if (
|
||||
typeof subject.id !== 'string' ||
|
||||
subject.id.length < 1 ||
|
||||
subject.id.length > 255 ||
|
||||
SUBJECT_ID_CONTROL_PATTERN.test(subject.id)
|
||||
) {
|
||||
throw new InvalidSecurityContractError('subject id is invalid');
|
||||
}
|
||||
if (!AUTHENTICATION_ID_PATTERN.test(value.authenticationId)) {
|
||||
throw new InvalidSecurityContractError('authentication id is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value.authenticatedAtMs) ||
|
||||
value.authenticatedAtMs < 0 ||
|
||||
!Number.isSafeInteger(value.expiresAtMs) ||
|
||||
value.expiresAtMs <= value.authenticatedAtMs ||
|
||||
value.authenticatedAtMs > nowMs ||
|
||||
value.expiresAtMs <= nowMs
|
||||
) {
|
||||
throw new InvalidSecurityContractError('principal lifetime is inactive');
|
||||
}
|
||||
if (!SECURITY_AUTHENTICATION_ASSURANCES.includes(value.assurance)) {
|
||||
throw new InvalidSecurityContractError(
|
||||
'authentication assurance is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
subject: Object.freeze({ type: subject.type, id: subject.id }),
|
||||
authenticationId: value.authenticationId,
|
||||
authenticatedAtMs: value.authenticatedAtMs,
|
||||
expiresAtMs: value.expiresAtMs,
|
||||
assurance: value.assurance,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeSecurityPolicyDecision(
|
||||
value: SecurityPolicyDecision,
|
||||
): Readonly<SecurityPolicyDecision> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidSecurityContractError('policy decision must be an object');
|
||||
}
|
||||
exactKeys(value, ['effect', 'reasons', 'fence'], 'policy decision');
|
||||
if (!SECURITY_POLICY_EFFECTS.includes(value.effect)) {
|
||||
throw new InvalidSecurityContractError('policy effect 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 InvalidSecurityContractError('policy reasons are invalid');
|
||||
}
|
||||
let fence: Readonly<SecurityPolicyFence> | null = null;
|
||||
if (value.fence !== null) {
|
||||
if (
|
||||
!value.fence ||
|
||||
typeof value.fence !== 'object' ||
|
||||
Array.isArray(value.fence)
|
||||
) {
|
||||
throw new InvalidSecurityContractError('policy fence is invalid');
|
||||
}
|
||||
exactKeys(
|
||||
value.fence,
|
||||
['projectVersion', 'bindingVersion'],
|
||||
'policy fence',
|
||||
);
|
||||
fence = Object.freeze({
|
||||
projectVersion: positiveVersion(
|
||||
'policy project version',
|
||||
value.fence.projectVersion,
|
||||
),
|
||||
bindingVersion:
|
||||
value.fence.bindingVersion === null
|
||||
? null
|
||||
: positiveVersion(
|
||||
'policy binding version',
|
||||
value.fence.bindingVersion,
|
||||
),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
effect: value.effect,
|
||||
reasons: Object.freeze([...value.reasons]),
|
||||
fence,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user