mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+831
@@ -0,0 +1,831 @@
|
||||
import {
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
type ApiCredentialAdministrationOperation,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
assertApiCredentialId,
|
||||
assertApiCredentialPepperKeyId,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
IDENTITY_ADMINISTRATION_OPERATIONS,
|
||||
type IdentityAdministrationOperation,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
import {
|
||||
type AppendAuthorizedLocalApiCredentialResult,
|
||||
type AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult,
|
||||
type AppendAuthorizedLocalIdentityResult,
|
||||
type InspectAuthorizedLocalApiCredentialResult,
|
||||
type InspectAuthorizedLocalIdentityResult,
|
||||
type LocalIdentityCredentialAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_VERSION = 2_147_483_647;
|
||||
const MAX_CREDENTIAL_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
interface BaseAdministrationRequest {
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
interface BaseInspectionRequest {
|
||||
readonly projectId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly requestId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface LocalIdentityAdministrationRequest
|
||||
extends BaseAdministrationRequest {
|
||||
readonly operation: IdentityAdministrationOperation;
|
||||
readonly target: SecuritySubject;
|
||||
readonly expectedCurrentVersion: number;
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialAdministrationRequest
|
||||
extends BaseAdministrationRequest {
|
||||
readonly operation: ApiCredentialAdministrationOperation;
|
||||
readonly credentialId: string;
|
||||
readonly target: SecuritySubject;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly pepperKeyId: string;
|
||||
readonly secretDigest?: string;
|
||||
readonly deliveryDigest?: string;
|
||||
readonly notBeforeAtMs?: number;
|
||||
readonly expiresAtMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalCredentialDeliveryAcknowledgementRequest
|
||||
extends BaseAdministrationRequest {
|
||||
readonly credentialMutationId: string;
|
||||
readonly expectedDeliveryDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalIdentityInspectionRequest extends BaseInspectionRequest {
|
||||
readonly target: SecuritySubject;
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialInspectionRequest
|
||||
extends BaseInspectionRequest {
|
||||
readonly credentialId: string;
|
||||
}
|
||||
|
||||
export interface LocalIdentityCredentialAdministrationService {
|
||||
inspectIdentity(
|
||||
request: LocalIdentityInspectionRequest,
|
||||
): Promise<InspectAuthorizedLocalIdentityResult>;
|
||||
inspectCredential(
|
||||
request: LocalApiCredentialInspectionRequest,
|
||||
): Promise<InspectAuthorizedLocalApiCredentialResult>;
|
||||
changeIdentity(
|
||||
request: LocalIdentityAdministrationRequest,
|
||||
): Promise<AppendAuthorizedLocalIdentityResult>;
|
||||
changeCredential(
|
||||
request: LocalApiCredentialAdministrationRequest,
|
||||
): Promise<AppendAuthorizedLocalApiCredentialResult>;
|
||||
acknowledgeCredentialDelivery(
|
||||
request: LocalCredentialDeliveryAcknowledgementRequest,
|
||||
): Promise<AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult>;
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialAdministrationConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local Identity credential administration is invalid: ${message}`);
|
||||
this.name = 'LocalIdentityCredentialAdministrationConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialAdministrationAuthenticationError extends Error {
|
||||
readonly code =
|
||||
'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential administration requires a strong User');
|
||||
this.name = 'LocalIdentityCredentialAdministrationAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialAdministrationAuthorizationError extends Error {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential administration is not authorized');
|
||||
this.name = 'LocalIdentityCredentialAdministrationAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialAdministrationServiceUnavailableError extends Error {
|
||||
readonly code =
|
||||
'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_SERVICE_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential administration service is unavailable');
|
||||
this.name = 'LocalIdentityCredentialAdministrationServiceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function safeNow(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'clock is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function common(
|
||||
request: BaseAdministrationRequest,
|
||||
nowMs: number,
|
||||
): Readonly<BaseAdministrationRequest> {
|
||||
try {
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'projectId is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof request.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.mutationId) ||
|
||||
typeof request.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(request.requestId)
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'mutationId or requestId is invalid',
|
||||
);
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(request.principal, nowMs);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationAuthenticationError();
|
||||
}
|
||||
return Object.freeze({ ...request, principal });
|
||||
}
|
||||
|
||||
function inspectionCommon(
|
||||
request: BaseInspectionRequest,
|
||||
nowMs: number,
|
||||
): Readonly<BaseInspectionRequest> {
|
||||
try {
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'projectId is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof request.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.auditEventId) ||
|
||||
typeof request.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(request.requestId)
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'auditEventId or requestId is invalid',
|
||||
);
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(request.principal, nowMs);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationAuthenticationError();
|
||||
}
|
||||
return Object.freeze({ ...request, principal });
|
||||
}
|
||||
|
||||
function expectedVersion(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value >= MAX_VERSION) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'expectedCurrentVersion is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function target(value: SecuritySubject): Readonly<SecuritySubject> {
|
||||
let normalized: Readonly<SecuritySubject>;
|
||||
try {
|
||||
normalized = normalizeProjectPolicySubject(value);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'target is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
normalized.type !== 'user' &&
|
||||
normalized.type !== 'api_app' &&
|
||||
normalized.type !== 'mcp_client' &&
|
||||
normalized.type !== 'agent'
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'target type is not locally administrable',
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function audit(options: {
|
||||
readonly eventId: string;
|
||||
readonly requestId: string;
|
||||
readonly operationId: string;
|
||||
readonly projectId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly outcome: SecurityAuditRecord['outcome'];
|
||||
readonly reasons: readonly string[];
|
||||
readonly fence: SecurityPolicyFence | null;
|
||||
readonly occurredAtMs: number;
|
||||
}): Readonly<SecurityAuditRecord> {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: options.eventId,
|
||||
requestId: options.requestId,
|
||||
operationId: options.operationId,
|
||||
projectId: options.projectId,
|
||||
subject: options.principal.subject,
|
||||
authenticationId: options.principal.authenticationId,
|
||||
outcome: options.outcome,
|
||||
reasons: options.reasons,
|
||||
fence: options.fence,
|
||||
occurredAtMs: options.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy: ProjectPolicyRepository,
|
||||
repository: LocalIdentityCredentialAdministrationRepository,
|
||||
options: { readonly now?: () => number } = {},
|
||||
): LocalIdentityCredentialAdministrationService {
|
||||
if (
|
||||
!projectPolicy ||
|
||||
typeof projectPolicy.resolve !== 'function' ||
|
||||
!repository ||
|
||||
typeof repository.resolveAuthorityProjectId !== 'function' ||
|
||||
typeof repository.resolveIdentity !== 'function' ||
|
||||
typeof repository.resolveIdentityMutation !== 'function' ||
|
||||
typeof repository.appendAuthorizedIdentity !== 'function' ||
|
||||
typeof repository.inspectAuthorizedIdentity !== 'function' ||
|
||||
typeof repository.resolveCredentialMutation !== 'function' ||
|
||||
typeof repository.appendAuthorizedCredential !== 'function' ||
|
||||
typeof repository.inspectAuthorizedCredential !== 'function' ||
|
||||
typeof repository.resolveDeliveryAcknowledgement !== 'function' ||
|
||||
typeof repository.appendAuthorizedDeliveryAcknowledgement !== 'function' ||
|
||||
typeof repository.record !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'now') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'dependencies are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const policy = new ProjectPolicyEngine(projectPolicy);
|
||||
|
||||
async function authorize(
|
||||
request: Readonly<BaseAdministrationRequest | BaseInspectionRequest>,
|
||||
eventId: string,
|
||||
operationId: string,
|
||||
occurredAtMs: number,
|
||||
): Promise<Readonly<SecurityPolicyFence>> {
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
'project.manage',
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
await repository.record(
|
||||
audit({
|
||||
eventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'authorization_unavailable',
|
||||
reasons: ['policy_unavailable'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
|
||||
}
|
||||
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
|
||||
}
|
||||
if (decision.effect !== 'allow' || !decision.fence?.bindingVersion) {
|
||||
try {
|
||||
await repository.record(
|
||||
audit({
|
||||
eventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome:
|
||||
decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
|
||||
}
|
||||
throw new LocalIdentityCredentialAdministrationAuthorizationError();
|
||||
}
|
||||
let authorityProjectId: string | null;
|
||||
try {
|
||||
authorityProjectId = await repository.resolveAuthorityProjectId();
|
||||
if (authorityProjectId !== null) {
|
||||
assertProjectPolicyProjectId(authorityProjectId);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
await repository.record(
|
||||
audit({
|
||||
eventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'authorization_unavailable',
|
||||
reasons: ['instance_authority_project_unavailable'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
|
||||
}
|
||||
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
|
||||
}
|
||||
if (authorityProjectId !== request.projectId) {
|
||||
try {
|
||||
await repository.record(
|
||||
audit({
|
||||
eventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'denied',
|
||||
reasons: ['instance_authority_project_required'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
|
||||
}
|
||||
throw new LocalIdentityCredentialAdministrationAuthorizationError();
|
||||
}
|
||||
return decision.fence;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async inspectIdentity(input: LocalIdentityInspectionRequest) {
|
||||
exactObject(
|
||||
input,
|
||||
['projectId', 'target', 'auditEventId', 'requestId', 'principal'],
|
||||
'identity inspection request',
|
||||
);
|
||||
const occurredAtMs = safeNow(now);
|
||||
const request = inspectionCommon(input, occurredAtMs);
|
||||
const subject = target(input.target);
|
||||
const operationId = 'identity.inspect';
|
||||
const fence = await authorize(
|
||||
request,
|
||||
request.auditEventId,
|
||||
operationId,
|
||||
occurredAtMs,
|
||||
);
|
||||
return repository.inspectAuthorizedIdentity({
|
||||
target: subject,
|
||||
authorization: {
|
||||
projectId: request.projectId,
|
||||
actor: request.principal.subject,
|
||||
fence,
|
||||
},
|
||||
audit: audit({
|
||||
eventId: request.auditEventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'allowed',
|
||||
reasons: ['owner_identity_inspect'],
|
||||
fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async inspectCredential(input: LocalApiCredentialInspectionRequest) {
|
||||
exactObject(
|
||||
input,
|
||||
['projectId', 'credentialId', 'auditEventId', 'requestId', 'principal'],
|
||||
'credential inspection request',
|
||||
);
|
||||
const occurredAtMs = safeNow(now);
|
||||
const request = inspectionCommon(input, occurredAtMs);
|
||||
try {
|
||||
assertApiCredentialId(input.credentialId);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'credentialId is invalid',
|
||||
);
|
||||
}
|
||||
const operationId = 'credential.inspect';
|
||||
const fence = await authorize(
|
||||
request,
|
||||
request.auditEventId,
|
||||
operationId,
|
||||
occurredAtMs,
|
||||
);
|
||||
return repository.inspectAuthorizedCredential({
|
||||
credentialId: input.credentialId,
|
||||
authorization: {
|
||||
projectId: request.projectId,
|
||||
actor: request.principal.subject,
|
||||
fence,
|
||||
},
|
||||
audit: audit({
|
||||
eventId: request.auditEventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'allowed',
|
||||
reasons: ['owner_credential_inspect'],
|
||||
fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async changeIdentity(input: LocalIdentityAdministrationRequest) {
|
||||
exactObject(
|
||||
input,
|
||||
[
|
||||
'projectId',
|
||||
'operation',
|
||||
'target',
|
||||
'expectedCurrentVersion',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'principal',
|
||||
],
|
||||
'identity request',
|
||||
);
|
||||
const occurredAtMs = safeNow(now);
|
||||
const request = common(input, occurredAtMs);
|
||||
if (!IDENTITY_ADMINISTRATION_OPERATIONS.includes(input.operation)) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'identity operation is invalid',
|
||||
);
|
||||
}
|
||||
const currentVersion = expectedVersion(input.expectedCurrentVersion);
|
||||
const subject = target(input.target);
|
||||
const operationId = `identity.${input.operation}`;
|
||||
const fence = await authorize(
|
||||
request,
|
||||
request.mutationId,
|
||||
operationId,
|
||||
occurredAtMs,
|
||||
);
|
||||
const replay = await repository.resolveIdentityMutation(
|
||||
request.mutationId,
|
||||
);
|
||||
const mutationTime = replay?.mutation.createdAtMs ?? occurredAtMs;
|
||||
return repository.appendAuthorizedIdentity({
|
||||
expectedCurrentVersion: currentVersion,
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation: input.operation,
|
||||
subject,
|
||||
subjectVersion: currentVersion + 1,
|
||||
expectedPreviousVersion: currentVersion,
|
||||
status: input.operation === 'disable' ? 'disabled' : 'active',
|
||||
changedBy: request.principal.subject,
|
||||
createdAtMs: mutationTime,
|
||||
},
|
||||
authorization: {
|
||||
projectId: request.projectId,
|
||||
actor: request.principal.subject,
|
||||
fence,
|
||||
},
|
||||
audit: audit({
|
||||
eventId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'allowed',
|
||||
reasons: ['owner_identity_admin'],
|
||||
fence,
|
||||
occurredAtMs: mutationTime,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async changeCredential(input: LocalApiCredentialAdministrationRequest) {
|
||||
const active = input.operation !== 'revoke';
|
||||
exactObject(
|
||||
input,
|
||||
[
|
||||
'projectId',
|
||||
'operation',
|
||||
'credentialId',
|
||||
'target',
|
||||
'expectedCurrentVersion',
|
||||
'pepperKeyId',
|
||||
...(active
|
||||
? ['secretDigest', 'deliveryDigest', 'notBeforeAtMs', 'expiresAtMs']
|
||||
: []),
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'principal',
|
||||
],
|
||||
'credential request',
|
||||
);
|
||||
const occurredAtMs = safeNow(now);
|
||||
const request = common(input, occurredAtMs);
|
||||
if (
|
||||
input.operation !== 'issue' &&
|
||||
input.operation !== 'rotate' &&
|
||||
input.operation !== 'revoke'
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'credential operation is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertApiCredentialId(input.credentialId);
|
||||
assertApiCredentialPepperKeyId(input.pepperKeyId);
|
||||
} catch {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'credentialId or pepperKeyId is invalid',
|
||||
);
|
||||
}
|
||||
const currentVersion = expectedVersion(input.expectedCurrentVersion);
|
||||
const subject = target(input.target);
|
||||
let secretDigest = REVOKED_API_CREDENTIAL_DIGEST;
|
||||
let deliveryDigest: string | null = null;
|
||||
let notBeforeAtMs = occurredAtMs;
|
||||
let expiresAtMs = occurredAtMs + 1;
|
||||
if (active) {
|
||||
if (
|
||||
typeof input.secretDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(input.secretDigest) ||
|
||||
typeof input.deliveryDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(input.deliveryDigest) ||
|
||||
!Number.isSafeInteger(input.notBeforeAtMs) ||
|
||||
!Number.isSafeInteger(input.expiresAtMs) ||
|
||||
(input.expiresAtMs as number) <= (input.notBeforeAtMs as number)
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'credential material or lifetime is invalid',
|
||||
);
|
||||
}
|
||||
secretDigest = input.secretDigest;
|
||||
deliveryDigest = input.deliveryDigest;
|
||||
notBeforeAtMs = input.notBeforeAtMs as number;
|
||||
expiresAtMs = input.expiresAtMs as number;
|
||||
}
|
||||
const operationId = `credential.${input.operation}`;
|
||||
const fence = await authorize(
|
||||
request,
|
||||
request.mutationId,
|
||||
operationId,
|
||||
occurredAtMs,
|
||||
);
|
||||
const replay = await repository.resolveCredentialMutation(
|
||||
request.mutationId,
|
||||
);
|
||||
const mutationTime = replay?.mutation.createdAtMs ?? occurredAtMs;
|
||||
if (
|
||||
active &&
|
||||
(notBeforeAtMs < mutationTime ||
|
||||
expiresAtMs - mutationTime > MAX_CREDENTIAL_LIFETIME_MS)
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'credential material or lifetime is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
replay &&
|
||||
(replay.mutation.operation !== input.operation ||
|
||||
replay.mutation.credentialId !== input.credentialId ||
|
||||
replay.mutation.expectedPreviousVersion !== currentVersion ||
|
||||
!sameSubject(replay.credential.subject, subject) ||
|
||||
replay.credential.pepperKeyId !== input.pepperKeyId ||
|
||||
(active &&
|
||||
(replay.credential.secretDigest !== secretDigest ||
|
||||
replay.delivery?.digest !== deliveryDigest ||
|
||||
replay.credential.notBeforeAtMs !== notBeforeAtMs ||
|
||||
replay.credential.expiresAtMs !== expiresAtMs)))
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'credential replay conflicts with request',
|
||||
);
|
||||
}
|
||||
const identity = replay
|
||||
? null
|
||||
: await repository.resolveIdentity(subject);
|
||||
if (!replay && (!identity || (active && identity.status !== 'active'))) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'target Identity is unavailable',
|
||||
);
|
||||
}
|
||||
if (replay && !active) {
|
||||
secretDigest = replay.credential.secretDigest;
|
||||
notBeforeAtMs = replay.credential.notBeforeAtMs;
|
||||
expiresAtMs = replay.credential.expiresAtMs;
|
||||
}
|
||||
return repository.appendAuthorizedCredential({
|
||||
expectedCurrentVersion: currentVersion,
|
||||
credential: {
|
||||
credentialId: input.credentialId,
|
||||
version: currentVersion + 1,
|
||||
pepperKeyId: input.pepperKeyId,
|
||||
state: active ? 'active' : 'revoked',
|
||||
subject,
|
||||
subjectStatus: replay?.credential.subjectStatus ?? identity!.status,
|
||||
secretDigest,
|
||||
createdAtMs: mutationTime,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
},
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation: input.operation,
|
||||
credentialId: input.credentialId,
|
||||
credentialVersion: currentVersion + 1,
|
||||
expectedPreviousVersion: currentVersion,
|
||||
changedBy: request.principal.subject,
|
||||
createdAtMs: mutationTime,
|
||||
},
|
||||
authorization: {
|
||||
projectId: request.projectId,
|
||||
actor: request.principal.subject,
|
||||
fence,
|
||||
},
|
||||
delivery:
|
||||
deliveryDigest === null
|
||||
? null
|
||||
: Object.freeze({ digest: deliveryDigest }),
|
||||
audit: audit({
|
||||
eventId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'allowed',
|
||||
reasons: ['owner_credential_admin'],
|
||||
fence,
|
||||
occurredAtMs: mutationTime,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async acknowledgeCredentialDelivery(
|
||||
input: LocalCredentialDeliveryAcknowledgementRequest,
|
||||
) {
|
||||
exactObject(
|
||||
input,
|
||||
[
|
||||
'projectId',
|
||||
'credentialMutationId',
|
||||
'expectedDeliveryDigest',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'principal',
|
||||
],
|
||||
'delivery acknowledgement request',
|
||||
);
|
||||
const occurredAtMs = safeNow(now);
|
||||
const request = common(input, occurredAtMs);
|
||||
if (
|
||||
!UUID_V4_PATTERN.test(input.credentialMutationId) ||
|
||||
input.credentialMutationId === request.mutationId ||
|
||||
!DIGEST_PATTERN.test(input.expectedDeliveryDigest)
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationConfigurationError(
|
||||
'delivery acknowledgement value is invalid',
|
||||
);
|
||||
}
|
||||
const operationId = 'credential.delivery.acknowledge';
|
||||
const fence = await authorize(
|
||||
request,
|
||||
request.mutationId,
|
||||
operationId,
|
||||
occurredAtMs,
|
||||
);
|
||||
const existing = await repository.resolveDeliveryAcknowledgement(
|
||||
input.credentialMutationId,
|
||||
);
|
||||
const acknowledgementTime = existing?.acknowledgedAtMs ?? occurredAtMs;
|
||||
return repository.appendAuthorizedDeliveryAcknowledgement({
|
||||
acknowledgement: {
|
||||
credentialMutationId: input.credentialMutationId,
|
||||
acknowledgementMutationId: request.mutationId,
|
||||
projectId: request.projectId,
|
||||
deliveryDigest: input.expectedDeliveryDigest,
|
||||
acknowledgedBy: request.principal.subject,
|
||||
acknowledgedAtMs: acknowledgementTime,
|
||||
},
|
||||
authorization: {
|
||||
projectId: request.projectId,
|
||||
actor: request.principal.subject,
|
||||
fence,
|
||||
},
|
||||
audit: audit({
|
||||
eventId: request.mutationId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
principal: request.principal,
|
||||
outcome: 'allowed',
|
||||
reasons: ['owner_credential_delivery_acknowledged'],
|
||||
fence,
|
||||
occurredAtMs: acknowledgementTime,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
import {
|
||||
LOCAL_SECRET_ALGORITHM,
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretUnavailableError,
|
||||
LocalSecretVersionConflictError,
|
||||
assertLocalSecretExpectedVersion,
|
||||
assertLocalSecretMutationId,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretPlaintext,
|
||||
assertLocalSecretProjectId,
|
||||
createLocalSecretRef,
|
||||
type LocalSecretEnvelope,
|
||||
type LocalSecretKeyProvider,
|
||||
type PutEncryptedLocalSecretResult,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
type LocalSecretAdministrationMutation,
|
||||
type LocalSecretAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/local-secret-administration';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
SecurityAuditUnavailableError,
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
encryptLocalSecretEnvelope,
|
||||
localSecretPlaintextMatches,
|
||||
ownedLocalSecretKeyMaterial,
|
||||
type LocalSecretNonceFactory,
|
||||
} from '@qinglong/local-secret';
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export interface LocalSecretAdministrationRequest {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly plaintext: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface LocalSecretAdministrationOptions {
|
||||
readonly now?: () => number;
|
||||
readonly nonceFactory?: LocalSecretNonceFactory;
|
||||
}
|
||||
|
||||
export interface LocalSecretAdministrationService {
|
||||
put(
|
||||
request: LocalSecretAdministrationRequest,
|
||||
): Promise<PutEncryptedLocalSecretResult>;
|
||||
}
|
||||
|
||||
export class LocalSecretAdministrationConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Local Secret administration configuration is invalid: ${message}`);
|
||||
this.name = 'LocalSecretAdministrationConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSecretAdministrationAuthenticationError extends Error {
|
||||
readonly code = 'LOCAL_SECRET_ADMINISTRATION_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Local Secret administration requires a strong principal');
|
||||
this.name = 'LocalSecretAdministrationAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSecretAdministrationAuthorizationError extends Error {
|
||||
readonly code = 'LOCAL_SECRET_ADMINISTRATION_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Local Secret administration is not authorized');
|
||||
this.name = 'LocalSecretAdministrationAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSecretAdministrationUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_SECRET_ADMINISTRATION_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Secret administration is unavailable');
|
||||
this.name = 'LocalSecretAdministrationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function assertRequest(request: LocalSecretAdministrationRequest): void {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
!exactKeys(request, [
|
||||
'projectId',
|
||||
'name',
|
||||
'plaintext',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'expectedCurrentVersion',
|
||||
'principal',
|
||||
])
|
||||
) {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'request shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertLocalSecretProjectId(request.projectId);
|
||||
assertLocalSecretName(request.name);
|
||||
assertLocalSecretPlaintext(request.plaintext);
|
||||
assertLocalSecretMutationId(request.mutationId);
|
||||
assertLocalSecretExpectedVersion(request.expectedCurrentVersion);
|
||||
} catch {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'request value is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!UUID_V4_PATTERN.test(request.mutationId) ||
|
||||
!REQUEST_ID_PATTERN.test(request.requestId)
|
||||
) {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'request identity is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function administrationPrincipal(
|
||||
value: SecurityPrincipal,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(value, nowMs);
|
||||
} catch {
|
||||
throw new LocalSecretAdministrationAuthenticationError();
|
||||
}
|
||||
const human =
|
||||
principal.subject.type === 'user' &&
|
||||
STRONG_USER_ASSURANCES.has(principal.assurance);
|
||||
const system =
|
||||
principal.subject.type === 'system' && principal.assurance === 'service';
|
||||
if (!human && !system) {
|
||||
throw new LocalSecretAdministrationAuthenticationError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
function auditRecord(options: {
|
||||
readonly request: LocalSecretAdministrationRequest;
|
||||
readonly principal: Readonly<SecurityPrincipal> | null;
|
||||
readonly operationId: 'secret.create' | 'secret.manage' | 'secret.rotate';
|
||||
readonly outcome: SecurityAuditRecord['outcome'];
|
||||
readonly reasons: readonly string[];
|
||||
readonly fence: SecurityPolicyDecision['fence'];
|
||||
readonly occurredAtMs: number;
|
||||
}): Readonly<SecurityAuditRecord> {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: options.request.mutationId,
|
||||
requestId: options.request.requestId,
|
||||
operationId: options.operationId,
|
||||
projectId: options.request.projectId,
|
||||
subject: options.principal?.subject ?? null,
|
||||
authenticationId: options.principal?.authenticationId ?? null,
|
||||
outcome: options.outcome,
|
||||
reasons: options.reasons,
|
||||
fence: options.fence,
|
||||
occurredAtMs: options.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function sameAuditSemantic(
|
||||
left: Readonly<SecurityAuditRecord>,
|
||||
right: Readonly<SecurityAuditRecord>,
|
||||
): boolean {
|
||||
const { occurredAtMs: _leftTime, ...leftSemantic } = left;
|
||||
const { occurredAtMs: _rightTime, ...rightSemantic } = right;
|
||||
return JSON.stringify(leftSemantic) === JSON.stringify(rightSemantic);
|
||||
}
|
||||
|
||||
function result(
|
||||
status: PutEncryptedLocalSecretResult['status'],
|
||||
envelope: LocalSecretEnvelope,
|
||||
): PutEncryptedLocalSecretResult {
|
||||
return Object.freeze({
|
||||
status,
|
||||
version: envelope.version,
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: envelope.projectId,
|
||||
name: envelope.name,
|
||||
version: envelope.version,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function matchesExisting(
|
||||
existing: Readonly<LocalSecretAdministrationMutation>,
|
||||
expectedAudit: Readonly<SecurityAuditRecord>,
|
||||
request: LocalSecretAdministrationRequest,
|
||||
keys: LocalSecretKeyProvider,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
existing.envelope.version !== request.expectedCurrentVersion + 1 ||
|
||||
!sameAuditSemantic(existing.audit, expectedAudit)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const material = ownedLocalSecretKeyMaterial(
|
||||
await keys.resolve(existing.envelope.keyId),
|
||||
existing.envelope.keyId,
|
||||
);
|
||||
try {
|
||||
return localSecretPlaintextMatches(
|
||||
existing.envelope,
|
||||
material.key,
|
||||
request.plaintext,
|
||||
);
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalSecretAdministrationService(
|
||||
projectPolicy: ProjectPolicyRepository,
|
||||
mutations: LocalSecretAdministrationRepository,
|
||||
audit: SecurityAuditSink,
|
||||
keys: LocalSecretKeyProvider,
|
||||
options: LocalSecretAdministrationOptions = {},
|
||||
): LocalSecretAdministrationService {
|
||||
if (
|
||||
!projectPolicy ||
|
||||
typeof projectPolicy.resolve !== 'function' ||
|
||||
typeof projectPolicy.append !== 'function'
|
||||
) {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'Project Policy repository is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!mutations ||
|
||||
typeof mutations.resolveLocalSecretAdministrationMutation !== 'function' ||
|
||||
typeof mutations.appendAuthorizedLocalSecretEnvelope !== 'function'
|
||||
) {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'mutation repository is invalid',
|
||||
);
|
||||
}
|
||||
if (!audit || typeof audit.record !== 'function') {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'audit sink is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!keys ||
|
||||
typeof keys.active !== 'function' ||
|
||||
typeof keys.resolve !== 'function'
|
||||
) {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'key provider is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(
|
||||
options,
|
||||
options.nonceFactory === undefined
|
||||
? options.now === undefined
|
||||
? []
|
||||
: ['now']
|
||||
: options.now === undefined
|
||||
? ['nonceFactory']
|
||||
: ['now', 'nonceFactory'],
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.nonceFactory !== undefined &&
|
||||
typeof options.nonceFactory !== 'function')
|
||||
) {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'options are invalid',
|
||||
);
|
||||
}
|
||||
const policy = new ProjectPolicyEngine(projectPolicy);
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
return Object.freeze({
|
||||
async put(
|
||||
request: LocalSecretAdministrationRequest,
|
||||
): Promise<PutEncryptedLocalSecretResult> {
|
||||
assertRequest(request);
|
||||
const nowMs = now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new LocalSecretAdministrationConfigurationError(
|
||||
'clock is invalid',
|
||||
);
|
||||
}
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = administrationPrincipal(request.principal, nowMs);
|
||||
} catch (error) {
|
||||
try {
|
||||
await audit.record(
|
||||
auditRecord({
|
||||
request,
|
||||
principal: null,
|
||||
operationId: 'secret.manage',
|
||||
outcome: 'authentication_rejected',
|
||||
reasons: ['strong_authentication_required'],
|
||||
fence: null,
|
||||
occurredAtMs: nowMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = await policy.authorize(
|
||||
principal,
|
||||
request.projectId,
|
||||
'secret.manage',
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProjectPolicyUnavailableError)) {
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
try {
|
||||
await audit.record(
|
||||
auditRecord({
|
||||
request,
|
||||
principal,
|
||||
operationId: 'secret.manage',
|
||||
outcome: 'authorization_unavailable',
|
||||
reasons: ['policy_unavailable'],
|
||||
fence: null,
|
||||
occurredAtMs: nowMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
|
||||
if (decision.effect !== 'allow') {
|
||||
try {
|
||||
await audit.record(
|
||||
auditRecord({
|
||||
request,
|
||||
principal,
|
||||
operationId: 'secret.manage',
|
||||
outcome:
|
||||
decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs: nowMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
throw new LocalSecretAdministrationAuthorizationError();
|
||||
}
|
||||
if (!decision.fence || decision.fence.bindingVersion === null) {
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
|
||||
const operationId =
|
||||
request.expectedCurrentVersion === 0
|
||||
? ('secret.create' as const)
|
||||
: ('secret.rotate' as const);
|
||||
const allowedAudit = auditRecord({
|
||||
request,
|
||||
principal,
|
||||
operationId,
|
||||
outcome: 'allowed',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs: nowMs,
|
||||
});
|
||||
|
||||
try {
|
||||
const existing =
|
||||
await mutations.resolveLocalSecretAdministrationMutation(
|
||||
request.projectId,
|
||||
request.name,
|
||||
request.mutationId,
|
||||
);
|
||||
if (existing) {
|
||||
if (!(await matchesExisting(existing, allowedAudit, request, keys))) {
|
||||
throw new LocalSecretMutationConflictError();
|
||||
}
|
||||
return result('existing', existing.envelope);
|
||||
}
|
||||
|
||||
const material = ownedLocalSecretKeyMaterial(await keys.active());
|
||||
try {
|
||||
const envelope = encryptLocalSecretEnvelope(
|
||||
{
|
||||
projectId: request.projectId,
|
||||
name: request.name,
|
||||
version: request.expectedCurrentVersion + 1,
|
||||
mutationId: request.mutationId,
|
||||
keyId: material.keyId,
|
||||
algorithm: LOCAL_SECRET_ALGORITHM,
|
||||
createdAtMs: nowMs,
|
||||
},
|
||||
request.plaintext,
|
||||
material.key,
|
||||
options.nonceFactory,
|
||||
);
|
||||
const appended = await mutations.appendAuthorizedLocalSecretEnvelope({
|
||||
expectedCurrentVersion: request.expectedCurrentVersion,
|
||||
envelope,
|
||||
subject: principal.subject,
|
||||
fence: decision.fence,
|
||||
audit: allowedAudit,
|
||||
});
|
||||
if (
|
||||
appended.status === 'existing' &&
|
||||
!(await matchesExisting(
|
||||
{ envelope: appended.envelope, audit: appended.audit },
|
||||
allowedAudit,
|
||||
request,
|
||||
keys,
|
||||
))
|
||||
) {
|
||||
throw new LocalSecretMutationConflictError();
|
||||
}
|
||||
return result(appended.status, appended.envelope);
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalSecretVersionConflictError ||
|
||||
error instanceof LocalSecretMutationConflictError ||
|
||||
error instanceof LocalSecretAuthorizationFenceConflictError ||
|
||||
error instanceof LocalSecretUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof SecurityAuditUnavailableError) {
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
throw new LocalSecretAdministrationUnavailableError();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
LocalSecurityAuditQueryUnavailableError,
|
||||
MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE,
|
||||
type ListAuthorizedLocalSecurityAuditResult,
|
||||
type LocalSecurityAuditQueryRepository,
|
||||
} from '@qinglong/runtime-core/local-security-audit-query';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
assertProjectPolicyProjectId,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
normalizeSecurityAuditQuery,
|
||||
type SecurityAuditQuery,
|
||||
} from '@qinglong/runtime-core/security-audit-query';
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export interface ListLocalSecurityAuditRequest {
|
||||
readonly authorityProjectId: string;
|
||||
readonly query: SecurityAuditQuery;
|
||||
readonly auditEventId: string;
|
||||
readonly requestId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface LocalSecurityAuditQueryService {
|
||||
list(
|
||||
request: ListLocalSecurityAuditRequest,
|
||||
): Promise<ListAuthorizedLocalSecurityAuditResult>;
|
||||
}
|
||||
|
||||
export class LocalSecurityAuditQueryConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local security audit query is invalid: ${message}`);
|
||||
this.name = 'LocalSecurityAuditQueryConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSecurityAuditQueryAuthenticationError extends Error {
|
||||
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Local security audit query requires a strong User');
|
||||
this.name = 'LocalSecurityAuditQueryAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSecurityAuditQueryAuthorizationError extends Error {
|
||||
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Local security audit query is not authorized');
|
||||
this.name = 'LocalSecurityAuditQueryAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRequest(
|
||||
value: ListLocalSecurityAuditRequest,
|
||||
): Readonly<ListLocalSecurityAuditRequest> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalSecurityAuditQueryConfigurationError(
|
||||
'request must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
['authorityProjectId', 'query', 'auditEventId', 'requestId', 'principal'],
|
||||
'request',
|
||||
);
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.authorityProjectId);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryConfigurationError(
|
||||
'authority Project identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.auditEventId) ||
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId)
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryConfigurationError(
|
||||
'audit or request identity is invalid',
|
||||
);
|
||||
}
|
||||
let query: Readonly<SecurityAuditQuery>;
|
||||
try {
|
||||
query = normalizeSecurityAuditQuery(value.query);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryConfigurationError(
|
||||
'filter, cursor, or limit is invalid',
|
||||
);
|
||||
}
|
||||
if (query.limit > MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE) {
|
||||
throw new LocalSecurityAuditQueryConfigurationError(
|
||||
'limit exceeds the local maximum of 64',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value, query });
|
||||
}
|
||||
|
||||
function strongUser(
|
||||
value: SecurityPrincipal,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(value, nowMs);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryAuthenticationError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
function auditRecord(options: {
|
||||
readonly request: Readonly<ListLocalSecurityAuditRequest>;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly outcome: SecurityAuditRecord['outcome'];
|
||||
readonly reasons: readonly string[];
|
||||
readonly fence: SecurityAuditRecord['fence'];
|
||||
readonly occurredAtMs: number;
|
||||
}): Readonly<SecurityAuditRecord> {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: options.request.auditEventId,
|
||||
requestId: options.request.requestId,
|
||||
operationId: 'security.audit.list',
|
||||
projectId: options.request.authorityProjectId,
|
||||
subject: options.principal.subject,
|
||||
authenticationId: options.principal.authenticationId,
|
||||
outcome: options.outcome,
|
||||
reasons: options.reasons,
|
||||
fence: options.fence,
|
||||
occurredAtMs: options.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalSecurityAuditQueryService(
|
||||
projectPolicy: ProjectPolicyRepository,
|
||||
repository: LocalSecurityAuditQueryRepository,
|
||||
options: { readonly now?: () => number } = {},
|
||||
): LocalSecurityAuditQueryService {
|
||||
if (
|
||||
!projectPolicy ||
|
||||
typeof projectPolicy.resolve !== 'function' ||
|
||||
!repository ||
|
||||
typeof repository.listAuthorized !== 'function' ||
|
||||
typeof repository.record !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryConfigurationError(
|
||||
'dependencies are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const policy = new ProjectPolicyEngine(projectPolicy);
|
||||
return Object.freeze({
|
||||
async list(input: ListLocalSecurityAuditRequest) {
|
||||
const request = normalizeRequest(input);
|
||||
const occurredAtMs = now();
|
||||
const principal = strongUser(request.principal, occurredAtMs);
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(
|
||||
principal,
|
||||
request.authorityProjectId,
|
||||
'project.manage',
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
await repository.record(
|
||||
auditRecord({
|
||||
request,
|
||||
principal,
|
||||
outcome: 'authorization_unavailable',
|
||||
reasons: ['policy_unavailable'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryUnavailableError();
|
||||
}
|
||||
throw new LocalSecurityAuditQueryUnavailableError();
|
||||
}
|
||||
if (decision.effect !== 'allow' || !decision.fence?.bindingVersion) {
|
||||
try {
|
||||
await repository.record(
|
||||
auditRecord({
|
||||
request,
|
||||
principal,
|
||||
outcome:
|
||||
decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryUnavailableError();
|
||||
}
|
||||
throw new LocalSecurityAuditQueryAuthorizationError();
|
||||
}
|
||||
try {
|
||||
return await repository.listAuthorized({
|
||||
query: request.query,
|
||||
authorization: {
|
||||
authorityProjectId: request.authorityProjectId,
|
||||
actor: principal.subject,
|
||||
fence: decision.fence,
|
||||
},
|
||||
audit: auditRecord({
|
||||
request,
|
||||
principal,
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_query'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecurityAuditQueryUnavailableError();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
LocalSecurityAuditCompactionMutationConflictError,
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
LocalSecurityAuditRetentionUnavailableError,
|
||||
MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE,
|
||||
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
type CompactAuthorizedLocalSecurityAuditResult,
|
||||
type LocalSecurityAuditRetentionRepository,
|
||||
} from '@qinglong/runtime-core/local-security-audit-retention';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
assertProjectPolicyProjectId,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export interface CompactLocalSecurityAuditRequest {
|
||||
readonly authorityProjectId: string;
|
||||
readonly retentionMs: number;
|
||||
readonly eligibleBeforeMs: number;
|
||||
readonly limit: number;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface LocalSecurityAuditRetentionService {
|
||||
compact(
|
||||
request: CompactLocalSecurityAuditRequest,
|
||||
): Promise<CompactAuthorizedLocalSecurityAuditResult>;
|
||||
}
|
||||
|
||||
export class LocalSecurityAuditRetentionConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local security audit retention is invalid: ${message}`);
|
||||
this.name = 'LocalSecurityAuditRetentionConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSecurityAuditRetentionAuthenticationError extends Error {
|
||||
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Local security audit retention requires a strong User');
|
||||
this.name = 'LocalSecurityAuditRetentionAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSecurityAuditRetentionAuthorizationError extends Error {
|
||||
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Local security audit retention is not authorized');
|
||||
this.name = 'LocalSecurityAuditRetentionAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new LocalSecurityAuditRetentionConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function request(
|
||||
value: CompactLocalSecurityAuditRequest,
|
||||
nowMs: number,
|
||||
): Readonly<CompactLocalSecurityAuditRequest> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalSecurityAuditRetentionConfigurationError(
|
||||
'request must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'authorityProjectId',
|
||||
'retentionMs',
|
||||
'eligibleBeforeMs',
|
||||
'limit',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'failureAuditEventId',
|
||||
'principal',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.authorityProjectId);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditRetentionConfigurationError(
|
||||
'authority Project identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!UUID_V4_PATTERN.test(value.mutationId) ||
|
||||
!UUID_V4_PATTERN.test(value.failureAuditEventId) ||
|
||||
value.mutationId === value.failureAuditEventId ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
||||
!Number.isSafeInteger(value.retentionMs) ||
|
||||
value.retentionMs < MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
|
||||
value.retentionMs > MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
|
||||
!Number.isSafeInteger(value.eligibleBeforeMs) ||
|
||||
value.eligibleBeforeMs < 0 ||
|
||||
value.eligibleBeforeMs + value.retentionMs > nowMs ||
|
||||
!Number.isSafeInteger(value.limit) ||
|
||||
value.limit < 1 ||
|
||||
value.limit > MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE
|
||||
) {
|
||||
throw new LocalSecurityAuditRetentionConfigurationError(
|
||||
'identity, retention fence, or limit is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function strongUser(
|
||||
value: SecurityPrincipal,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(value, nowMs);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditRetentionAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new LocalSecurityAuditRetentionAuthenticationError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
function auditRecord(options: {
|
||||
readonly eventId: string;
|
||||
readonly request: Readonly<CompactLocalSecurityAuditRequest>;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly outcome: SecurityAuditRecord['outcome'];
|
||||
readonly reasons: readonly string[];
|
||||
readonly fence: SecurityAuditRecord['fence'];
|
||||
readonly occurredAtMs: number;
|
||||
}): Readonly<SecurityAuditRecord> {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: options.eventId,
|
||||
requestId: options.request.requestId,
|
||||
operationId: 'security.audit.compact',
|
||||
projectId: options.request.authorityProjectId,
|
||||
subject: options.principal.subject,
|
||||
authenticationId: options.principal.authenticationId,
|
||||
outcome: options.outcome,
|
||||
reasons: options.reasons,
|
||||
fence: options.fence,
|
||||
occurredAtMs: options.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalSecurityAuditRetentionService(
|
||||
projectPolicy: ProjectPolicyRepository,
|
||||
repository: LocalSecurityAuditRetentionRepository,
|
||||
options: { readonly now?: () => number } = {},
|
||||
): LocalSecurityAuditRetentionService {
|
||||
if (
|
||||
!projectPolicy ||
|
||||
typeof projectPolicy.resolve !== 'function' ||
|
||||
!repository ||
|
||||
typeof repository.resolveCompaction !== 'function' ||
|
||||
typeof repository.compactAuthorized !== 'function' ||
|
||||
typeof repository.record !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new LocalSecurityAuditRetentionConfigurationError(
|
||||
'dependencies are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const policy = new ProjectPolicyEngine(projectPolicy);
|
||||
return Object.freeze({
|
||||
async compact(input: CompactLocalSecurityAuditRequest) {
|
||||
const occurredAtMs = now();
|
||||
if (!Number.isSafeInteger(occurredAtMs) || occurredAtMs < 0) {
|
||||
throw new LocalSecurityAuditRetentionConfigurationError(
|
||||
'trusted clock is invalid',
|
||||
);
|
||||
}
|
||||
const command = request(input, occurredAtMs);
|
||||
const principal = strongUser(command.principal, occurredAtMs);
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(
|
||||
principal,
|
||||
command.authorityProjectId,
|
||||
'project.manage',
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
await repository.record(
|
||||
auditRecord({
|
||||
eventId: command.failureAuditEventId,
|
||||
request: command,
|
||||
principal,
|
||||
outcome: 'authorization_unavailable',
|
||||
reasons: ['policy_unavailable'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
if (decision.effect !== 'allow' || !decision.fence?.bindingVersion) {
|
||||
try {
|
||||
await repository.record(
|
||||
auditRecord({
|
||||
eventId: command.failureAuditEventId,
|
||||
request: command,
|
||||
principal,
|
||||
outcome:
|
||||
decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
throw new LocalSecurityAuditRetentionAuthorizationError();
|
||||
}
|
||||
try {
|
||||
return await repository.compactAuthorized({
|
||||
mutationId: command.mutationId,
|
||||
requestId: command.requestId,
|
||||
retentionMs: command.retentionMs,
|
||||
eligibleBeforeMs: command.eligibleBeforeMs,
|
||||
limit: command.limit,
|
||||
authorization: {
|
||||
authorityProjectId: command.authorityProjectId,
|
||||
actor: principal.subject,
|
||||
fence: decision.fence,
|
||||
},
|
||||
audit: auditRecord({
|
||||
eventId: command.mutationId,
|
||||
request: command,
|
||||
principal,
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_compaction'],
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError ||
|
||||
error instanceof LocalSecurityAuditCompactionMutationConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecurityAuditRetentionUnavailableError();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user