mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add short-lived security administration command
This commit is contained in:
@@ -75,6 +75,11 @@
|
||||
"require": "./dist/security-administration/clusterAdministration.js",
|
||||
"default": "./dist/security-administration/clusterAdministration.js"
|
||||
},
|
||||
"./administration-command": {
|
||||
"types": "./dist/security-administration/clusterAdministrationCommand.d.ts",
|
||||
"require": "./dist/security-administration/clusterAdministrationCommand.js",
|
||||
"default": "./dist/security-administration/clusterAdministrationCommand.js"
|
||||
},
|
||||
"./automation-management": {
|
||||
"types": "./dist/automation-management/automationManagement.d.ts",
|
||||
"require": "./dist/automation-management/automationManagement.js",
|
||||
@@ -414,6 +419,7 @@
|
||||
},
|
||||
"bin": {
|
||||
"ql3-cluster-admin": "dist/product-cli/cli.js",
|
||||
"ql3-security-admin": "dist/security-administration/clusterAdministrationCli.js",
|
||||
"ql3-copilot-client": "dist/copilot-client/cli.js",
|
||||
"ql3-copilot-mcp": "dist/copilot-mcp/cli.js",
|
||||
"ql3-copilot-console": "dist/copilot-console/cli.js",
|
||||
|
||||
@@ -85,6 +85,12 @@ export const CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE = Object.freeze({
|
||||
purpose: 'run-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-security-administration+jwt',
|
||||
purpose: 'security-administration',
|
||||
});
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionVerifierOptions {
|
||||
readonly issuer: string;
|
||||
readonly audience: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE,
|
||||
createClusterPluginPackageIdentityAssertionVerifier,
|
||||
type ClusterManagementIdentityAssertionProfile,
|
||||
type ClusterPluginPackageIdentityAssertionAuthentication,
|
||||
@@ -517,3 +518,13 @@ export function createClusterRunIdentityKeysetFile(
|
||||
assertionProfile: CLUSTER_RUN_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterSecurityAdministrationIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile:
|
||||
CLUSTER_SECURITY_ADMINISTRATION_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
|
||||
target: 'worker-credential/workerCredentialManagementClientCli.js',
|
||||
description: 'manage Worker credentials through the authenticated API',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'security',
|
||||
binary: 'ql3-security-admin',
|
||||
target: 'security-administration/clusterAdministrationCli.js',
|
||||
description: 'administer identities, API credentials and audit records',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'approval',
|
||||
binary: 'ql3-approval-client',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
ClusterAdministrationCommandError,
|
||||
createClusterAdministrationCommandRunner,
|
||||
} from './clusterAdministrationCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-security-admin --command=/absolute/command.json --assertion=/absolute/assertion.jwt --keyset=/absolute/keyset.json --pepper=/absolute/pepper [--delivery=/absolute/token.json]';
|
||||
|
||||
function argumentsFrom(argv: readonly string[]) {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
return Object.freeze({ kind: 'help' as const });
|
||||
}
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
const match = /^--(command|assertion|keyset|pepper|delivery)=(\/.+)$/.exec(
|
||||
argument,
|
||||
);
|
||||
if (!match || values.has(match[1]!)) {
|
||||
throw new ClusterAdministrationCommandError('CLI arguments are invalid');
|
||||
}
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
!values.has('command') ||
|
||||
!values.has('assertion') ||
|
||||
!values.has('keyset') ||
|
||||
!values.has('pepper')
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError('CLI arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: 'run' as const,
|
||||
paths: Object.freeze({
|
||||
commandFile: values.get('command')!,
|
||||
assertionFile: values.get('assertion')!,
|
||||
keysetFile: values.get('keyset')!,
|
||||
pepperFile: values.get('pepper')!,
|
||||
...(values.has('delivery')
|
||||
? { deliveryFile: values.get('delivery')! }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function failure(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-security-administration',
|
||||
event: 'command_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
try {
|
||||
const parsed = argumentsFrom(argv);
|
||||
if (parsed.kind === 'help') {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const result = await createClusterAdministrationCommandRunner().run(
|
||||
parsed.paths,
|
||||
process.env,
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failure(error))}\n`);
|
||||
process.exitCode =
|
||||
error instanceof ClusterAdministrationCommandError ? 64 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main(process.argv.slice(2));
|
||||
}
|
||||
+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();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
import { randomBytes as nodeRandomBytes } from 'node:crypto';
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
fsyncSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'node:fs';
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
normalize,
|
||||
parse,
|
||||
resolve,
|
||||
} from 'node:path';
|
||||
|
||||
import { assertApiCredentialPepper } from '@qinglong/runtime-core/api-credential-token';
|
||||
import {
|
||||
PostgresApiCredentialAdministrationRepository,
|
||||
PostgresIdentityAdministrationRepository,
|
||||
PostgresSecurityAuditQueryRepository,
|
||||
assertPostgresAdminSchemaReady,
|
||||
createPostgresDatabaseOpener,
|
||||
isPostgresTlsDnsServername,
|
||||
loadPostgresCertificateAuthorityFile,
|
||||
loadPostgresConnectionEnvironment,
|
||||
type QingLongPostgresDatabaseResource,
|
||||
} from '@qinglong/cluster-postgres/admin';
|
||||
|
||||
import { createClusterSecurityAdministrationIdentityKeysetFile } from '../management-support/pluginPackageIdentityKeyset';
|
||||
import { createClusterAdministrationService } from './clusterAdministration';
|
||||
import type {
|
||||
ClusterAdministrationCommandAuthority,
|
||||
ClusterAdministrationCommandDependencies,
|
||||
ClusterAdministrationCommandPaths,
|
||||
} from './clusterAdministrationCommand';
|
||||
|
||||
const MAX_DELIVERY_BYTES = 32 * 1024;
|
||||
|
||||
export class ClusterAdministrationCommandError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_ADMINISTRATION_COMMAND_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Cluster administration command is invalid: ${message}`);
|
||||
this.name = 'ClusterAdministrationCommandError';
|
||||
}
|
||||
}
|
||||
|
||||
export function boundedClusterAdministrationFile(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
normalize(value) !== value ||
|
||||
parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4_096
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
`${label} must be a normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameFileState(
|
||||
left: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
}>,
|
||||
right: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
}>,
|
||||
): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
function readStableFile(
|
||||
candidatePath: string,
|
||||
maximumBytes: number,
|
||||
privateMaterial: boolean,
|
||||
): Buffer {
|
||||
const filePath = boundedClusterAdministrationFile(
|
||||
candidatePath,
|
||||
'input file',
|
||||
);
|
||||
let descriptor: number | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1 ||
|
||||
before.size > maximumBytes ||
|
||||
(before.mode & (privateMaterial ? 0o077 : 0o022)) !== 0
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'input file authority is invalid',
|
||||
);
|
||||
}
|
||||
bytes = Buffer.alloc(before.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (count === 0) break;
|
||||
offset += count;
|
||||
}
|
||||
const after = fstatSync(descriptor);
|
||||
if (offset !== before.size || !sameFileState(before, after)) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'input file changed while being read',
|
||||
);
|
||||
}
|
||||
return bytes.subarray(0, offset);
|
||||
} catch (error) {
|
||||
bytes?.fill(0);
|
||||
if (error instanceof ClusterAdministrationCommandError) throw error;
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'input file cannot be read',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function booleanEnvironment(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
name: string,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === 'true') return true;
|
||||
if (value === undefined || value === '' || value === 'false') return false;
|
||||
throw new ClusterAdministrationCommandError(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
function defaultDatabaseOpener(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): () => Promise<QingLongPostgresDatabaseResource> {
|
||||
let connection;
|
||||
try {
|
||||
connection = loadPostgresConnectionEnvironment(environment, {
|
||||
connectionString: 'QL3_POSTGRES_ADMIN_URL',
|
||||
host: 'QL3_POSTGRES_ADMIN_HOST',
|
||||
port: 'QL3_POSTGRES_ADMIN_PORT',
|
||||
database: 'QL3_POSTGRES_ADMIN_DATABASE',
|
||||
user: 'QL3_POSTGRES_ADMIN_USER',
|
||||
password: 'QL3_POSTGRES_ADMIN_PASSWORD',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'PostgreSQL admin connection is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
const mode = environment.QL3_POSTGRES_ADMIN_TLS_MODE ?? 'verify-full';
|
||||
if (mode !== 'verify-full' && mode !== 'disable') {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'QL3_POSTGRES_ADMIN_TLS_MODE must be verify-full or disable',
|
||||
);
|
||||
}
|
||||
if (
|
||||
mode === 'disable' &&
|
||||
!booleanEnvironment(environment, 'QL3_POSTGRES_ADMIN_ALLOW_INSECURE')
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'disabling PostgreSQL admin TLS requires explicit opt-in',
|
||||
);
|
||||
}
|
||||
const servername = environment.QL3_POSTGRES_ADMIN_TLS_SERVERNAME;
|
||||
if (mode === 'verify-full' && !isPostgresTlsDnsServername(servername)) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'QL3_POSTGRES_ADMIN_TLS_SERVERNAME must be an explicit DNS name',
|
||||
);
|
||||
}
|
||||
const caFile = environment.QL3_POSTGRES_ADMIN_TLS_CA_FILE;
|
||||
if (mode === 'disable' && caFile) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'PostgreSQL admin CA cannot be used when TLS is disabled',
|
||||
);
|
||||
}
|
||||
let ca: string | undefined;
|
||||
if (caFile) {
|
||||
try {
|
||||
ca = loadPostgresCertificateAuthorityFile(
|
||||
boundedClusterAdministrationFile(caFile, 'PostgreSQL admin CA file'),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'PostgreSQL admin CA file is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return createPostgresDatabaseOpener({
|
||||
role: 'admin',
|
||||
connection: Object.freeze({
|
||||
...connection,
|
||||
tls:
|
||||
mode === 'disable'
|
||||
? Object.freeze({ mode: 'disable' as const })
|
||||
: Object.freeze({
|
||||
mode: 'verify-full' as const,
|
||||
servername: servername!,
|
||||
...(ca === undefined ? {} : { ca }),
|
||||
}),
|
||||
}),
|
||||
pool: Object.freeze({
|
||||
applicationName: 'qinglong3-security-admin',
|
||||
maxConnections: 1,
|
||||
connectionTimeoutMs: 5_000,
|
||||
idleTimeoutMs: 1_000,
|
||||
maxLifetimeSeconds: 60,
|
||||
}),
|
||||
onPoolError() {},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDefaultAuthority(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
pepper: string,
|
||||
): Promise<Readonly<ClusterAdministrationCommandAuthority>> {
|
||||
assertApiCredentialPepper(pepper);
|
||||
const database = await defaultDatabaseOpener(environment)();
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
closePromise ??= database.close();
|
||||
return closePromise;
|
||||
};
|
||||
try {
|
||||
await assertPostgresAdminSchemaReady(database.pool);
|
||||
return Object.freeze({
|
||||
administration: createClusterAdministrationService(
|
||||
new PostgresIdentityAdministrationRepository(database.pool),
|
||||
new PostgresApiCredentialAdministrationRepository(database.pool),
|
||||
pepper,
|
||||
),
|
||||
audit: new PostgresSecurityAuditQueryRepository(database.pool),
|
||||
close,
|
||||
});
|
||||
} catch (error) {
|
||||
await close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateDefault(keysetFile: string, assertion: string) {
|
||||
const identities = createClusterSecurityAdministrationIdentityKeysetFile({
|
||||
filePath: boundedClusterAdministrationFile(
|
||||
keysetFile,
|
||||
'identity keyset file',
|
||||
),
|
||||
});
|
||||
return identities.bind(assertion).authenticate();
|
||||
}
|
||||
|
||||
export function publishClusterAdministrationCredentialDelivery(
|
||||
filePathValue: string,
|
||||
bytes: Buffer,
|
||||
): void {
|
||||
const filePath = boundedClusterAdministrationFile(
|
||||
filePathValue,
|
||||
'delivery file',
|
||||
);
|
||||
if (bytes.length < 1 || bytes.length > MAX_DELIVERY_BYTES) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'credential delivery is oversized',
|
||||
);
|
||||
}
|
||||
const parent = dirname(filePath);
|
||||
const parentStatus = lstatSync(parent, { throwIfNoEntry: false });
|
||||
if (
|
||||
parentStatus === undefined ||
|
||||
!parentStatus.isDirectory() ||
|
||||
parentStatus.isSymbolicLink() ||
|
||||
(parentStatus.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'credential delivery directory authority is invalid',
|
||||
);
|
||||
}
|
||||
const temporary = resolve(
|
||||
parent,
|
||||
`.${basename(filePath)}.${process.pid}.${nodeRandomBytes(12).toString(
|
||||
'hex',
|
||||
)}.tmp`,
|
||||
);
|
||||
let descriptor: number | undefined;
|
||||
let linked = false;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
temporary,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
offset += writeSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
fsyncSync(descriptor);
|
||||
const status = fstatSync(descriptor);
|
||||
if (
|
||||
!status.isFile() ||
|
||||
status.size !== bytes.length ||
|
||||
(status.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'credential delivery file authority is invalid',
|
||||
);
|
||||
}
|
||||
closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
linkSync(temporary, filePath);
|
||||
linked = true;
|
||||
unlinkSync(temporary);
|
||||
const parentDescriptor = openSync(
|
||||
parent,
|
||||
constants.O_RDONLY | (constants.O_DIRECTORY ?? 0),
|
||||
);
|
||||
try {
|
||||
fsyncSync(parentDescriptor);
|
||||
} finally {
|
||||
closeSync(parentDescriptor);
|
||||
}
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
try {
|
||||
unlinkSync(temporary);
|
||||
} catch {
|
||||
// Preserve the original delivery failure.
|
||||
}
|
||||
if (error instanceof ClusterAdministrationCommandError) throw error;
|
||||
throw new ClusterAdministrationCommandError(
|
||||
linked
|
||||
? 'credential delivery directory could not be synchronized'
|
||||
: 'credential delivery could not be published',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeClusterAdministrationCommandPaths(
|
||||
value: ClusterAdministrationCommandPaths,
|
||||
requiresDelivery: boolean,
|
||||
): Readonly<ClusterAdministrationCommandPaths> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'command paths must be an object',
|
||||
);
|
||||
}
|
||||
const expected = [
|
||||
'assertionFile',
|
||||
'commandFile',
|
||||
'keysetFile',
|
||||
'pepperFile',
|
||||
...(requiresDelivery ? ['deliveryFile'] : []),
|
||||
].sort();
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'command paths shape is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
commandFile: boundedClusterAdministrationFile(
|
||||
value.commandFile,
|
||||
'command file',
|
||||
),
|
||||
assertionFile: boundedClusterAdministrationFile(
|
||||
value.assertionFile,
|
||||
'assertion file',
|
||||
),
|
||||
keysetFile: boundedClusterAdministrationFile(
|
||||
value.keysetFile,
|
||||
'identity keyset file',
|
||||
),
|
||||
pepperFile: boundedClusterAdministrationFile(
|
||||
value.pepperFile,
|
||||
'pepper file',
|
||||
),
|
||||
...(requiresDelivery
|
||||
? {
|
||||
deliveryFile: boundedClusterAdministrationFile(
|
||||
value.deliveryFile,
|
||||
'delivery file',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function clusterAdministrationCommandFileBeforeAdmission(
|
||||
value: unknown,
|
||||
): string {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'command paths must be an object',
|
||||
);
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const required = ['assertionFile', 'commandFile', 'keysetFile', 'pepperFile'];
|
||||
if (
|
||||
required.some((key) => !Object.hasOwn(candidate, key)) ||
|
||||
Object.keys(candidate).some(
|
||||
(key) => !required.includes(key) && key !== 'deliveryFile',
|
||||
)
|
||||
) {
|
||||
throw new ClusterAdministrationCommandError(
|
||||
'command paths shape is invalid',
|
||||
);
|
||||
}
|
||||
return boundedClusterAdministrationFile(
|
||||
candidate.commandFile,
|
||||
'command file',
|
||||
);
|
||||
}
|
||||
|
||||
export const CLUSTER_ADMINISTRATION_COMMAND_RUNTIME_DEPENDENCIES: ClusterAdministrationCommandDependencies =
|
||||
Object.freeze({
|
||||
openAuthority: openDefaultAuthority,
|
||||
authenticate: authenticateDefault,
|
||||
readFile: readStableFile,
|
||||
publishDelivery: publishClusterAdministrationCredentialDelivery,
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createClusterAdministrationCommandRunner,
|
||||
normalizeClusterAdministrationCommand,
|
||||
publishClusterAdministrationCredentialDelivery,
|
||||
} = require('@qinglong/cluster-admin/administration-command');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: { type: 'user', id: 'security-owner' },
|
||||
authenticationId: 'assertion:security-command-1',
|
||||
authenticatedAtMs: 900,
|
||||
expiresAtMs: 2_000,
|
||||
assurance: 'multi_factor',
|
||||
});
|
||||
const SUBJECT = Object.freeze({ type: 'api_app', id: 'automation-client' });
|
||||
const PATHS = Object.freeze({
|
||||
commandFile: '/private/command.json',
|
||||
assertionFile: '/private/assertion.jwt',
|
||||
keysetFile: '/private/keyset.json',
|
||||
pepperFile: '/private/pepper',
|
||||
});
|
||||
|
||||
function identityCommand(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.register',
|
||||
request: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174301',
|
||||
requestId: 'security-identity-register-1',
|
||||
expectedCurrentVersion: 0,
|
||||
subject: SUBJECT,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function credentialCommand(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.issue',
|
||||
request: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174302',
|
||||
requestId: 'security-credential-issue-1',
|
||||
expectedCurrentVersion: 0,
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authority(overrides = {}) {
|
||||
const calls = [];
|
||||
let closes = 0;
|
||||
const credential = {
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
secretDigest: 'digest',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
state: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 1_000,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
};
|
||||
const value = {
|
||||
administration: {
|
||||
async registerIdentity(request) {
|
||||
calls.push(['identity.register', request]);
|
||||
return {
|
||||
status: 'inserted',
|
||||
identity: {
|
||||
subject: request.subject,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 1_000,
|
||||
updatedAtMs: 1_000,
|
||||
},
|
||||
mutation: {},
|
||||
};
|
||||
},
|
||||
async enableIdentity() {
|
||||
throw new Error('unexpected enable');
|
||||
},
|
||||
async disableIdentity() {
|
||||
throw new Error('unexpected disable');
|
||||
},
|
||||
async issueCredential(request) {
|
||||
calls.push(['credential.issue', request]);
|
||||
return {
|
||||
status: 'inserted',
|
||||
credential,
|
||||
mutation: {},
|
||||
token: 'ql3c_automation-primary_private-secret',
|
||||
};
|
||||
},
|
||||
async rotateCredential() {
|
||||
throw new Error('unexpected rotate');
|
||||
},
|
||||
async revokeCredential() {
|
||||
throw new Error('unexpected revoke');
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async list(query) {
|
||||
calls.push(['audit.list', query]);
|
||||
return { records: [], nextCursor: null };
|
||||
},
|
||||
},
|
||||
async close() {
|
||||
closes += 1;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { value, calls, closes: () => closes };
|
||||
}
|
||||
|
||||
function runner(command, authorityValue, published = []) {
|
||||
const buffers = [];
|
||||
const pepper = 'A'.repeat(43);
|
||||
const files = new Map([
|
||||
[PATHS.commandFile, `${JSON.stringify(command)}\n`],
|
||||
[PATHS.assertionFile, 'signed.assertion.value'],
|
||||
[PATHS.pepperFile, pepper],
|
||||
]);
|
||||
const authentications = [];
|
||||
const opens = [];
|
||||
const instance = createClusterAdministrationCommandRunner({
|
||||
async openAuthority(environment, candidatePepper) {
|
||||
opens.push({ environment, pepper: candidatePepper });
|
||||
return authorityValue;
|
||||
},
|
||||
async authenticate(keysetFile, assertion) {
|
||||
authentications.push({ keysetFile, assertion });
|
||||
return PRINCIPAL;
|
||||
},
|
||||
readFile(filePath) {
|
||||
const value = files.get(filePath);
|
||||
if (value === undefined) throw new Error(`unexpected file: ${filePath}`);
|
||||
const buffer = Buffer.from(value);
|
||||
buffers.push(buffer);
|
||||
return buffer;
|
||||
},
|
||||
publishDelivery(filePath, bytes) {
|
||||
published.push({ filePath, bytes: Buffer.from(bytes) });
|
||||
},
|
||||
});
|
||||
return { instance, buffers, authentications, opens };
|
||||
}
|
||||
|
||||
test('executes one strongly authenticated identity mutation and closes authority', async () => {
|
||||
const target = authority();
|
||||
const execution = runner(identityCommand(), target.value);
|
||||
|
||||
const result = await execution.instance.run(PATHS, { deployment: 'test' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.register',
|
||||
status: 'inserted',
|
||||
subject: SUBJECT,
|
||||
version: 1,
|
||||
identityStatus: 'active',
|
||||
});
|
||||
assert.deepEqual(execution.authentications, [
|
||||
{
|
||||
keysetFile: PATHS.keysetFile,
|
||||
assertion: 'signed.assertion.value',
|
||||
},
|
||||
]);
|
||||
assert.equal(execution.opens[0].pepper, 'A'.repeat(43));
|
||||
assert.equal(target.calls[0][1].principal, PRINCIPAL);
|
||||
assert.equal(target.closes(), 1);
|
||||
assert.equal(
|
||||
execution.buffers.every((value) => value.every((byte) => byte === 0)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes a credential token only to the private delivery boundary', async () => {
|
||||
const target = authority();
|
||||
const published = [];
|
||||
const execution = runner(credentialCommand(), target.value, published);
|
||||
const paths = { ...PATHS, deliveryFile: '/private/delivery.json' };
|
||||
|
||||
const result = await execution.instance.run(paths, {});
|
||||
|
||||
assert.equal(result.operation, 'credential.issue');
|
||||
assert.equal(result.status, 'inserted');
|
||||
assert.equal('token' in result, false);
|
||||
assert.deepEqual(result.delivery.fileName, 'delivery.json');
|
||||
assert.match(result.delivery.digest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(published.length, 1);
|
||||
const delivery = JSON.parse(published[0].bytes.toString('utf8'));
|
||||
assert.equal(delivery.token, 'ql3c_automation-primary_private-secret');
|
||||
assert.equal(delivery.mutationId, credentialCommand().request.mutationId);
|
||||
assert.equal(target.closes(), 1);
|
||||
});
|
||||
|
||||
test('does not recreate lost token material during exact credential replay', async () => {
|
||||
const base = authority();
|
||||
base.value.administration.issueCredential = async () => ({
|
||||
status: 'existing',
|
||||
credential: {
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
state: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 1_000,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
},
|
||||
mutation: {},
|
||||
token: null,
|
||||
});
|
||||
const published = [];
|
||||
const execution = runner(credentialCommand(), base.value, published);
|
||||
|
||||
const result = await execution.instance.run(
|
||||
{ ...PATHS, deliveryFile: '/private/delivery.json' },
|
||||
{},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 'existing');
|
||||
assert.equal('delivery' in result, false);
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(base.closes(), 1);
|
||||
});
|
||||
|
||||
test('revokes a credential without requiring or publishing a delivery file', async () => {
|
||||
const target = authority();
|
||||
target.value.administration.revokeCredential = async (request) => ({
|
||||
status: 'inserted',
|
||||
credential: {
|
||||
credentialId: request.credentialId,
|
||||
subject: request.subject,
|
||||
state: 'revoked',
|
||||
version: 2,
|
||||
createdAtMs: 1_000,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
},
|
||||
mutation: {},
|
||||
});
|
||||
const published = [];
|
||||
const execution = runner(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.revoke',
|
||||
request: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174303',
|
||||
requestId: 'security-credential-revoke-1',
|
||||
expectedCurrentVersion: 1,
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
},
|
||||
},
|
||||
target.value,
|
||||
published,
|
||||
);
|
||||
|
||||
const result = await execution.instance.run(PATHS, {});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.revoke',
|
||||
status: 'inserted',
|
||||
subject: SUBJECT,
|
||||
credentialId: 'automation-primary',
|
||||
version: 2,
|
||||
state: 'revoked',
|
||||
});
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(target.closes(), 1);
|
||||
});
|
||||
|
||||
test('keeps audit query bounded and rejects widened command shapes before admission', async () => {
|
||||
const query = {
|
||||
schemaVersion: 1,
|
||||
operation: 'audit.list',
|
||||
request: { limit: 25, filter: { outcome: 'allowed' } },
|
||||
};
|
||||
const target = authority();
|
||||
const execution = runner(query, target.value);
|
||||
const result = await execution.instance.run(PATHS, {});
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1,
|
||||
operation: 'audit.list',
|
||||
page: { records: [], nextCursor: null },
|
||||
});
|
||||
assert.deepEqual(target.calls, [
|
||||
['audit.list', { limit: 25, filter: { outcome: 'allowed' } }],
|
||||
]);
|
||||
assert.throws(
|
||||
() => normalizeClusterAdministrationCommand({ ...query, debug: true }),
|
||||
/command shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClusterAdministrationCommand({
|
||||
...query,
|
||||
request: { limit: 201, filter: {} },
|
||||
}),
|
||||
/audit query is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened path authority before reading a command file', async () => {
|
||||
let reads = 0;
|
||||
const instance = createClusterAdministrationCommandRunner({
|
||||
async openAuthority() {
|
||||
throw new Error('must not open authority');
|
||||
},
|
||||
async authenticate() {
|
||||
throw new Error('must not authenticate');
|
||||
},
|
||||
readFile() {
|
||||
reads += 1;
|
||||
throw new Error('must not read');
|
||||
},
|
||||
publishDelivery() {
|
||||
throw new Error('must not publish');
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
instance.run({ ...PATHS, ambientCredential: true }, {}),
|
||||
/command paths shape is invalid/,
|
||||
);
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
|
||||
test('publishes a 0600 no-replace delivery and leaves an existing target intact', (t) => {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-security-delivery-'),
|
||||
);
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'credential.json');
|
||||
const bytes = Buffer.from('{"token":"secret"}\n');
|
||||
|
||||
publishClusterAdministrationCredentialDelivery(filePath, bytes);
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600);
|
||||
assert.deepEqual(fs.readFileSync(filePath), bytes);
|
||||
assert.throws(
|
||||
() =>
|
||||
publishClusterAdministrationCredentialDelivery(
|
||||
filePath,
|
||||
Buffer.from('{"token":"replacement"}\n'),
|
||||
),
|
||||
/could not be published/,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(filePath), bytes);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
createClusterApprovalIdentityKeysetFile,
|
||||
createClusterModelProviderCredentialIdentityKeysetFile,
|
||||
createClusterRunIdentityKeysetFile,
|
||||
createClusterSecurityAdministrationIdentityKeysetFile,
|
||||
} = require('@qinglong/cluster-admin/plugin-package-identity-keyset');
|
||||
|
||||
const NOW_MS = 1_700_000_000_000;
|
||||
@@ -249,6 +250,38 @@ function runAssertion(key, overrides = {}) {
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function securityAdministrationAssertion(key, overrides = {}) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-security-administration+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const now = Math.floor(NOW_MS / 1000);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: 'qinglong3-security-administration',
|
||||
auth_time: now - 10,
|
||||
exp: now + 120,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: `security-administration-assertion-${key.kid}`,
|
||||
ql3_purpose: 'security-administration',
|
||||
sub: 'security-owner-1',
|
||||
...overrides,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = `${header}.${payload}`;
|
||||
return `${signed}.${sign(
|
||||
null,
|
||||
Buffer.from(signed, 'ascii'),
|
||||
key.privateKey,
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function atomicWrite(filePath, document) {
|
||||
const nextPath = `${filePath}.next`;
|
||||
await writeFile(nextPath, `${JSON.stringify(document)}\n`, { mode: 0o644 });
|
||||
@@ -337,10 +370,9 @@ test('loads an automation keyset with a purpose isolated from other management p
|
||||
type: 'user',
|
||||
id: 'automation-operator-1',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider.bind(workerAssertion(key)).authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
await assert.rejects(provider.bind(workerAssertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
@@ -358,14 +390,19 @@ test('loads an Approval keyset isolated by type, purpose and audience', async ()
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
const principal = await provider.bind(approvalAssertion(key)).authenticate();
|
||||
const principal = await provider
|
||||
.bind(approvalAssertion(key))
|
||||
.authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'approval-owner-1',
|
||||
});
|
||||
await assert.rejects(provider.bind(automationAssertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider.bind(automationAssertion(key)).authenticate(),
|
||||
{
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
},
|
||||
);
|
||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
@@ -435,6 +472,40 @@ test('loads a Run keyset isolated from every other management purpose', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('loads a Security Administration keyset isolated from other management purposes', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const key = reviewedKey('security-administration-key-1');
|
||||
await atomicWrite(filePath, {
|
||||
...keyset(1, [key]),
|
||||
audience: 'qinglong3-security-administration',
|
||||
});
|
||||
const provider = createClusterSecurityAdministrationIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
const principal = await provider
|
||||
.bind(securityAdministrationAssertion(key))
|
||||
.authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'security-owner-1',
|
||||
});
|
||||
await assert.rejects(provider.bind(runAssertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider
|
||||
.bind(
|
||||
securityAdministrationAssertion(key, {
|
||||
ql3_purpose: 'run-management',
|
||||
}),
|
||||
)
|
||||
.authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('supports overlap rotation then immediately revokes the previous key', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
|
||||
@@ -336,7 +336,7 @@ function validContextFixture(t) {
|
||||
|
||||
test('catalog exposes only reviewed product entrypoints from the same package', () => {
|
||||
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 12);
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 13);
|
||||
assert.equal(
|
||||
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
||||
@@ -354,6 +354,7 @@ test('catalog exposes only reviewed product entrypoints from the same package',
|
||||
);
|
||||
assert.equal(
|
||||
command.binary.includes('-client') ||
|
||||
command.binary === 'ql3-security-admin' ||
|
||||
command.binary === 'ql3-copilot-mcp' ||
|
||||
command.binary === 'ql3-copilot-console' ||
|
||||
command.binary === 'ql3-copilot-evidence-verify',
|
||||
@@ -391,6 +392,7 @@ test('help and version are bounded installation-derived product facts', () => {
|
||||
/\n evidence-verify\s+verify one redacted Console evidence/,
|
||||
);
|
||||
assert.match(help, /Server, migration, recovery, executor and key-custody/);
|
||||
assert.match(help, /\n security\s+administer identities, API credentials/);
|
||||
assert.equal(help.includes('plugin-package-manage'), false);
|
||||
assert.equal(
|
||||
loadQingLong3ClusterProductVersion(moduleDirectory),
|
||||
@@ -923,6 +925,11 @@ test('binary exposes help/version and delegates without a shell', () => {
|
||||
assert.match(delegatedHelp.stdout, /^Usage: ql3-run-client /);
|
||||
assert.equal(delegatedHelp.stderr, '');
|
||||
|
||||
const securityHelp = runCli(['security', '--help']);
|
||||
assert.equal(securityHelp.status, 0);
|
||||
assert.match(securityHelp.stdout, /^Usage: ql3-security-admin /);
|
||||
assert.equal(securityHelp.stderr, '');
|
||||
|
||||
const rejected = runCli(['../../tmp/not-a-command']);
|
||||
assert.equal(rejected.status, 64);
|
||||
assert.equal(rejected.stdout, '');
|
||||
|
||||
Reference in New Issue
Block a user