feat(ql3): establish 3.0 incubation baseline

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