mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
559 lines
18 KiB
TypeScript
559 lines
18 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { basename } from 'node:path';
|
|
|
|
import {
|
|
createSingletonApiCredentialPepperKeyring,
|
|
normalizeApiCredentialPepperKeyring,
|
|
type ApiCredentialPepperKeyring,
|
|
} from '@qinglong/runtime-core/api-credential-pepper-keyring';
|
|
import { LEGACY_API_CREDENTIAL_PEPPER_KEY_ID } from '@qinglong/runtime-core/api-credential';
|
|
import {
|
|
normalizeApiCredentialPepperReferenceKeyId,
|
|
normalizeApiCredentialPepperReferenceLimit,
|
|
type ApiCredentialPepperReferenceRepository,
|
|
} from '@qinglong/runtime-core/api-credential-pepper-reference';
|
|
import { normalizeIdentityAdministrationSubject } from '@qinglong/runtime-core/identity-administration';
|
|
import {
|
|
normalizeSecurityAuditQuery,
|
|
type SecurityAuditQuery,
|
|
type SecurityAuditQueryPage,
|
|
type SecurityAuditQueryRepository,
|
|
} from '@qinglong/runtime-core/security-audit-query';
|
|
import type {
|
|
SecurityPrincipal,
|
|
SecuritySubject,
|
|
} from '@qinglong/runtime-core/security';
|
|
import { type ClusterAdministrationService } from './clusterAdministration';
|
|
import {
|
|
CLUSTER_ADMINISTRATION_COMMAND_RUNTIME_DEPENDENCIES,
|
|
ClusterAdministrationCommandError,
|
|
clusterAdministrationCommandFileBeforeAdmission,
|
|
normalizeClusterAdministrationCommandPaths,
|
|
publishClusterAdministrationCredentialDelivery,
|
|
} from './clusterAdministrationCommandRuntime';
|
|
|
|
export {
|
|
ClusterAdministrationCommandError,
|
|
publishClusterAdministrationCredentialDelivery,
|
|
};
|
|
|
|
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 MAX_VERSION = 2_147_483_646;
|
|
const MAX_COMMAND_BYTES = 64 * 1024;
|
|
const MAX_ASSERTION_BYTES = 16 * 1024;
|
|
const MAX_PEPPER_BYTES = 256;
|
|
const MAX_PEPPER_KEYRING_BYTES = 2 * 1024;
|
|
|
|
export type ClusterAdministrationCommandOperation =
|
|
| 'identity.register'
|
|
| 'identity.enable'
|
|
| 'identity.disable'
|
|
| 'credential.issue'
|
|
| 'credential.rotate'
|
|
| 'credential.revoke'
|
|
| 'pepper.references'
|
|
| 'audit.list';
|
|
|
|
interface BaseMutationRequest {
|
|
readonly mutationId: string;
|
|
readonly requestId: string;
|
|
readonly expectedCurrentVersion: number;
|
|
readonly subject: SecuritySubject;
|
|
}
|
|
|
|
interface IdentityCommand {
|
|
readonly schemaVersion: 1;
|
|
readonly operation:
|
|
| 'identity.register'
|
|
| 'identity.enable'
|
|
| 'identity.disable';
|
|
readonly request: BaseMutationRequest;
|
|
}
|
|
|
|
interface CredentialCommand {
|
|
readonly schemaVersion: 1;
|
|
readonly operation:
|
|
| 'credential.issue'
|
|
| 'credential.rotate'
|
|
| 'credential.revoke';
|
|
readonly request: BaseMutationRequest & {
|
|
readonly credentialId: string;
|
|
readonly notBeforeAtMs?: number;
|
|
readonly expiresAtMs?: number;
|
|
};
|
|
}
|
|
|
|
interface AuditCommand {
|
|
readonly schemaVersion: 1;
|
|
readonly operation: 'audit.list';
|
|
readonly request: SecurityAuditQuery;
|
|
}
|
|
|
|
interface PepperReferenceCommand {
|
|
readonly schemaVersion: 1;
|
|
readonly operation: 'pepper.references';
|
|
readonly request: Readonly<{
|
|
readonly pepperKeyId: string;
|
|
readonly limit: number;
|
|
}>;
|
|
}
|
|
|
|
export type ClusterAdministrationCommand =
|
|
| IdentityCommand
|
|
| CredentialCommand
|
|
| PepperReferenceCommand
|
|
| AuditCommand;
|
|
|
|
export interface ClusterAdministrationCommandPaths {
|
|
readonly commandFile: string;
|
|
readonly assertionFile: string;
|
|
readonly keysetFile: string;
|
|
readonly pepperFile?: string;
|
|
readonly pepperKeyringFile?: string;
|
|
readonly deliveryFile?: string;
|
|
}
|
|
|
|
export type ClusterAdministrationCommandResult =
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: IdentityCommand['operation'];
|
|
status: 'inserted' | 'existing';
|
|
subject: Readonly<SecuritySubject>;
|
|
version: number;
|
|
identityStatus: 'active' | 'disabled';
|
|
}>
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: CredentialCommand['operation'];
|
|
status: 'inserted' | 'existing';
|
|
subject: Readonly<SecuritySubject>;
|
|
credentialId: string;
|
|
version: number;
|
|
state: 'active' | 'revoked';
|
|
delivery?: Readonly<{ fileName: string; digest: string }>;
|
|
}>
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: 'audit.list';
|
|
page: Readonly<SecurityAuditQueryPage>;
|
|
}>
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: 'pepper.references';
|
|
pepperKeyId: string;
|
|
observedAtMs: number;
|
|
credentialIds: readonly string[];
|
|
hasMore: boolean;
|
|
}>;
|
|
|
|
export interface ClusterAdministrationCommandAuthority {
|
|
readonly administration: ClusterAdministrationService;
|
|
readonly audit: SecurityAuditQueryRepository;
|
|
readonly pepperReferences: ApiCredentialPepperReferenceRepository;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export interface ClusterAdministrationCommandDependencies {
|
|
readonly openAuthority: (
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
pepperKeyring: Readonly<ApiCredentialPepperKeyring>,
|
|
) => Promise<Readonly<ClusterAdministrationCommandAuthority>>;
|
|
readonly authenticate: (
|
|
keysetFile: string,
|
|
assertion: string,
|
|
) => Promise<Readonly<SecurityPrincipal>>;
|
|
readonly readFile: (
|
|
filePath: string,
|
|
maximumBytes: number,
|
|
privateMaterial: boolean,
|
|
) => Buffer;
|
|
readonly publishDelivery: (filePath: string, bytes: Buffer) => void;
|
|
}
|
|
|
|
function exactObject(
|
|
value: unknown,
|
|
expectedKeys: readonly string[],
|
|
label: string,
|
|
): asserts value is Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new ClusterAdministrationCommandError(`${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 ClusterAdministrationCommandError(`${label} shape is invalid`);
|
|
}
|
|
}
|
|
|
|
function strictUtf8(bytes: Buffer, label: string): string {
|
|
try {
|
|
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
} catch (error) {
|
|
throw new ClusterAdministrationCommandError(
|
|
`${label} must be strict UTF-8`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeMutationRequest(
|
|
value: unknown,
|
|
activeCredential: boolean,
|
|
credential: boolean,
|
|
): BaseMutationRequest & {
|
|
readonly credentialId?: string;
|
|
readonly notBeforeAtMs?: number;
|
|
readonly expiresAtMs?: number;
|
|
} {
|
|
exactObject(
|
|
value,
|
|
[
|
|
'expectedCurrentVersion',
|
|
'mutationId',
|
|
'requestId',
|
|
'subject',
|
|
...(credential ? ['credentialId'] : []),
|
|
...(activeCredential ? ['notBeforeAtMs', 'expiresAtMs'] : []),
|
|
],
|
|
'request',
|
|
);
|
|
let subject: Readonly<SecuritySubject>;
|
|
try {
|
|
subject = normalizeIdentityAdministrationSubject(
|
|
value.subject as SecuritySubject,
|
|
);
|
|
} catch (error) {
|
|
throw new ClusterAdministrationCommandError('subject is invalid', error);
|
|
}
|
|
if (
|
|
typeof value.mutationId !== 'string' ||
|
|
!UUID_V4_PATTERN.test(value.mutationId) ||
|
|
typeof value.requestId !== 'string' ||
|
|
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
|
!Number.isSafeInteger(value.expectedCurrentVersion) ||
|
|
(value.expectedCurrentVersion as number) < 0 ||
|
|
(value.expectedCurrentVersion as number) > MAX_VERSION ||
|
|
(credential &&
|
|
(typeof value.credentialId !== 'string' ||
|
|
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.credentialId))) ||
|
|
(activeCredential &&
|
|
(!Number.isSafeInteger(value.notBeforeAtMs) ||
|
|
(value.notBeforeAtMs as number) < 0 ||
|
|
!Number.isSafeInteger(value.expiresAtMs) ||
|
|
(value.expiresAtMs as number) <= (value.notBeforeAtMs as number)))
|
|
) {
|
|
throw new ClusterAdministrationCommandError('mutation request is invalid');
|
|
}
|
|
return Object.freeze({
|
|
mutationId: value.mutationId,
|
|
requestId: value.requestId,
|
|
expectedCurrentVersion: value.expectedCurrentVersion as number,
|
|
subject,
|
|
...(credential ? { credentialId: value.credentialId as string } : {}),
|
|
...(activeCredential
|
|
? {
|
|
notBeforeAtMs: value.notBeforeAtMs as number,
|
|
expiresAtMs: value.expiresAtMs as number,
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
export function normalizeClusterAdministrationCommand(
|
|
value: unknown,
|
|
): Readonly<ClusterAdministrationCommand> {
|
|
exactObject(value, ['operation', 'request', 'schemaVersion'], 'command');
|
|
const operations: readonly ClusterAdministrationCommandOperation[] = [
|
|
'identity.register',
|
|
'identity.enable',
|
|
'identity.disable',
|
|
'credential.issue',
|
|
'credential.rotate',
|
|
'credential.revoke',
|
|
'pepper.references',
|
|
'audit.list',
|
|
];
|
|
if (
|
|
value.schemaVersion !== 1 ||
|
|
typeof value.operation !== 'string' ||
|
|
!operations.includes(
|
|
value.operation as ClusterAdministrationCommandOperation,
|
|
)
|
|
) {
|
|
throw new ClusterAdministrationCommandError(
|
|
'command version or operation is invalid',
|
|
);
|
|
}
|
|
const operation = value.operation as ClusterAdministrationCommandOperation;
|
|
if (operation === 'pepper.references') {
|
|
exactObject(value.request, ['limit', 'pepperKeyId'], 'pepper reference');
|
|
try {
|
|
return Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation,
|
|
request: Object.freeze({
|
|
pepperKeyId: normalizeApiCredentialPepperReferenceKeyId(
|
|
value.request.pepperKeyId as string,
|
|
),
|
|
limit: normalizeApiCredentialPepperReferenceLimit(
|
|
value.request.limit as number,
|
|
),
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
throw new ClusterAdministrationCommandError(
|
|
'pepper reference query is invalid',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
if (operation === 'audit.list') {
|
|
let request: Readonly<SecurityAuditQuery>;
|
|
try {
|
|
request = normalizeSecurityAuditQuery(
|
|
value.request as SecurityAuditQuery,
|
|
);
|
|
} catch (error) {
|
|
throw new ClusterAdministrationCommandError(
|
|
'audit query is invalid',
|
|
error,
|
|
);
|
|
}
|
|
return Object.freeze({ schemaVersion: 1, operation, request });
|
|
}
|
|
const credential = operation.startsWith('credential.');
|
|
const activeCredential =
|
|
operation === 'credential.issue' || operation === 'credential.rotate';
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
operation,
|
|
request: normalizeMutationRequest(
|
|
value.request,
|
|
activeCredential,
|
|
credential,
|
|
),
|
|
} as ClusterAdministrationCommand);
|
|
}
|
|
|
|
function parseCommand(bytes: Buffer): Readonly<ClusterAdministrationCommand> {
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(strictUtf8(bytes, 'command file'));
|
|
} catch (error) {
|
|
if (error instanceof ClusterAdministrationCommandError) throw error;
|
|
throw new ClusterAdministrationCommandError(
|
|
'command file must contain JSON',
|
|
error,
|
|
);
|
|
}
|
|
return normalizeClusterAdministrationCommand(value);
|
|
}
|
|
|
|
function credentialDelivery(
|
|
command: CredentialCommand,
|
|
token: string,
|
|
result: Awaited<ReturnType<ClusterAdministrationService['issueCredential']>>,
|
|
): Buffer {
|
|
return Buffer.from(
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
kind: 'qinglong3-cluster-api-credential-delivery',
|
|
operation: command.operation,
|
|
mutationId: command.request.mutationId,
|
|
requestId: command.request.requestId,
|
|
credentialId: result.credential.credentialId,
|
|
subject: result.credential.subject,
|
|
version: result.credential.version,
|
|
token,
|
|
notBeforeAtMs: result.credential.notBeforeAtMs,
|
|
expiresAtMs: result.credential.expiresAtMs,
|
|
})}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
export function createClusterAdministrationCommandRunner(
|
|
dependencies: ClusterAdministrationCommandDependencies = CLUSTER_ADMINISTRATION_COMMAND_RUNTIME_DEPENDENCIES,
|
|
): Readonly<{
|
|
run(
|
|
paths: ClusterAdministrationCommandPaths,
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
): Promise<Readonly<ClusterAdministrationCommandResult>>;
|
|
}> {
|
|
exactObject(
|
|
dependencies,
|
|
['authenticate', 'openAuthority', 'publishDelivery', 'readFile'],
|
|
'dependencies',
|
|
);
|
|
if (
|
|
typeof dependencies.openAuthority !== 'function' ||
|
|
typeof dependencies.authenticate !== 'function' ||
|
|
typeof dependencies.readFile !== 'function' ||
|
|
typeof dependencies.publishDelivery !== 'function'
|
|
) {
|
|
throw new ClusterAdministrationCommandError('dependencies are invalid');
|
|
}
|
|
return Object.freeze({
|
|
async run(pathsValue, environment) {
|
|
const commandFile =
|
|
clusterAdministrationCommandFileBeforeAdmission(pathsValue);
|
|
const commandBytes = dependencies.readFile(
|
|
commandFile,
|
|
MAX_COMMAND_BYTES,
|
|
true,
|
|
);
|
|
let command: Readonly<ClusterAdministrationCommand>;
|
|
try {
|
|
command = parseCommand(commandBytes);
|
|
} finally {
|
|
commandBytes.fill(0);
|
|
}
|
|
const requiresDelivery =
|
|
command.operation === 'credential.issue' ||
|
|
command.operation === 'credential.rotate';
|
|
const paths = normalizeClusterAdministrationCommandPaths(
|
|
pathsValue,
|
|
requiresDelivery,
|
|
);
|
|
const assertionBytes = dependencies.readFile(
|
|
paths.assertionFile,
|
|
MAX_ASSERTION_BYTES,
|
|
true,
|
|
);
|
|
const pepperKeyringBytes = dependencies.readFile(
|
|
paths.pepperKeyringFile ?? paths.pepperFile!,
|
|
paths.pepperKeyringFile === undefined
|
|
? MAX_PEPPER_BYTES
|
|
: MAX_PEPPER_KEYRING_BYTES,
|
|
true,
|
|
);
|
|
let authority:
|
|
| Readonly<ClusterAdministrationCommandAuthority>
|
|
| undefined;
|
|
try {
|
|
const assertion = strictUtf8(assertionBytes, 'assertion file').trim();
|
|
let pepperKeyring: Readonly<ApiCredentialPepperKeyring>;
|
|
if (paths.pepperKeyringFile === undefined) {
|
|
pepperKeyring = createSingletonApiCredentialPepperKeyring(
|
|
strictUtf8(pepperKeyringBytes, 'pepper file').trim(),
|
|
LEGACY_API_CREDENTIAL_PEPPER_KEY_ID,
|
|
);
|
|
} else {
|
|
try {
|
|
pepperKeyring = normalizeApiCredentialPepperKeyring(
|
|
JSON.parse(
|
|
strictUtf8(pepperKeyringBytes, 'pepper keyring file'),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
throw new ClusterAdministrationCommandError(
|
|
'pepper keyring file is invalid',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
const principal = await dependencies.authenticate(
|
|
paths.keysetFile,
|
|
assertion,
|
|
);
|
|
authority = await dependencies.openAuthority(
|
|
environment,
|
|
pepperKeyring,
|
|
);
|
|
if (command.operation === 'pepper.references') {
|
|
void principal;
|
|
const inspection = await authority.pepperReferences.inspect(
|
|
command.request.pepperKeyId,
|
|
command.request.limit,
|
|
);
|
|
return Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: command.operation,
|
|
...inspection,
|
|
});
|
|
}
|
|
if (command.operation === 'audit.list') {
|
|
// Successful verification is the short-lived admin admission. Audit
|
|
// queries remain read-only and use the repository's bounded contract.
|
|
void principal;
|
|
return Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: command.operation,
|
|
page: await authority.audit.list(command.request),
|
|
});
|
|
}
|
|
if (command.operation.startsWith('identity.')) {
|
|
const identityCommand = command as Readonly<IdentityCommand>;
|
|
const operation = identityCommand.operation.slice(
|
|
'identity.'.length,
|
|
) as 'register' | 'enable' | 'disable';
|
|
const result = await authority.administration[
|
|
operation === 'register'
|
|
? 'registerIdentity'
|
|
: operation === 'enable'
|
|
? 'enableIdentity'
|
|
: 'disableIdentity'
|
|
]({ ...identityCommand.request, principal });
|
|
return Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: identityCommand.operation,
|
|
status: result.status,
|
|
subject: result.identity.subject,
|
|
version: result.identity.version,
|
|
identityStatus: result.identity.status,
|
|
});
|
|
}
|
|
const credentialCommand = command as Readonly<CredentialCommand>;
|
|
const method =
|
|
credentialCommand.operation === 'credential.issue'
|
|
? 'issueCredential'
|
|
: credentialCommand.operation === 'credential.rotate'
|
|
? 'rotateCredential'
|
|
: 'revokeCredential';
|
|
const result = await authority.administration[method]({
|
|
...credentialCommand.request,
|
|
principal,
|
|
} as never);
|
|
let delivery:
|
|
| Readonly<{ fileName: string; digest: string }>
|
|
| undefined;
|
|
if (typeof result.token === 'string') {
|
|
const bytes = credentialDelivery(
|
|
credentialCommand,
|
|
result.token,
|
|
result,
|
|
);
|
|
try {
|
|
dependencies.publishDelivery(paths.deliveryFile!, bytes);
|
|
delivery = Object.freeze({
|
|
fileName: basename(paths.deliveryFile!),
|
|
digest: createHash('sha256').update(bytes).digest('hex'),
|
|
});
|
|
} finally {
|
|
bytes.fill(0);
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: credentialCommand.operation,
|
|
status: result.status,
|
|
subject: result.credential.subject,
|
|
credentialId: result.credential.credentialId,
|
|
version: result.credential.version,
|
|
state: result.credential.state,
|
|
...(delivery === undefined ? {} : { delivery }),
|
|
});
|
|
} finally {
|
|
assertionBytes.fill(0);
|
|
pepperKeyringBytes.fill(0);
|
|
await authority?.close();
|
|
}
|
|
},
|
|
});
|
|
}
|