mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+452
@@ -0,0 +1,452 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
assertApiCredentialId,
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
readPrivateLocalCommandFile,
|
||||
type SecuritySubject,
|
||||
} from './codecAuthority';
|
||||
import {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
type BaseInspectionRequest,
|
||||
type BaseMutationRequest,
|
||||
type BaseTargetMutationRequest,
|
||||
type LocalIdentityCredentialCommand,
|
||||
type LocalIdentityCredentialCommandOptions,
|
||||
} from './contracts';
|
||||
|
||||
export const MAX_PATH_BYTES = 4096;
|
||||
export const MAX_VERSION = 2_147_483_647;
|
||||
export const MIN_CREDENTIAL_LIFETIME_MS = 60_000;
|
||||
export const MAX_CREDENTIAL_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
export 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}$/;
|
||||
export const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
export const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export 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 LocalIdentityCredentialCommandConfigurationError(
|
||||
`${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 LocalIdentityCredentialCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function descendant(
|
||||
root: string,
|
||||
candidate: string,
|
||||
label: string,
|
||||
): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function requiresDelivery(
|
||||
operation: LocalIdentityCredentialCommand['operation'],
|
||||
) {
|
||||
return (
|
||||
operation === 'credential.issue' ||
|
||||
operation === 'credential.rotate' ||
|
||||
operation === 'credential.delivery.acknowledge'
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeOptions(
|
||||
value: unknown,
|
||||
operation: LocalIdentityCredentialCommand['operation'],
|
||||
): Readonly<LocalIdentityCredentialCommandOptions> {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
const deliveryRequired = requiresDelivery(operation);
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
...(deliveryRequired ? ['credentialDeliveryDirectory'] : []),
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
const databasePath = boundedPath(value.databasePath, 'databasePath');
|
||||
const ownerPepperKeyringDirectory = boundedPath(
|
||||
value.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const credentialFilePath = boundedPath(
|
||||
value.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
for (const [label, candidate] of [
|
||||
['databasePath', databasePath],
|
||||
['ownerPepperKeyringDirectory', ownerPepperKeyringDirectory],
|
||||
['credentialFilePath', credentialFilePath],
|
||||
] as const) {
|
||||
descendant(deploymentRoot, candidate, label);
|
||||
}
|
||||
let credentialDeliveryDirectory: string | undefined;
|
||||
if (deliveryRequired) {
|
||||
credentialDeliveryDirectory = boundedPath(
|
||||
value.credentialDeliveryDirectory,
|
||||
'credentialDeliveryDirectory',
|
||||
);
|
||||
descendant(
|
||||
deploymentRoot,
|
||||
credentialDeliveryDirectory,
|
||||
'credentialDeliveryDirectory',
|
||||
);
|
||||
if (
|
||||
credentialDeliveryDirectory === path.dirname(databasePath) ||
|
||||
credentialDeliveryDirectory === ownerPepperKeyringDirectory
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'credentialDeliveryDirectory must not share database or keyring storage',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: value.profile,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
...(credentialDeliveryDirectory === undefined
|
||||
? {}
|
||||
: { credentialDeliveryDirectory }),
|
||||
...(value.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: value.busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCommonRequest(
|
||||
value: Record<string, unknown>,
|
||||
): Readonly<BaseMutationRequest> {
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.projectId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'projectId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.mutationId) ||
|
||||
typeof value.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.failureAuditEventId) ||
|
||||
value.failureAuditEventId === value.mutationId ||
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'mutation or request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: value.projectId as string,
|
||||
mutationId: value.mutationId,
|
||||
requestId: value.requestId,
|
||||
failureAuditEventId: value.failureAuditEventId,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeInspectionCommonRequest(
|
||||
value: Record<string, unknown>,
|
||||
): Readonly<BaseInspectionRequest> {
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.projectId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'projectId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.auditEventId) ||
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'audit or request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: value.projectId as string,
|
||||
requestId: value.requestId,
|
||||
auditEventId: value.auditEventId,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeInspectionTarget(
|
||||
value: unknown,
|
||||
): Readonly<SecuritySubject> {
|
||||
let target: Readonly<SecuritySubject>;
|
||||
try {
|
||||
target = normalizeProjectPolicySubject(value as SecuritySubject);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!['user', 'api_app', 'mcp_client', 'agent'].includes(target.type)) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target is invalid',
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function normalizeTargetRequest(
|
||||
value: Record<string, unknown>,
|
||||
): Readonly<BaseTargetMutationRequest> {
|
||||
const common = normalizeCommonRequest(value);
|
||||
let target: Readonly<SecuritySubject>;
|
||||
try {
|
||||
target = normalizeProjectPolicySubject(value.target as SecuritySubject);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!['user', 'api_app', 'mcp_client', 'agent'].includes(target.type) ||
|
||||
!Number.isSafeInteger(value.expectedCurrentVersion) ||
|
||||
(value.expectedCurrentVersion as number) < 0 ||
|
||||
(value.expectedCurrentVersion as number) >= MAX_VERSION
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target or expectedCurrentVersion is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...common,
|
||||
target,
|
||||
expectedCurrentVersion: value.expectedCurrentVersion as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRequest(
|
||||
value: unknown,
|
||||
operation: LocalIdentityCredentialCommand['operation'],
|
||||
): LocalIdentityCredentialCommand['request'] {
|
||||
const inspection =
|
||||
operation === 'identity.inspect' || operation === 'credential.inspect';
|
||||
if (inspection) {
|
||||
const identityInspection = operation === 'identity.inspect';
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'projectId',
|
||||
...(identityInspection ? ['target'] : ['credentialId']),
|
||||
'requestId',
|
||||
'auditEventId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const common = normalizeInspectionCommonRequest(value);
|
||||
if (identityInspection) {
|
||||
return Object.freeze({
|
||||
...common,
|
||||
target: normalizeInspectionTarget(value.target),
|
||||
});
|
||||
}
|
||||
try {
|
||||
assertApiCredentialId(value.credentialId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'credentialId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...common,
|
||||
credentialId: value.credentialId as string,
|
||||
});
|
||||
}
|
||||
const identity = operation.startsWith('identity.');
|
||||
const activeCredential =
|
||||
operation === 'credential.issue' || operation === 'credential.rotate';
|
||||
const revokeCredential = operation === 'credential.revoke';
|
||||
const acknowledge = operation === 'credential.delivery.acknowledge';
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'projectId',
|
||||
...(acknowledge ? [] : ['target', 'expectedCurrentVersion']),
|
||||
...(activeCredential || revokeCredential ? ['credentialId'] : []),
|
||||
...(activeCredential ? ['lifetimeMs'] : []),
|
||||
...(acknowledge
|
||||
? ['credentialMutationId', 'expectedDeliveryDigest']
|
||||
: []),
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'failureAuditEventId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (acknowledge) {
|
||||
const common = normalizeCommonRequest(value);
|
||||
if (
|
||||
typeof value.credentialMutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.credentialMutationId) ||
|
||||
value.credentialMutationId === common.mutationId ||
|
||||
typeof value.expectedDeliveryDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.expectedDeliveryDigest)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'delivery acknowledgement is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...common,
|
||||
credentialMutationId: value.credentialMutationId,
|
||||
expectedDeliveryDigest: value.expectedDeliveryDigest,
|
||||
});
|
||||
}
|
||||
const target = normalizeTargetRequest(value);
|
||||
if (identity) return target;
|
||||
try {
|
||||
assertApiCredentialId(value.credentialId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'credentialId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
activeCredential &&
|
||||
(!Number.isSafeInteger(value.lifetimeMs) ||
|
||||
(value.lifetimeMs as number) < MIN_CREDENTIAL_LIFETIME_MS ||
|
||||
(value.lifetimeMs as number) > MAX_CREDENTIAL_LIFETIME_MS)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'lifetimeMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...target,
|
||||
credentialId: value.credentialId as string,
|
||||
...(activeCredential ? { lifetimeMs: value.lifetimeMs as number } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalIdentityCredentialCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['schemaVersion', 'operation', 'options', 'request'],
|
||||
'command',
|
||||
);
|
||||
const operations: readonly LocalIdentityCredentialCommand['operation'][] = [
|
||||
'identity.inspect',
|
||||
'identity.register',
|
||||
'identity.enable',
|
||||
'identity.disable',
|
||||
'credential.inspect',
|
||||
'credential.issue',
|
||||
'credential.rotate',
|
||||
'credential.revoke',
|
||||
'credential.delivery.acknowledge',
|
||||
];
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
typeof value.operation !== 'string' ||
|
||||
!operations.includes(
|
||||
value.operation as LocalIdentityCredentialCommand['operation'],
|
||||
)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
const operation =
|
||||
value.operation as LocalIdentityCredentialCommand['operation'];
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options: normalizeOptions(value.options, operation),
|
||||
request: normalizeRequest(value.request, operation),
|
||||
} as LocalIdentityCredentialCommand);
|
||||
}
|
||||
|
||||
export function readCommandFile(
|
||||
candidatePath: string,
|
||||
): Readonly<LocalIdentityCredentialCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalIdentityCredentialCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
export { assertApiCredentialId } from '@qinglong/runtime-core/api-credential';
|
||||
export {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
export type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type { createLocalIdentityCredentialAdministrationService } from '@qinglong/local-admin/identity-credential-administration';
|
||||
export type { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type {
|
||||
LocalCredentialAdministrationDeliveryRecord,
|
||||
LocalCredentialAdministrationDeliverySummary,
|
||||
} from '@qinglong/local-owner-console/credential-administration-delivery';
|
||||
export type { LocalOwnerPepperKeyringFileProvider } from '@qinglong/local-owner-console/pepper-custody';
|
||||
export type { openLocalSqliteIdentityCredentialAdministrationDatabase } from '@qinglong/local-sqlite/identity-credential-administration';
|
||||
export type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import type {
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
establishAuthenticatedLocalCommand,
|
||||
LocalCredentialAdministrationDeliveryRecord,
|
||||
LocalCredentialAdministrationDeliverySummary,
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
SecuritySubject,
|
||||
} from './contractAuthority';
|
||||
|
||||
export type IdentityCommandOperation =
|
||||
| 'identity.register'
|
||||
| 'identity.enable'
|
||||
| 'identity.disable';
|
||||
|
||||
export type CredentialCommandOperation =
|
||||
| 'credential.issue'
|
||||
| 'credential.rotate'
|
||||
| 'credential.revoke';
|
||||
|
||||
export type DeliveryCommandOperation = 'credential.delivery.acknowledge';
|
||||
|
||||
export type InspectionCommandOperation =
|
||||
| 'identity.inspect'
|
||||
| 'credential.inspect';
|
||||
|
||||
export interface LocalIdentityCredentialCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly credentialDeliveryDirectory?: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface BaseMutationRequest {
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface BaseTargetMutationRequest extends BaseMutationRequest {
|
||||
readonly target: SecuritySubject;
|
||||
readonly expectedCurrentVersion: number;
|
||||
}
|
||||
|
||||
export interface BaseInspectionRequest {
|
||||
readonly projectId: string;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
}
|
||||
|
||||
export interface LocalIdentityInspectionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'identity.inspect';
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseInspectionRequest & {
|
||||
readonly target: SecuritySubject;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialInspectionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'credential.inspect';
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseInspectionRequest & {
|
||||
readonly credentialId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalIdentityAdministrationCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: IdentityCommandOperation;
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseTargetMutationRequest;
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialIssueCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'credential.issue' | 'credential.rotate';
|
||||
readonly options: LocalIdentityCredentialCommandOptions & {
|
||||
readonly credentialDeliveryDirectory: string;
|
||||
};
|
||||
readonly request: BaseTargetMutationRequest & {
|
||||
readonly credentialId: string;
|
||||
readonly lifetimeMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialRevokeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'credential.revoke';
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseTargetMutationRequest & {
|
||||
readonly credentialId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalCredentialDeliveryAcknowledgeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: DeliveryCommandOperation;
|
||||
readonly options: LocalIdentityCredentialCommandOptions & {
|
||||
readonly credentialDeliveryDirectory: string;
|
||||
};
|
||||
readonly request: BaseMutationRequest & {
|
||||
readonly credentialMutationId: string;
|
||||
readonly expectedDeliveryDigest: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalIdentityCredentialCommand =
|
||||
| LocalIdentityInspectionCommand
|
||||
| LocalApiCredentialInspectionCommand
|
||||
| LocalIdentityAdministrationCommand
|
||||
| LocalApiCredentialIssueCommand
|
||||
| LocalApiCredentialRevokeCommand
|
||||
| LocalCredentialDeliveryAcknowledgeCommand;
|
||||
|
||||
export type LocalIdentityCredentialCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: InspectionCommandOperation;
|
||||
projectId: string;
|
||||
found: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'identity.inspect';
|
||||
projectId: string;
|
||||
found: true;
|
||||
target: Readonly<SecuritySubject>;
|
||||
version: number;
|
||||
identityStatus: 'active' | 'disabled';
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'credential.inspect';
|
||||
projectId: string;
|
||||
found: true;
|
||||
credentialId: string;
|
||||
target: Readonly<SecuritySubject>;
|
||||
version: number;
|
||||
state: 'active' | 'revoked';
|
||||
subjectStatus: 'active' | 'disabled';
|
||||
createdAtMs: number;
|
||||
notBeforeAtMs: number;
|
||||
expiresAtMs: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: IdentityCommandOperation;
|
||||
status: 'inserted' | 'existing';
|
||||
projectId: string;
|
||||
target: Readonly<SecuritySubject>;
|
||||
version: number;
|
||||
identityStatus: 'active' | 'disabled';
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: CredentialCommandOperation;
|
||||
status: 'inserted' | 'existing';
|
||||
projectId: string;
|
||||
target: Readonly<SecuritySubject>;
|
||||
credentialId: string;
|
||||
version: number;
|
||||
state: 'active' | 'revoked';
|
||||
expiresAtMs: number;
|
||||
delivery?: Readonly<{
|
||||
fileName: string;
|
||||
digest: string;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: DeliveryCommandOperation;
|
||||
status: 'inserted' | 'existing';
|
||||
projectId: string;
|
||||
credentialMutationId: string;
|
||||
acknowledgementMutationId: string;
|
||||
deliveryDigest: string;
|
||||
cleanup: 'removed' | 'absent';
|
||||
}>;
|
||||
|
||||
export interface CredentialDelivery {
|
||||
readonly directory: string;
|
||||
prepare(
|
||||
record: LocalCredentialAdministrationDeliveryRecord,
|
||||
): Readonly<LocalCredentialAdministrationDeliveryRecord>;
|
||||
digest(record: LocalCredentialAdministrationDeliveryRecord): string;
|
||||
publish(
|
||||
record: LocalCredentialAdministrationDeliveryRecord,
|
||||
expectedDeliveryDigest: string,
|
||||
): Readonly<LocalCredentialAdministrationDeliverySummary>;
|
||||
removeAcknowledged(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
): 'removed' | 'absent';
|
||||
}
|
||||
|
||||
export interface PepperMaterialProvider {
|
||||
resolve(
|
||||
pepperKeyId: string,
|
||||
): ReturnType<LocalOwnerPepperKeyringFileProvider['resolve']>;
|
||||
}
|
||||
|
||||
export interface LocalIdentityCredentialCommandRunner {
|
||||
run(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalIdentityCredentialCommandResult>>;
|
||||
}
|
||||
|
||||
export interface LocalIdentityCredentialCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteIdentityCredentialAdministrationDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createService: typeof createLocalIdentityCredentialAdministrationService;
|
||||
readonly createDelivery: (directory: string) => CredentialDelivery;
|
||||
readonly createPepperProvider: (directory: string) => PepperMaterialProvider;
|
||||
readonly randomBytes: (size: number) => Buffer;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Identity credential command is invalid: ${message}`);
|
||||
this.name = 'LocalIdentityCredentialCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialCommandPepperUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_COMMAND_PEPPER_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential active pepper material is unavailable');
|
||||
this.name = 'LocalIdentityCredentialCommandPepperUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialCommandCurrentCredentialError extends Error {
|
||||
readonly code =
|
||||
'LOCAL_IDENTITY_CREDENTIAL_COMMAND_CURRENT_CREDENTIAL_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential current version is unavailable');
|
||||
this.name = 'LocalIdentityCredentialCommandCurrentCredentialError';
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
export {
|
||||
LocalIdentityCredentialAdministrationAuthenticationError,
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
LocalIdentityCredentialAdministrationServiceUnavailableError,
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
} from '@qinglong/local-admin/identity-credential-administration';
|
||||
export type { LocalIdentityCredentialAdministrationService } from '@qinglong/local-admin/identity-credential-administration';
|
||||
export {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type { AuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export { FileLocalCredentialAdministrationDelivery } from '@qinglong/local-owner-console/credential-administration-delivery';
|
||||
export type {
|
||||
LocalCredentialAdministrationDeliveryRecord,
|
||||
LocalCredentialAdministrationDeliverySummary,
|
||||
} from '@qinglong/local-owner-console/credential-administration-delivery';
|
||||
export { LocalOwnerPepperKeyringFileProvider } from '@qinglong/local-owner-console/pepper-custody';
|
||||
export { LocalSqliteAuthenticatedManagementFenceError } from '@qinglong/local-sqlite/authenticated-management';
|
||||
export type { LocalSqliteAuthenticatedUserCredentialFence } from '@qinglong/local-sqlite/authenticated-management';
|
||||
export { openLocalSqliteIdentityCredentialAdministrationDatabase } from '@qinglong/local-sqlite/identity-credential-administration';
|
||||
export type { LocalSqliteIdentityCredentialAdministrationDatabase } from '@qinglong/local-sqlite/identity-credential-administration';
|
||||
export {
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
ApiCredentialAdministrationSubjectNotFoundError,
|
||||
ApiCredentialAdministrationVersionConflictError,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
export type { ApiCredentialRecord } from '@qinglong/runtime-core/api-credential';
|
||||
export {
|
||||
API_CREDENTIAL_SECRET_BYTES,
|
||||
apiCredentialSecretDigest,
|
||||
} from '@qinglong/runtime-core/api-credential-token';
|
||||
export {
|
||||
IdentityAdministrationMutationConflictError,
|
||||
IdentityAdministrationVersionConflictError,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
export {
|
||||
LocalCredentialDeliveryMutationConflictError,
|
||||
LocalCredentialOwnerContinuityError,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
LocalIdentityOwnerBindingConflictError,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
export type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
export type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
ApiCredentialAdministrationSubjectNotFoundError,
|
||||
ApiCredentialAdministrationVersionConflictError,
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
IdentityAdministrationMutationConflictError,
|
||||
IdentityAdministrationVersionConflictError,
|
||||
LocalCredentialDeliveryMutationConflictError,
|
||||
LocalCredentialOwnerContinuityError,
|
||||
LocalIdentityCredentialAdministrationAuthenticationError,
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
LocalIdentityCredentialAdministrationServiceUnavailableError,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
LocalIdentityOwnerBindingConflictError,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type AuthenticatedLocalCommand,
|
||||
type LocalCredentialAdministrationDeliverySummary,
|
||||
type LocalIdentityCredentialAdministrationService,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
type LocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
type SecurityAuditRecord,
|
||||
type SecuritySubject,
|
||||
} from './executionAuthority';
|
||||
import {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
LocalIdentityCredentialCommandCurrentCredentialError,
|
||||
type LocalApiCredentialIssueCommand,
|
||||
type LocalIdentityCredentialCommand,
|
||||
type LocalIdentityCredentialCommandResult,
|
||||
type LocalIdentityCredentialCommandRunnerDependencies,
|
||||
} from './contracts';
|
||||
|
||||
export function dependencies(
|
||||
value: LocalIdentityCredentialCommandRunnerDependencies,
|
||||
): Readonly<LocalIdentityCredentialCommandRunnerDependencies> {
|
||||
const expected = [
|
||||
'openDatabase',
|
||||
'authenticate',
|
||||
'createService',
|
||||
'createDelivery',
|
||||
'createPepperProvider',
|
||||
'randomBytes',
|
||||
'now',
|
||||
];
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !== expected.sort().join('\0') ||
|
||||
expected.some(
|
||||
(key) =>
|
||||
typeof value[
|
||||
key as keyof LocalIdentityCredentialCommandRunnerDependencies
|
||||
] !== 'function',
|
||||
)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function clock(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'clock is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function failureAudit(
|
||||
command: Readonly<LocalIdentityCredentialCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
if (
|
||||
error instanceof LocalIdentityCredentialAdministrationAuthenticationError ||
|
||||
error instanceof LocalIdentityCredentialAdministrationAuthorizationError ||
|
||||
error instanceof
|
||||
LocalIdentityCredentialAdministrationServiceUnavailableError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof LocalIdentityCredentialAuthorizationFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (
|
||||
error instanceof LocalIdentityOwnerBindingConflictError ||
|
||||
error instanceof LocalCredentialOwnerContinuityError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'owner_continuity_required';
|
||||
} else if (
|
||||
error instanceof IdentityAdministrationVersionConflictError ||
|
||||
error instanceof ApiCredentialAdministrationVersionConflictError ||
|
||||
error instanceof ApiCredentialAdministrationSubjectNotFoundError ||
|
||||
error instanceof LocalIdentityCredentialCommandCurrentCredentialError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'current_version_or_subject_conflict';
|
||||
} else if (
|
||||
error instanceof IdentityAdministrationMutationConflictError ||
|
||||
error instanceof ApiCredentialAdministrationMutationConflictError ||
|
||||
error instanceof LocalCredentialDeliveryMutationConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'mutation_conflict';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId:
|
||||
'failureAuditEventId' in command.request
|
||||
? command.request.failureAuditEventId
|
||||
: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateFence(
|
||||
database: LocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
}
|
||||
|
||||
export function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
export function activeCredentialResult(
|
||||
command: Readonly<LocalApiCredentialIssueCommand>,
|
||||
result: Awaited<
|
||||
ReturnType<LocalIdentityCredentialAdministrationService['changeCredential']>
|
||||
>,
|
||||
delivery: Readonly<LocalCredentialAdministrationDeliverySummary>,
|
||||
): Readonly<LocalIdentityCredentialCommandResult> {
|
||||
if (
|
||||
result.credential.credentialId !== command.request.credentialId ||
|
||||
result.credential.version !== command.request.expectedCurrentVersion + 1 ||
|
||||
!sameSubject(result.credential.subject, command.request.target) ||
|
||||
result.credential.state !== 'active' ||
|
||||
result.delivery?.digest !== delivery.deliveryDigest ||
|
||||
delivery.mutationId !== command.request.mutationId ||
|
||||
delivery.credentialId !== command.request.credentialId ||
|
||||
delivery.projectId !== command.request.projectId ||
|
||||
!sameSubject(delivery.subject, command.request.target) ||
|
||||
path.dirname(delivery.path) !== command.options.credentialDeliveryDirectory
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
projectId: command.request.projectId,
|
||||
target: result.credential.subject,
|
||||
credentialId: result.credential.credentialId,
|
||||
version: result.credential.version,
|
||||
state: result.credential.state,
|
||||
expiresAtMs: result.credential.expiresAtMs,
|
||||
delivery: Object.freeze({
|
||||
fileName: path.basename(delivery.path),
|
||||
digest: delivery.deliveryDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
import { randomBytes as cryptoRandomBytes } from 'node:crypto';
|
||||
import {
|
||||
API_CREDENTIAL_SECRET_BYTES,
|
||||
FileLocalCredentialAdministrationDelivery,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
apiCredentialSecretDigest,
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
establishAuthenticatedLocalCommand,
|
||||
openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
type ApiCredentialRecord,
|
||||
type AuthenticatedLocalCommand,
|
||||
type LocalCredentialAdministrationDeliveryRecord,
|
||||
} from './executionAuthority';
|
||||
import {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
LocalIdentityCredentialCommandCurrentCredentialError,
|
||||
LocalIdentityCredentialCommandPepperUnavailableError,
|
||||
type LocalApiCredentialInspectionCommand,
|
||||
type LocalApiCredentialIssueCommand,
|
||||
type LocalApiCredentialRevokeCommand,
|
||||
type LocalCredentialDeliveryAcknowledgeCommand,
|
||||
type LocalIdentityAdministrationCommand,
|
||||
type LocalIdentityCredentialCommandResult,
|
||||
type LocalIdentityCredentialCommandRunner,
|
||||
type LocalIdentityCredentialCommandRunnerDependencies,
|
||||
type LocalIdentityInspectionCommand,
|
||||
} from './contracts';
|
||||
import { readCommandFile } from './codec';
|
||||
import {
|
||||
activateFence,
|
||||
activeCredentialResult,
|
||||
clock,
|
||||
dependencies,
|
||||
failureAudit,
|
||||
sameSubject,
|
||||
} from './executionSupport';
|
||||
|
||||
export function createLocalIdentityCredentialCommandRunner(
|
||||
candidateDependencies: LocalIdentityCredentialCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createService: createLocalIdentityCredentialAdministrationService,
|
||||
createDelivery: (directory) =>
|
||||
new FileLocalCredentialAdministrationDelivery(directory),
|
||||
createPepperProvider: (directory) =>
|
||||
new LocalOwnerPepperKeyringFileProvider(directory),
|
||||
randomBytes: cryptoRandomBytes,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalIdentityCredentialCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_identity_admin',
|
||||
});
|
||||
await activateFence(database, authenticated);
|
||||
const commandNowMs = clock(adapters.now);
|
||||
const service = adapters.createService(
|
||||
database.projectPolicy,
|
||||
database.identityCredentialAdministration,
|
||||
{ now: () => commandNowMs },
|
||||
);
|
||||
|
||||
if (command.operation === 'identity.inspect') {
|
||||
const inspectCommand =
|
||||
command as Readonly<LocalIdentityInspectionCommand>;
|
||||
const result = await service.inspectIdentity({
|
||||
projectId: inspectCommand.request.projectId,
|
||||
target: inspectCommand.request.target,
|
||||
auditEventId: inspectCommand.request.auditEventId,
|
||||
requestId: inspectCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
if (!result.identity) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: false as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: true as const,
|
||||
target: result.identity.subject,
|
||||
version: result.identity.version,
|
||||
identityStatus: result.identity.status,
|
||||
createdAtMs: result.identity.createdAtMs,
|
||||
updatedAtMs: result.identity.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (command.operation === 'credential.inspect') {
|
||||
const inspectCommand =
|
||||
command as Readonly<LocalApiCredentialInspectionCommand>;
|
||||
const result = await service.inspectCredential({
|
||||
projectId: inspectCommand.request.projectId,
|
||||
credentialId: inspectCommand.request.credentialId,
|
||||
auditEventId: inspectCommand.request.auditEventId,
|
||||
requestId: inspectCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
if (!result.credential) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: false as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: true as const,
|
||||
credentialId: result.credential.credentialId,
|
||||
target: result.credential.subject,
|
||||
version: result.credential.version,
|
||||
state: result.credential.state,
|
||||
subjectStatus: result.credential.subjectStatus,
|
||||
createdAtMs: result.credential.createdAtMs,
|
||||
notBeforeAtMs: result.credential.notBeforeAtMs,
|
||||
expiresAtMs: result.credential.expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (command.operation.startsWith('identity.')) {
|
||||
const identityCommand =
|
||||
command as Readonly<LocalIdentityAdministrationCommand>;
|
||||
const result = await service.changeIdentity({
|
||||
projectId: identityCommand.request.projectId,
|
||||
operation: identityCommand.operation.slice('identity.'.length) as
|
||||
| 'register'
|
||||
| 'enable'
|
||||
| 'disable',
|
||||
target: identityCommand.request.target,
|
||||
expectedCurrentVersion:
|
||||
identityCommand.request.expectedCurrentVersion,
|
||||
mutationId: identityCommand.request.mutationId,
|
||||
requestId: identityCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: identityCommand.operation,
|
||||
status: result.status,
|
||||
projectId: identityCommand.request.projectId,
|
||||
target: result.identity.subject,
|
||||
version: result.identity.version,
|
||||
identityStatus: result.identity.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
command.operation === 'credential.issue' ||
|
||||
command.operation === 'credential.rotate'
|
||||
) {
|
||||
const credentialCommand =
|
||||
command as Readonly<LocalApiCredentialIssueCommand>;
|
||||
const active = await database.ownerPepper.resolveActive();
|
||||
if (!active) {
|
||||
throw new LocalIdentityCredentialCommandPepperUnavailableError();
|
||||
}
|
||||
const key = await database.ownerPepper.resolveKey(
|
||||
active.activePepperKeyId,
|
||||
);
|
||||
const pepper = adapters
|
||||
.createPepperProvider(
|
||||
credentialCommand.options.ownerPepperKeyringDirectory,
|
||||
)
|
||||
.resolve(active.activePepperKeyId);
|
||||
if (
|
||||
!key ||
|
||||
key.state !== 'active' ||
|
||||
key.materialDigest !== active.materialDigest ||
|
||||
!pepper ||
|
||||
pepper.summary.digest !== active.materialDigest
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandPepperUnavailableError();
|
||||
}
|
||||
const material = adapters.randomBytes(API_CREDENTIAL_SECRET_BYTES);
|
||||
if (
|
||||
!Buffer.isBuffer(material) ||
|
||||
material.byteLength !== API_CREDENTIAL_SECRET_BYTES
|
||||
) {
|
||||
material?.fill?.(0);
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'random source returned invalid credential material',
|
||||
);
|
||||
}
|
||||
let prepared: Readonly<LocalCredentialAdministrationDeliveryRecord>;
|
||||
try {
|
||||
const delivery = adapters.createDelivery(
|
||||
credentialCommand.options.credentialDeliveryDirectory,
|
||||
);
|
||||
prepared = delivery.prepare({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-managed-credential-delivery',
|
||||
mutationId: credentialCommand.request.mutationId,
|
||||
requestId: credentialCommand.request.requestId,
|
||||
projectId: credentialCommand.request.projectId,
|
||||
subject: credentialCommand.request.target,
|
||||
credentialId: credentialCommand.request.credentialId,
|
||||
secret: material.toString('base64url'),
|
||||
notBeforeAtMs: commandNowMs,
|
||||
expiresAtMs:
|
||||
commandNowMs + credentialCommand.request.lifetimeMs,
|
||||
});
|
||||
const deliveryDigest = delivery.digest(prepared);
|
||||
const result = await service.changeCredential({
|
||||
projectId: credentialCommand.request.projectId,
|
||||
operation:
|
||||
credentialCommand.operation === 'credential.issue'
|
||||
? 'issue'
|
||||
: 'rotate',
|
||||
credentialId: credentialCommand.request.credentialId,
|
||||
target: credentialCommand.request.target,
|
||||
expectedCurrentVersion:
|
||||
credentialCommand.request.expectedCurrentVersion,
|
||||
pepperKeyId: active.activePepperKeyId,
|
||||
secretDigest: apiCredentialSecretDigest(
|
||||
pepper.pepper,
|
||||
credentialCommand.request.credentialId,
|
||||
prepared.secret,
|
||||
),
|
||||
deliveryDigest,
|
||||
notBeforeAtMs: prepared.notBeforeAtMs,
|
||||
expiresAtMs: prepared.expiresAtMs,
|
||||
mutationId: credentialCommand.request.mutationId,
|
||||
requestId: credentialCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
const published = delivery.publish(prepared, deliveryDigest);
|
||||
return activeCredentialResult(
|
||||
credentialCommand,
|
||||
result,
|
||||
published,
|
||||
);
|
||||
} finally {
|
||||
material.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (command.operation === 'credential.revoke') {
|
||||
const credentialCommand =
|
||||
command as Readonly<LocalApiCredentialRevokeCommand>;
|
||||
const current: Readonly<ApiCredentialRecord> | null =
|
||||
await database.apiCredentials.resolve(
|
||||
credentialCommand.request.credentialId,
|
||||
);
|
||||
if (
|
||||
!current ||
|
||||
!sameSubject(current.subject, credentialCommand.request.target)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandCurrentCredentialError();
|
||||
}
|
||||
const result = await service.changeCredential({
|
||||
projectId: credentialCommand.request.projectId,
|
||||
operation: 'revoke',
|
||||
credentialId: credentialCommand.request.credentialId,
|
||||
target: credentialCommand.request.target,
|
||||
expectedCurrentVersion:
|
||||
credentialCommand.request.expectedCurrentVersion,
|
||||
pepperKeyId: current.pepperKeyId,
|
||||
mutationId: credentialCommand.request.mutationId,
|
||||
requestId: credentialCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: credentialCommand.operation,
|
||||
status: result.status,
|
||||
projectId: credentialCommand.request.projectId,
|
||||
target: result.credential.subject,
|
||||
credentialId: result.credential.credentialId,
|
||||
version: result.credential.version,
|
||||
state: result.credential.state,
|
||||
expiresAtMs: result.credential.expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
const acknowledgementCommand =
|
||||
command as Readonly<LocalCredentialDeliveryAcknowledgeCommand>;
|
||||
const result = await service.acknowledgeCredentialDelivery({
|
||||
projectId: acknowledgementCommand.request.projectId,
|
||||
credentialMutationId:
|
||||
acknowledgementCommand.request.credentialMutationId,
|
||||
expectedDeliveryDigest:
|
||||
acknowledgementCommand.request.expectedDeliveryDigest,
|
||||
mutationId: acknowledgementCommand.request.mutationId,
|
||||
requestId: acknowledgementCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
const acknowledgement = result.acknowledgement;
|
||||
if (
|
||||
acknowledgement.credentialMutationId !==
|
||||
acknowledgementCommand.request.credentialMutationId ||
|
||||
acknowledgement.acknowledgementMutationId !==
|
||||
acknowledgementCommand.request.mutationId ||
|
||||
acknowledgement.projectId !==
|
||||
acknowledgementCommand.request.projectId ||
|
||||
acknowledgement.deliveryDigest !==
|
||||
acknowledgementCommand.request.expectedDeliveryDigest
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
const cleanup = adapters
|
||||
.createDelivery(
|
||||
acknowledgementCommand.options.credentialDeliveryDirectory,
|
||||
)
|
||||
.removeAcknowledged(
|
||||
acknowledgement.credentialMutationId,
|
||||
acknowledgement.deliveryDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: acknowledgementCommand.operation,
|
||||
status: result.status,
|
||||
projectId: acknowledgement.projectId,
|
||||
credentialMutationId: acknowledgement.credentialMutationId,
|
||||
acknowledgementMutationId:
|
||||
acknowledgement.acknowledgementMutationId,
|
||||
deliveryDigest: acknowledgement.deliveryDigest,
|
||||
cleanup,
|
||||
});
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
clock(adapters.now),
|
||||
);
|
||||
if (audit) {
|
||||
await database.identityCredentialAdministration.record(audit);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalIdentityCredentialCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalIdentityCredentialCommandResult>> {
|
||||
return createLocalIdentityCredentialCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalIdentityCredentialCommandFile } from './identityCredentialCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-identity run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_IDENTITY_CREDENTIAL_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalIdentityCredentialCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_IDENTITY_CREDENTIAL_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Identity credential command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,23 @@
|
||||
// Security management owns identity and API credential administration commands.
|
||||
export {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
LocalIdentityCredentialCommandCurrentCredentialError,
|
||||
LocalIdentityCredentialCommandPepperUnavailableError,
|
||||
} from './identity-credential-command/contracts';
|
||||
export type {
|
||||
LocalApiCredentialInspectionCommand,
|
||||
LocalApiCredentialIssueCommand,
|
||||
LocalApiCredentialRevokeCommand,
|
||||
LocalCredentialDeliveryAcknowledgeCommand,
|
||||
LocalIdentityAdministrationCommand,
|
||||
LocalIdentityCredentialCommand,
|
||||
LocalIdentityCredentialCommandOptions,
|
||||
LocalIdentityCredentialCommandResult,
|
||||
LocalIdentityCredentialCommandRunner,
|
||||
LocalIdentityCredentialCommandRunnerDependencies,
|
||||
LocalIdentityInspectionCommand,
|
||||
} from './identity-credential-command/contracts';
|
||||
export {
|
||||
createLocalIdentityCredentialCommandRunner,
|
||||
runLocalIdentityCredentialCommandFile,
|
||||
} from './identity-credential-command/runner';
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalProjectPolicyCommandFile } from './projectPolicyCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-policy run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_PROJECT_POLICY_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalProjectPolicyCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_PROJECT_POLICY_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Project policy command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalSecretCommandFile } from './secretCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-secret run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_SECRET_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalSecretCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_SECRET_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Secret command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,475 @@
|
||||
// Security management owns authenticated Local Secret mutation commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
readPrivateLocalJsonFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import { LocalSecretKeyringFileProvider } from '@qinglong/local-secret';
|
||||
import {
|
||||
LocalSecretAdministrationAuthenticationError,
|
||||
LocalSecretAdministrationAuthorizationError,
|
||||
LocalSecretAdministrationUnavailableError,
|
||||
createLocalSecretAdministrationService,
|
||||
} from '@qinglong/local-admin/secret-administration';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
import {
|
||||
openLocalSqliteSecretAdministrationDatabase,
|
||||
type LocalSqliteSecretAdministrationDatabase,
|
||||
} from '@qinglong/local-sqlite/secret-administration';
|
||||
import { LocalSecretAuthorizationFenceConflictError } from '@qinglong/runtime-core/local-secret-administration';
|
||||
import {
|
||||
MAX_LOCAL_SECRET_PLAINTEXT_BYTES,
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretVersionConflictError,
|
||||
assertLocalSecretExpectedVersion,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretPlaintext,
|
||||
assertLocalSecretProjectId,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_SECRET_VALUE_FILE_BYTES = MAX_LOCAL_SECRET_PLAINTEXT_BYTES + 1024;
|
||||
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}$/;
|
||||
|
||||
export interface LocalSecretCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly secretKeyringPath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PutLocalSecretCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'secret.put';
|
||||
readonly options: LocalSecretCommandOptions;
|
||||
readonly request: {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly secretValueFilePath: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalSecretCommand = PutLocalSecretCommand;
|
||||
|
||||
export type LocalSecretCommandResult = Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: LocalSecretCommand['operation'];
|
||||
status: 'inserted' | 'existing';
|
||||
version: number;
|
||||
secretRef: string;
|
||||
}>;
|
||||
|
||||
export interface LocalSecretCommandRunner {
|
||||
run(commandFilePath: string): Promise<Readonly<LocalSecretCommandResult>>;
|
||||
}
|
||||
|
||||
export interface LocalSecretCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteSecretAdministrationDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createService: typeof createLocalSecretAdministrationService;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalSecretCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_SECRET_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Secret command configuration is invalid: ${message}`);
|
||||
this.name = 'LocalSecretCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
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 LocalSecretCommandConfigurationError(
|
||||
`${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 LocalSecretCommandConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(value: unknown): Readonly<LocalSecretCommandOptions> {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
'secretKeyringPath',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
for (const key of [
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
'secretKeyringPath',
|
||||
] as const) {
|
||||
descendant(deploymentRoot, boundedPath(value[key], key), key);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalSecretCommandConfigurationError('busyTimeoutMs is invalid');
|
||||
}
|
||||
return Object.freeze(value as unknown as LocalSecretCommandOptions);
|
||||
}
|
||||
|
||||
function normalizeRequest(
|
||||
value: unknown,
|
||||
deploymentRoot: string,
|
||||
): Readonly<PutLocalSecretCommand['request']> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'projectId',
|
||||
'name',
|
||||
'secretValueFilePath',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'failureAuditEventId',
|
||||
'expectedCurrentVersion',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const secretValueFilePath = boundedPath(
|
||||
value.secretValueFilePath,
|
||||
'secretValueFilePath',
|
||||
);
|
||||
descendant(deploymentRoot, secretValueFilePath, 'secretValueFilePath');
|
||||
try {
|
||||
assertLocalSecretProjectId(value.projectId);
|
||||
assertLocalSecretName(value.name);
|
||||
assertLocalSecretExpectedVersion(value.expectedCurrentVersion);
|
||||
} catch (error) {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'Secret identity or expected version is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.mutationId) ||
|
||||
typeof value.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.failureAuditEventId) ||
|
||||
value.failureAuditEventId === value.mutationId ||
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId)
|
||||
) {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze(value as unknown as PutLocalSecretCommand['request']);
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): Readonly<LocalSecretCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['schemaVersion', 'operation', 'options', 'request'],
|
||||
'command',
|
||||
);
|
||||
if (value.schemaVersion !== 1 || value.operation !== 'secret.put') {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
const options = normalizeOptions(value.options);
|
||||
const request = normalizeRequest(value.request, options.deploymentRoot);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'secret.put',
|
||||
options,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
function readCommandFile(candidatePath: string): Readonly<LocalSecretCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretCommandConfigurationError) throw error;
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readSecretValue(filePath: string): string {
|
||||
try {
|
||||
const value = readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_SECRET_VALUE_FILE_BYTES,
|
||||
});
|
||||
exactObject(value, ['kind', 'schemaVersion', 'value'], 'secret value');
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
value.kind !== 'qinglong3-local-secret-value'
|
||||
) {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'secret value version or kind is invalid',
|
||||
);
|
||||
}
|
||||
assertLocalSecretPlaintext(value.value);
|
||||
return value.value;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretCommandConfigurationError) throw error;
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'secret value file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'secret value is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalSecretCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
if (
|
||||
error instanceof LocalSecretAdministrationAuthenticationError ||
|
||||
error instanceof LocalSecretAdministrationAuthorizationError ||
|
||||
error instanceof LocalSecretAdministrationUnavailableError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof LocalSecretAuthorizationFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (error instanceof LocalSecretVersionConflictError) {
|
||||
outcome = 'denied';
|
||||
reason = 'current_version_conflict';
|
||||
} else if (error instanceof LocalSecretMutationConflictError) {
|
||||
outcome = 'denied';
|
||||
reason = 'mutation_conflict';
|
||||
} else if (error instanceof LocalSecretCommandConfigurationError) {
|
||||
outcome = 'denied';
|
||||
reason = 'secret_value_rejected';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: 'secret.manage',
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalSecretCommandRunnerDependencies,
|
||||
): Readonly<LocalSecretCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'createService', 'now', 'openDatabase']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.createService !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalSecretCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
async function activateFence(
|
||||
database: LocalSqliteSecretAdministrationDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
}
|
||||
|
||||
export function createLocalSecretCommandRunner(
|
||||
candidateDependencies: LocalSecretCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteSecretAdministrationDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createService: createLocalSecretAdministrationService,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalSecretCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_secret',
|
||||
});
|
||||
await activateFence(database, authenticated);
|
||||
const plaintext = readSecretValue(
|
||||
command.request.secretValueFilePath,
|
||||
);
|
||||
await activateFence(database, authenticated);
|
||||
const service = adapters.createService(
|
||||
database.projectPolicy,
|
||||
database.localSecretAdministration,
|
||||
database.securityAudit,
|
||||
new LocalSecretKeyringFileProvider(
|
||||
command.options.secretKeyringPath,
|
||||
),
|
||||
{ now: adapters.now },
|
||||
);
|
||||
const result = await service.put({
|
||||
projectId: command.request.projectId,
|
||||
name: command.request.name,
|
||||
plaintext,
|
||||
mutationId: command.request.mutationId,
|
||||
requestId: command.request.requestId,
|
||||
expectedCurrentVersion: command.request.expectedCurrentVersion,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
version: result.version,
|
||||
secretRef: result.secretRef,
|
||||
});
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalSecretCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalSecretCommandResult>> {
|
||||
return createLocalSecretCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalSecurityAuditQueryCommandFile } from './securityAuditQueryCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-audit run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_SECURITY_AUDIT_QUERY_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalSecurityAuditQueryCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_SECURITY_AUDIT_QUERY_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local security audit query command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,636 @@
|
||||
// Security management owns bounded audit inspection and retention commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
LocalSecurityAuditQueryAuthenticationError,
|
||||
LocalSecurityAuditQueryAuthorizationError,
|
||||
createLocalSecurityAuditQueryService,
|
||||
type LocalSecurityAuditQueryService,
|
||||
} from '@qinglong/local-admin/security-audit-query';
|
||||
import {
|
||||
LocalSecurityAuditRetentionAuthenticationError,
|
||||
LocalSecurityAuditRetentionAuthorizationError,
|
||||
createLocalSecurityAuditRetentionService,
|
||||
type LocalSecurityAuditRetentionService,
|
||||
} from '@qinglong/local-admin/security-audit-retention';
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
import {
|
||||
openLocalSqliteSecurityAuditQueryDatabase,
|
||||
type LocalSqliteSecurityAuditQueryDatabase,
|
||||
} from '@qinglong/local-sqlite/security-audit-query';
|
||||
import {
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
LocalSecurityAuditQueryUnavailableError,
|
||||
MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE,
|
||||
} from '@qinglong/runtime-core/local-security-audit-query';
|
||||
import {
|
||||
LocalSecurityAuditCompactionMutationConflictError,
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
LocalSecurityAuditRetentionUnavailableError,
|
||||
MAX_EDGE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE,
|
||||
MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE,
|
||||
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
type LocalSecurityAuditCompactionRecord,
|
||||
} from '@qinglong/runtime-core/local-security-audit-retention';
|
||||
import { assertProjectPolicyProjectId } from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
normalizeSecurityAuditQuery,
|
||||
type SecurityAuditQuery,
|
||||
type SecurityAuditQueryCursor,
|
||||
} from '@qinglong/runtime-core/security-audit-query';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
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}$/;
|
||||
|
||||
export interface LocalSecurityAuditQueryCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalSecurityAuditQueryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'security.audit.list';
|
||||
readonly options: LocalSecurityAuditQueryCommandOptions;
|
||||
readonly request: {
|
||||
readonly authorityProjectId: string;
|
||||
readonly query: SecurityAuditQuery;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalSecurityAuditCompactionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'security.audit.compact';
|
||||
readonly options: LocalSecurityAuditQueryCommandOptions;
|
||||
readonly request: {
|
||||
readonly authorityProjectId: string;
|
||||
readonly retentionMs: number;
|
||||
readonly eligibleBeforeMs: number;
|
||||
readonly limit: number;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalSecurityAuditCommand =
|
||||
| LocalSecurityAuditQueryCommand
|
||||
| LocalSecurityAuditCompactionCommand;
|
||||
|
||||
export interface RedactedLocalSecurityAuditRecord {
|
||||
readonly eventId: string;
|
||||
readonly requestId: string;
|
||||
readonly operationId: string;
|
||||
readonly projectId: string | null;
|
||||
readonly subject: SecurityAuditRecord['subject'];
|
||||
readonly outcome: SecurityAuditRecord['outcome'];
|
||||
readonly reasons: readonly string[];
|
||||
readonly fence: SecurityAuditRecord['fence'];
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface LocalSecurityAuditQueryCommandResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'security.audit.list';
|
||||
readonly records: readonly Readonly<RedactedLocalSecurityAuditRecord>[];
|
||||
readonly nextCursor: Readonly<SecurityAuditQueryCursor> | null;
|
||||
}
|
||||
|
||||
export interface LocalSecurityAuditCompactionCommandResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'security.audit.compact';
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly mutationId: string;
|
||||
readonly retentionMs: number;
|
||||
readonly eligibleBeforeMs: number;
|
||||
readonly batchLimit: number;
|
||||
readonly deletedCount: number;
|
||||
readonly deletedPayloadBytes: number;
|
||||
readonly first: LocalSecurityAuditCompactionRecord['first'];
|
||||
readonly last: LocalSecurityAuditCompactionRecord['last'];
|
||||
readonly recordsDigest: string;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export type LocalSecurityAuditCommandResult =
|
||||
| LocalSecurityAuditQueryCommandResult
|
||||
| LocalSecurityAuditCompactionCommandResult;
|
||||
|
||||
export interface LocalSecurityAuditQueryCommandRunner {
|
||||
run(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalSecurityAuditCommandResult>>;
|
||||
}
|
||||
|
||||
export interface LocalSecurityAuditQueryCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteSecurityAuditQueryDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createService: typeof createLocalSecurityAuditQueryService;
|
||||
readonly createRetentionService: typeof createLocalSecurityAuditRetentionService;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalSecurityAuditQueryCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_COMMAND_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local security audit query command is invalid: ${message}`);
|
||||
this.name = 'LocalSecurityAuditQueryCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
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 LocalSecurityAuditQueryCommandConfigurationError(
|
||||
`${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 LocalSecurityAuditQueryCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalSecurityAuditQueryCommandOptions> {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
for (const key of [
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
] as const) {
|
||||
descendant(deploymentRoot, boundedPath(value[key], key), key);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
value as unknown as LocalSecurityAuditQueryCommandOptions,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): Readonly<LocalSecurityAuditCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['schemaVersion', 'operation', 'options', 'request'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
(value.operation !== 'security.audit.list' &&
|
||||
value.operation !== 'security.audit.compact')
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
const options = normalizeOptions(value.options);
|
||||
const compact = value.operation === 'security.audit.compact';
|
||||
exactObject(
|
||||
value.request,
|
||||
compact
|
||||
? [
|
||||
'authorityProjectId',
|
||||
'retentionMs',
|
||||
'eligibleBeforeMs',
|
||||
'limit',
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'failureAuditEventId',
|
||||
]
|
||||
: ['authorityProjectId', 'query', 'requestId', 'auditEventId'],
|
||||
'request',
|
||||
);
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.request.authorityProjectId as string);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'authority Project identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.request.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.request.requestId)
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'audit or request identity is invalid',
|
||||
);
|
||||
}
|
||||
if (compact) {
|
||||
const profileLimit =
|
||||
options.profile === 'edge'
|
||||
? MAX_EDGE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE
|
||||
: MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE;
|
||||
if (
|
||||
typeof value.request.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.mutationId) ||
|
||||
typeof value.request.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.failureAuditEventId) ||
|
||||
value.request.mutationId === value.request.failureAuditEventId ||
|
||||
!Number.isSafeInteger(value.request.retentionMs) ||
|
||||
(value.request.retentionMs as number) <
|
||||
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
|
||||
(value.request.retentionMs as number) >
|
||||
MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
|
||||
!Number.isSafeInteger(value.request.eligibleBeforeMs) ||
|
||||
(value.request.eligibleBeforeMs as number) < 0 ||
|
||||
!Number.isSafeInteger(
|
||||
(value.request.eligibleBeforeMs as number) +
|
||||
(value.request.retentionMs as number),
|
||||
) ||
|
||||
!Number.isSafeInteger(value.request.limit) ||
|
||||
(value.request.limit as number) < 1 ||
|
||||
(value.request.limit as number) > profileLimit
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'compaction identity, retention fence, or limit is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'security.audit.compact',
|
||||
options,
|
||||
request: Object.freeze({
|
||||
authorityProjectId: value.request.authorityProjectId as string,
|
||||
retentionMs: value.request.retentionMs as number,
|
||||
eligibleBeforeMs: value.request.eligibleBeforeMs as number,
|
||||
limit: value.request.limit as number,
|
||||
mutationId: value.request.mutationId,
|
||||
requestId: value.request.requestId,
|
||||
failureAuditEventId: value.request.failureAuditEventId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof value.request.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.auditEventId)
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'audit identity is invalid',
|
||||
);
|
||||
}
|
||||
let query: Readonly<SecurityAuditQuery>;
|
||||
try {
|
||||
query = normalizeSecurityAuditQuery(
|
||||
value.request.query as SecurityAuditQuery,
|
||||
);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'filter, cursor, or limit is invalid',
|
||||
);
|
||||
}
|
||||
if (query.limit > MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'limit exceeds the local maximum of 64',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'security.audit.list',
|
||||
options,
|
||||
request: Object.freeze({
|
||||
authorityProjectId: value.request.authorityProjectId as string,
|
||||
query,
|
||||
requestId: value.request.requestId,
|
||||
auditEventId: value.request.auditEventId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
candidatePath: string,
|
||||
): Readonly<LocalSecurityAuditCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecurityAuditQueryCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalSecurityAuditCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
if (
|
||||
error instanceof LocalSecurityAuditQueryAuthorizationError ||
|
||||
error instanceof LocalSecurityAuditQueryUnavailableError ||
|
||||
error instanceof LocalSecurityAuditRetentionAuthorizationError ||
|
||||
error instanceof LocalSecurityAuditRetentionUnavailableError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const eventId =
|
||||
command.operation === 'security.audit.compact'
|
||||
? command.request.failureAuditEventId
|
||||
: command.request.auditEventId;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError ||
|
||||
error instanceof LocalSecurityAuditQueryAuthenticationError ||
|
||||
error instanceof LocalSecurityAuditRetentionAuthenticationError
|
||||
) {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.authorityProjectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome: 'authentication_rejected',
|
||||
reasons: Object.freeze(['credential_rejected']),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
if (
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof LocalSecurityAuditQueryAuthorizationFenceConflictError ||
|
||||
error instanceof LocalSecurityAuditRetentionAuthorizationFenceConflictError
|
||||
) {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.authorityProjectId,
|
||||
subject: authenticated.principal.subject,
|
||||
authenticationId: authenticated.principal.authenticationId,
|
||||
outcome: 'denied',
|
||||
reasons: Object.freeze(['credential_or_policy_fence_rejected']),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
if (error instanceof LocalSecurityAuditCompactionMutationConflictError) {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.authorityProjectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome: 'denied',
|
||||
reasons: Object.freeze(['mutation_conflict']),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalSecurityAuditQueryCommandRunnerDependencies,
|
||||
): Readonly<LocalSecurityAuditQueryCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
[
|
||||
'authenticate',
|
||||
'createRetentionService',
|
||||
'createService',
|
||||
'now',
|
||||
'openDatabase',
|
||||
]
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.createService !== 'function' ||
|
||||
typeof value.createRetentionService !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalSecurityAuditQueryCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function redact(
|
||||
record: Readonly<SecurityAuditRecord>,
|
||||
): Readonly<RedactedLocalSecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId: record.eventId,
|
||||
requestId: record.requestId,
|
||||
operationId: record.operationId,
|
||||
projectId: record.projectId,
|
||||
subject: record.subject,
|
||||
outcome: record.outcome,
|
||||
reasons: record.reasons,
|
||||
fence: record.fence,
|
||||
occurredAtMs: record.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalSecurityAuditQueryCommandRunner(
|
||||
candidateDependencies: LocalSecurityAuditQueryCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteSecurityAuditQueryDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createService: createLocalSecurityAuditQueryService,
|
||||
createRetentionService: createLocalSecurityAuditRetentionService,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalSecurityAuditQueryCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_security_audit',
|
||||
});
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
if (command.operation === 'security.audit.compact') {
|
||||
const service: LocalSecurityAuditRetentionService =
|
||||
adapters.createRetentionService(
|
||||
database.projectPolicy,
|
||||
database.securityAuditRetention,
|
||||
{ now: adapters.now },
|
||||
);
|
||||
const result = await service.compact({
|
||||
authorityProjectId: command.request.authorityProjectId,
|
||||
retentionMs: command.request.retentionMs,
|
||||
eligibleBeforeMs: command.request.eligibleBeforeMs,
|
||||
limit: command.request.limit,
|
||||
mutationId: command.request.mutationId,
|
||||
requestId: command.request.requestId,
|
||||
failureAuditEventId: command.request.failureAuditEventId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
mutationId: result.record.mutationId,
|
||||
retentionMs: result.record.retentionMs,
|
||||
eligibleBeforeMs: result.record.eligibleBeforeMs,
|
||||
batchLimit: result.record.batchLimit,
|
||||
deletedCount: result.record.deletedCount,
|
||||
deletedPayloadBytes: result.record.deletedPayloadBytes,
|
||||
first: result.record.first,
|
||||
last: result.record.last,
|
||||
recordsDigest: result.record.recordsDigest,
|
||||
createdAtMs: result.record.createdAtMs,
|
||||
});
|
||||
}
|
||||
const service: LocalSecurityAuditQueryService =
|
||||
adapters.createService(
|
||||
database.projectPolicy,
|
||||
database.securityAuditQuery,
|
||||
{ now: adapters.now },
|
||||
);
|
||||
const result = await service.list({
|
||||
authorityProjectId: command.request.authorityProjectId,
|
||||
query: command.request.query,
|
||||
auditEventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
records: Object.freeze(result.records.map(redact)),
|
||||
nextCursor: result.nextCursor,
|
||||
});
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (audit) {
|
||||
try {
|
||||
await database.securityAudit.record(audit);
|
||||
} catch {
|
||||
throw new LocalSecurityAuditQueryUnavailableError();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalSecurityAuditQueryCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalSecurityAuditCommandResult>> {
|
||||
return createLocalSecurityAuditQueryCommandRunner().run(commandFilePath);
|
||||
}
|
||||
Reference in New Issue
Block a user