mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): add short-lived security administration command
This commit is contained in:
+468
@@ -0,0 +1,468 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token';
|
||||
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;
|
||||
|
||||
export type ClusterAdministrationCommandOperation =
|
||||
| 'identity.register'
|
||||
| 'identity.enable'
|
||||
| 'identity.disable'
|
||||
| 'credential.issue'
|
||||
| 'credential.rotate'
|
||||
| 'credential.revoke'
|
||||
| '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;
|
||||
}
|
||||
|
||||
export type ClusterAdministrationCommand =
|
||||
| IdentityCommand
|
||||
| CredentialCommand
|
||||
| AuditCommand;
|
||||
|
||||
export interface ClusterAdministrationCommandPaths {
|
||||
readonly commandFile: string;
|
||||
readonly assertionFile: string;
|
||||
readonly keysetFile: string;
|
||||
readonly pepperFile: 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>;
|
||||
}>;
|
||||
|
||||
export interface ClusterAdministrationCommandAuthority {
|
||||
readonly administration: ClusterAdministrationService;
|
||||
readonly audit: SecurityAuditQueryRepository;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterAdministrationCommandDependencies {
|
||||
readonly openAuthority: (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
pepper: string,
|
||||
) => 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',
|
||||
'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 === '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 pepperBytes = dependencies.readFile(
|
||||
paths.pepperFile,
|
||||
MAX_PEPPER_BYTES,
|
||||
true,
|
||||
);
|
||||
let authority:
|
||||
| Readonly<ClusterAdministrationCommandAuthority>
|
||||
| undefined;
|
||||
try {
|
||||
const assertion = strictUtf8(assertionBytes, 'assertion file').trim();
|
||||
const pepper = strictUtf8(pepperBytes, 'pepper file').trim();
|
||||
assertApiCredentialPepper(pepper);
|
||||
const principal = await dependencies.authenticate(
|
||||
paths.keysetFile,
|
||||
assertion,
|
||||
);
|
||||
authority = await dependencies.openAuthority(environment, pepper);
|
||||
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);
|
||||
pepperBytes.fill(0);
|
||||
await authority?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user