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:
@@ -0,0 +1,510 @@
|
||||
// Security Administration owns identity and API credential mutations.
|
||||
import { randomBytes as nodeRandomBytes } from 'node:crypto';
|
||||
import {
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
type ApiCredentialAdministrationRepository,
|
||||
type AppendApiCredentialResult,
|
||||
type ResolvedApiCredentialMutation,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
import {
|
||||
apiCredentialSecretDigest,
|
||||
assertApiCredentialPepper,
|
||||
formatApiCredentialToken,
|
||||
} from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
type ApiCredentialRecord,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import {
|
||||
type AppendIdentitySubjectResult,
|
||||
IdentityAdministrationMutationConflictError,
|
||||
type IdentityAdministrationOperation,
|
||||
type IdentityAdministrationRepository,
|
||||
type ResolvedIdentitySubjectMutation,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const MAX_CREDENTIAL_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export interface ClusterAdministrationOptions {
|
||||
readonly now?: () => number;
|
||||
readonly randomBytes?: (size: number) => Buffer;
|
||||
}
|
||||
|
||||
export interface IdentityAdministrationRequest {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface CredentialAdministrationRequest {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly credentialId: string;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ActiveCredentialAdministrationRequest
|
||||
extends CredentialAdministrationRequest {
|
||||
readonly notBeforeAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface CredentialAdministrationResult
|
||||
extends AppendApiCredentialResult {
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
export interface ClusterAdministrationService {
|
||||
registerIdentity(
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult>;
|
||||
enableIdentity(
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult>;
|
||||
disableIdentity(
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult>;
|
||||
issueCredential(
|
||||
request: ActiveCredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult>;
|
||||
rotateCredential(
|
||||
request: ActiveCredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult>;
|
||||
revokeCredential(
|
||||
request: CredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult>;
|
||||
}
|
||||
|
||||
export class ClusterAdministrationConfigurationError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Cluster administration configuration is invalid: ${message}`);
|
||||
this.name = 'ClusterAdministrationConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAdministrationAuthenticationError extends Error {
|
||||
readonly code = 'CLUSTER_ADMINISTRATION_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Cluster administration requires a strong principal');
|
||||
this.name = 'ClusterAdministrationAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterAdministrationSubjectUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_ADMINISTRATION_SUBJECT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Cluster administration subject is unavailable');
|
||||
this.name = 'ClusterAdministrationSubjectUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(value: unknown, name: string): asserts value is object {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterAdministrationConfigurationError(`${name} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
name: string,
|
||||
allowed: ReadonlySet<string>,
|
||||
): void {
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
`${name} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_REQUEST_KEYS = new Set([
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'expectedCurrentVersion',
|
||||
'subject',
|
||||
'principal',
|
||||
]);
|
||||
const CREDENTIAL_REQUEST_KEYS = new Set([
|
||||
...IDENTITY_REQUEST_KEYS,
|
||||
'credentialId',
|
||||
]);
|
||||
const ACTIVE_CREDENTIAL_REQUEST_KEYS = new Set([
|
||||
...CREDENTIAL_REQUEST_KEYS,
|
||||
'notBeforeAtMs',
|
||||
'expiresAtMs',
|
||||
]);
|
||||
|
||||
function administrationPrincipal(
|
||||
principal: SecurityPrincipal,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let normalized: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
normalized = normalizeSecurityPrincipal(principal, nowMs);
|
||||
} catch {
|
||||
throw new ClusterAdministrationAuthenticationError();
|
||||
}
|
||||
const human =
|
||||
normalized.subject.type === 'user' &&
|
||||
STRONG_USER_ASSURANCES.has(normalized.assurance);
|
||||
const system =
|
||||
normalized.subject.type === 'system' && normalized.assurance === 'service';
|
||||
if (!human && !system) {
|
||||
throw new ClusterAdministrationAuthenticationError();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function audit(
|
||||
mutationId: string,
|
||||
requestId: string,
|
||||
operationId: string,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
reason: 'identity_admin' | 'credential_admin',
|
||||
nowMs: number,
|
||||
): SecurityAuditRecord {
|
||||
return {
|
||||
eventId: mutationId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId: null,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: [reason],
|
||||
fence: null,
|
||||
occurredAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function sameReplayAudit(
|
||||
stored: Readonly<SecurityAuditRecord>,
|
||||
expected: Readonly<SecurityAuditRecord>,
|
||||
): boolean {
|
||||
const { occurredAtMs: _storedOccurredAtMs, ...storedSemantic } = stored;
|
||||
const { occurredAtMs: _expectedOccurredAtMs, ...expectedSemantic } = expected;
|
||||
return JSON.stringify(storedSemantic) === JSON.stringify(expectedSemantic);
|
||||
}
|
||||
|
||||
function sameIdentityReplay(
|
||||
stored: ResolvedIdentitySubjectMutation,
|
||||
operation: IdentityAdministrationOperation,
|
||||
request: IdentityAdministrationRequest,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
return (
|
||||
stored.mutation.operation === operation &&
|
||||
sameSubject(stored.mutation.subject, request.subject) &&
|
||||
stored.mutation.subjectVersion === request.expectedCurrentVersion + 1 &&
|
||||
stored.mutation.expectedPreviousVersion ===
|
||||
request.expectedCurrentVersion &&
|
||||
stored.mutation.status ===
|
||||
(operation === 'disable' ? 'disabled' : 'active') &&
|
||||
sameSubject(stored.mutation.changedBy, principal.subject) &&
|
||||
sameReplayAudit(
|
||||
stored.audit,
|
||||
audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`identity.${operation}`,
|
||||
principal,
|
||||
'identity_admin',
|
||||
nowMs,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function sameCredentialReplay(
|
||||
stored: ResolvedApiCredentialMutation,
|
||||
operation: 'issue' | 'rotate' | 'revoke',
|
||||
request:
|
||||
| ActiveCredentialAdministrationRequest
|
||||
| CredentialAdministrationRequest,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
const active =
|
||||
operation === 'revoke'
|
||||
? null
|
||||
: (request as ActiveCredentialAdministrationRequest);
|
||||
return (
|
||||
stored.mutation.operation === operation &&
|
||||
stored.mutation.credentialId === request.credentialId &&
|
||||
stored.mutation.credentialVersion === request.expectedCurrentVersion + 1 &&
|
||||
stored.mutation.expectedPreviousVersion ===
|
||||
request.expectedCurrentVersion &&
|
||||
sameSubject(stored.mutation.changedBy, principal.subject) &&
|
||||
stored.credential.state ===
|
||||
(operation === 'revoke' ? 'revoked' : 'active') &&
|
||||
sameSubject(stored.credential.subject, request.subject) &&
|
||||
(active === null ||
|
||||
(stored.credential.notBeforeAtMs === active.notBeforeAtMs &&
|
||||
stored.credential.expiresAtMs === active.expiresAtMs)) &&
|
||||
sameReplayAudit(
|
||||
stored.audit,
|
||||
audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`credential.${operation}`,
|
||||
principal,
|
||||
'credential_admin',
|
||||
nowMs,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function createClusterAdministrationService(
|
||||
identities: IdentityAdministrationRepository,
|
||||
credentials: ApiCredentialAdministrationRepository,
|
||||
pepper: string,
|
||||
options: ClusterAdministrationOptions = {},
|
||||
): ClusterAdministrationService {
|
||||
if (
|
||||
!identities ||
|
||||
typeof identities.resolve !== 'function' ||
|
||||
typeof identities.resolveMutation !== 'function' ||
|
||||
typeof identities.append !== 'function'
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'identity repository is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!credentials ||
|
||||
typeof credentials.resolveMutation !== 'function' ||
|
||||
typeof credentials.append !== 'function'
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'credential repository is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertApiCredentialPepper(pepper);
|
||||
} catch {
|
||||
throw new ClusterAdministrationConfigurationError('pepper is invalid');
|
||||
}
|
||||
exactObject(options, 'options');
|
||||
const optionKeys = Object.keys(options);
|
||||
if (optionKeys.some((key) => key !== 'now' && key !== 'randomBytes')) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'options shape is invalid',
|
||||
);
|
||||
}
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw new ClusterAdministrationConfigurationError('now is invalid');
|
||||
}
|
||||
if (
|
||||
options.randomBytes !== undefined &&
|
||||
typeof options.randomBytes !== 'function'
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError('randomBytes is invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const randomBytes = options.randomBytes ?? nodeRandomBytes;
|
||||
|
||||
const mutateIdentity = async (
|
||||
operation: IdentityAdministrationOperation,
|
||||
request: IdentityAdministrationRequest,
|
||||
): Promise<AppendIdentitySubjectResult> => {
|
||||
exactObject(request, 'identity request');
|
||||
exactKeys(request, 'identity request', IDENTITY_REQUEST_KEYS);
|
||||
const nowMs = now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new ClusterAdministrationConfigurationError('clock is invalid');
|
||||
}
|
||||
const principal = administrationPrincipal(request.principal, nowMs);
|
||||
const existing = await identities.resolveMutation(request.mutationId);
|
||||
if (existing) {
|
||||
if (!sameIdentityReplay(existing, operation, request, principal, nowMs)) {
|
||||
throw new IdentityAdministrationMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
identity: existing.identity,
|
||||
mutation: existing.mutation,
|
||||
});
|
||||
}
|
||||
return identities.append({
|
||||
expectedCurrentVersion: request.expectedCurrentVersion,
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation,
|
||||
subject: request.subject,
|
||||
subjectVersion: request.expectedCurrentVersion + 1,
|
||||
expectedPreviousVersion: request.expectedCurrentVersion,
|
||||
status: operation === 'disable' ? 'disabled' : 'active',
|
||||
changedBy: principal.subject,
|
||||
createdAtMs: nowMs,
|
||||
},
|
||||
audit: audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`identity.${operation}`,
|
||||
principal,
|
||||
'identity_admin',
|
||||
nowMs,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const mutateCredential = async (
|
||||
operation: 'issue' | 'rotate' | 'revoke',
|
||||
request:
|
||||
| ActiveCredentialAdministrationRequest
|
||||
| CredentialAdministrationRequest,
|
||||
): Promise<CredentialAdministrationResult> => {
|
||||
exactObject(request, 'credential request');
|
||||
exactKeys(
|
||||
request,
|
||||
'credential request',
|
||||
operation === 'revoke'
|
||||
? CREDENTIAL_REQUEST_KEYS
|
||||
: ACTIVE_CREDENTIAL_REQUEST_KEYS,
|
||||
);
|
||||
const nowMs = now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new ClusterAdministrationConfigurationError('clock is invalid');
|
||||
}
|
||||
const principal = administrationPrincipal(request.principal, nowMs);
|
||||
const existing = await credentials.resolveMutation(request.mutationId);
|
||||
if (existing) {
|
||||
if (
|
||||
!sameCredentialReplay(existing, operation, request, principal, nowMs)
|
||||
) {
|
||||
throw new ApiCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credential: existing.credential,
|
||||
mutation: existing.mutation,
|
||||
token: null,
|
||||
});
|
||||
}
|
||||
const identity = await identities.resolve(request.subject);
|
||||
if (!identity || (operation !== 'revoke' && identity.status !== 'active')) {
|
||||
throw new ClusterAdministrationSubjectUnavailableError();
|
||||
}
|
||||
|
||||
let secret: Buffer | undefined;
|
||||
let secretBase64Url: string | null = null;
|
||||
let secretDigest = REVOKED_API_CREDENTIAL_DIGEST;
|
||||
let notBeforeAtMs = nowMs;
|
||||
let expiresAtMs = nowMs + 1;
|
||||
if (operation !== 'revoke') {
|
||||
const activeRequest = request as ActiveCredentialAdministrationRequest;
|
||||
notBeforeAtMs = activeRequest.notBeforeAtMs;
|
||||
expiresAtMs = activeRequest.expiresAtMs;
|
||||
if (
|
||||
!Number.isSafeInteger(notBeforeAtMs) ||
|
||||
notBeforeAtMs < nowMs ||
|
||||
!Number.isSafeInteger(expiresAtMs) ||
|
||||
expiresAtMs <= notBeforeAtMs ||
|
||||
expiresAtMs - nowMs > MAX_CREDENTIAL_LIFETIME_MS
|
||||
) {
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'credential lifetime is invalid',
|
||||
);
|
||||
}
|
||||
secret = randomBytes(32);
|
||||
if (!Buffer.isBuffer(secret) || secret.byteLength !== 32) {
|
||||
if (Buffer.isBuffer(secret)) secret.fill(0);
|
||||
throw new ClusterAdministrationConfigurationError(
|
||||
'randomBytes returned invalid secret material',
|
||||
);
|
||||
}
|
||||
try {
|
||||
secretBase64Url = secret.toString('base64url');
|
||||
secretDigest = apiCredentialSecretDigest(
|
||||
pepper,
|
||||
request.credentialId,
|
||||
secretBase64Url,
|
||||
);
|
||||
} finally {
|
||||
secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
const credential: ApiCredentialRecord = {
|
||||
credentialId: request.credentialId,
|
||||
version: request.expectedCurrentVersion + 1,
|
||||
pepperKeyId: LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
||||
state: operation === 'revoke' ? 'revoked' : 'active',
|
||||
subject: request.subject,
|
||||
subjectStatus: identity.status,
|
||||
secretDigest,
|
||||
createdAtMs: nowMs,
|
||||
notBeforeAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
const result = await credentials.append({
|
||||
expectedCurrentVersion: request.expectedCurrentVersion,
|
||||
credential,
|
||||
mutation: {
|
||||
mutationId: request.mutationId,
|
||||
operation,
|
||||
credentialId: request.credentialId,
|
||||
credentialVersion: request.expectedCurrentVersion + 1,
|
||||
expectedPreviousVersion: request.expectedCurrentVersion,
|
||||
changedBy: principal.subject,
|
||||
createdAtMs: nowMs,
|
||||
},
|
||||
audit: audit(
|
||||
request.mutationId,
|
||||
request.requestId,
|
||||
`credential.${operation}`,
|
||||
principal,
|
||||
'credential_admin',
|
||||
nowMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
...result,
|
||||
token:
|
||||
result.status === 'inserted' && secretBase64Url
|
||||
? formatApiCredentialToken(request.credentialId, secretBase64Url)
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
registerIdentity: (request: IdentityAdministrationRequest) =>
|
||||
mutateIdentity('register', request),
|
||||
enableIdentity: (request: IdentityAdministrationRequest) =>
|
||||
mutateIdentity('enable', request),
|
||||
disableIdentity: (request: IdentityAdministrationRequest) =>
|
||||
mutateIdentity('disable', request),
|
||||
issueCredential: (request: ActiveCredentialAdministrationRequest) =>
|
||||
mutateCredential('issue', request),
|
||||
rotateCredential: (request: ActiveCredentialAdministrationRequest) =>
|
||||
mutateCredential('rotate', request),
|
||||
revokeCredential: (request: CredentialAdministrationRequest) =>
|
||||
mutateCredential('revoke', request),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user