blob: 5327587d2bb6d02fcd96858078a8234f7ea76774 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { randomUUID } from 'node:crypto';
import { hostname } from 'node:os';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import {
connectExistingRuntimeHost,
consumeAccessCredentialDelivery,
issueRuntimeHostOwnerConnectionCode,
REMOTE_DESKTOP_OWNER_ACCESS_POLICY,
} from '@maka/runtime-host/client';
import {
isOperationKey,
REMOTE_OWNER_OPERATION_GRANTS,
RUNTIME_HOST_PROTOCOL_VERSION,
type AccessCredentialRotationRevokeInput,
type ManagedAccessCredentialPrincipalKind,
type OperationKey,
} from '@maka/runtime-host/protocol';
import {
readRuntimeHostAccessCredentialMetadata,
type RuntimeHostAccessCredentialMetadata,
} from '@maka/runtime-host/server';
import {
encodeRuntimeHostAccessManagementFrame,
RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES,
type RuntimeHostAccessManagementAction,
} from '@maka/runtime-host/operator';
const PROTOCOL = {
min: RUNTIME_HOST_PROTOCOL_VERSION,
max: RUNTIME_HOST_PROTOCOL_VERSION,
} as const;
export class RuntimeHostAccessUnavailableError extends Error {
constructor(
readonly reason: string,
options?: ErrorOptions,
) {
super(`Runtime Host service is not available (${reason})`, options);
this.name = 'RuntimeHostAccessUnavailableError';
}
}
export interface RuntimeHostAccessIssueOptions {
readonly rootPath: string;
readonly expectedRootId?: string;
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly principalId: string;
readonly operationGrants: readonly string[];
readonly canPublishClientCapabilities: boolean;
readonly canUseHostPaths: boolean;
readonly capabilityOwnerCredentialId?: string;
readonly preset?: RuntimeHostAccessPreset;
readonly bindClientInstance?: boolean;
}
export type RuntimeHostAccessPreset = 'desktop-client' | 'terminal-client';
export interface ResolvedRuntimeHostAccessIssue {
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
readonly canUseHostPaths: boolean;
}
const CLIENT_CAPABILITY_PUBLICATION_OPERATIONS = new Set<OperationKey>([
'client.capability.replace',
'client.capability.unregister',
]);
export interface RuntimeHostAccessListOptions {
readonly rootPath: string;
readonly expectedRootId?: string;
}
export interface RuntimeHostAccessConnectionCodeOptions extends RuntimeHostAccessListOptions {
readonly name?: string;
}
export interface RuntimeHostAccessRevokeOptions extends RuntimeHostAccessListOptions {
readonly credentialId: string;
readonly currentCredentialFingerprint?: string;
}
export interface RuntimeHostAccessPrepareOptions extends RuntimeHostAccessListOptions {
readonly currentCredentialFingerprint: string;
}
export interface IssuedRuntimeHostAccessCredential {
readonly rootId: string;
readonly credential: string;
readonly credentialId: string;
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly principalId: string;
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
readonly canUseHostPaths: boolean;
}
export async function runRuntimeHostAccessIssueCli(
options: RuntimeHostAccessIssueOptions,
): Promise<number> {
const result = await issueRuntimeHostAccessCredential(options);
const { rootId: _rootId, ...output } = result;
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
return 0;
}
export async function runRuntimeHostAccessListCli(
options: RuntimeHostAccessListOptions,
framed = false,
): Promise<number> {
try {
const result = await listRuntimeHostAccessCredentials(options);
process.stdout.write(
framed
? encodeRuntimeHostAccessManagementFrame({
schemaVersion: 1,
kind: 'result',
action: 'list',
credentials: mutableCredentialMetadata(result.credentials),
})
: `${JSON.stringify(result, null, 2)}\n`,
);
return 0;
} catch (error) {
if (!framed) throw error;
writeAccessManagementError('list', error);
return 1;
}
}
export async function runRuntimeHostAccessConnectionCodeCli(
options: RuntimeHostAccessConnectionCodeOptions,
framed = false,
): Promise<number> {
let connection: Awaited<ReturnType<typeof connectLocalOwner>> | undefined;
try {
connection = await connectLocalOwner(options.rootPath, options.expectedRootId);
const connectionCode = await issueRuntimeHostOwnerConnectionCode({
rootPath: options.rootPath,
name: options.name?.trim() || truncateUtf8(hostname(), 128) || 'Runtime Host',
principalId: `connection-code:${randomUUID()}`,
client: connection,
});
process.stdout.write(
framed
? encodeRuntimeHostAccessManagementFrame({
schemaVersion: 1,
kind: 'result',
action: 'connection-code',
connectionCode,
})
: `${connectionCode}\n`,
);
return 0;
} catch (error) {
if (!framed) throw error;
writeAccessManagementError('connection-code', error);
return 1;
} finally {
await connection?.close();
}
}
export async function runRuntimeHostAccessPrepareCli(
options: RuntimeHostAccessPrepareOptions,
): Promise<number> {
try {
const before = await listRuntimeHostAccessCredentials(options);
const current = requireCurrentDesktopCredential(
before.credentials,
options.currentCredentialFingerprint,
);
const prepared = await prepareRuntimeHostAccessCredentialReplacement(
options,
current.credentialId,
);
const listed = await listRuntimeHostAccessCredentials(options);
if (
!listed.credentials.some((credential) => credential.credentialId === prepared.credentialId)
) {
throw new Error('Prepared Runtime Host credential metadata is unavailable');
}
process.stdout.write(
encodeRuntimeHostAccessManagementFrame({
schemaVersion: 1,
kind: 'result',
action: 'prepare',
credential: prepared.credential,
credentials: mutableCredentialMetadata(listed.credentials),
}),
);
return 0;
} catch (error) {
writeAccessManagementError('prepare', error);
return 1;
}
}
export function issueRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
): Promise<IssuedRuntimeHostAccessCredential> {
return mutateRuntimeHostAccessCredential(options, 'access.credential.issue');
}
export function prepareRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
): Promise<IssuedRuntimeHostAccessCredential> {
return mutateRuntimeHostAccessCredential(options, 'access.credential.prepare');
}
export function replaceRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
): Promise<ReplacedRuntimeHostAccessCredential> {
return mutateRuntimeHostAccessCredential(options, 'access.credential.replace');
}
export type ReplacedRuntimeHostAccessCredential = IssuedRuntimeHostAccessCredential;
async function prepareRuntimeHostAccessCredentialReplacement(
options: RuntimeHostAccessListOptions,
replacementOfCredentialId: string,
): Promise<IssuedRuntimeHostAccessCredential> {
const connection = await connectLocalOwner(options.rootPath, options.expectedRootId);
try {
const result = await connection.request('access.credential.rotation.prepare', {
replacementOfCredentialId,
});
const credential = await consumeAccessCredentialDelivery(
options.rootPath,
result.deliveryId,
result.credentialId,
);
const { deliveryId: _deliveryId, ...metadata } = result;
return { rootId: connection.rootId, credential, ...metadata };
} finally {
await connection.close();
}
}
async function mutateRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
operation: 'access.credential.issue' | 'access.credential.prepare' | 'access.credential.replace',
): Promise<IssuedRuntimeHostAccessCredential> {
const resolved = resolveRuntimeHostAccessIssue(options);
const connection = await connectLocalOwner(options.rootPath, options.expectedRootId);
try {
const result = await connection.request(operation, {
principalKind: resolved.principalKind,
principalId: options.principalId,
operationGrants: resolved.operationGrants,
canPublishClientCapabilities: resolved.canPublishClientCapabilities,
canUseHostPaths: resolved.canUseHostPaths,
...(operation !== 'access.credential.prepare' && options.capabilityOwnerCredentialId
? { capabilityOwnerCredentialId: options.capabilityOwnerCredentialId }
: {}),
...(operation === 'access.credential.prepare' && options.bindClientInstance
? { bindClientInstance: true }
: {}),
});
const credential = await consumeAccessCredentialDelivery(
options.rootPath,
result.deliveryId,
result.credentialId,
);
const { deliveryId: _deliveryId, ...metadata } = result;
return { rootId: connection.rootId, credential, ...metadata };
} finally {
await connection.close();
}
}
export async function listRuntimeHostAccessCredentials(
options: RuntimeHostAccessListOptions,
): Promise<{ readonly credentials: readonly RuntimeHostAccessCredentialMetadata[] }> {
return readRuntimeHostAccessCredentialMetadata(options.rootPath, options.expectedRootId);
}
export function resolveRuntimeHostAccessIssue(
options: RuntimeHostAccessIssueOptions,
): ResolvedRuntimeHostAccessIssue {
if (!options.preset) {
return {
principalKind: options.principalKind,
operationGrants: requireOperationGrants(options.operationGrants),
canPublishClientCapabilities: options.canPublishClientCapabilities,
canUseHostPaths: options.canUseHostPaths,
};
}
if (options.preset === 'desktop-client') return REMOTE_DESKTOP_OWNER_ACCESS_POLICY;
const operationGrants = REMOTE_OWNER_OPERATION_GRANTS.filter(
(operation) => !CLIENT_CAPABILITY_PUBLICATION_OPERATIONS.has(operation),
);
return {
principalKind: 'remote_owner',
operationGrants,
canPublishClientCapabilities: false,
canUseHostPaths: false,
};
}
export async function runRuntimeHostAccessRevokeCli(
options: RuntimeHostAccessRevokeOptions,
framed = false,
): Promise<number> {
try {
const before = await listRuntimeHostAccessCredentials(options);
const target = before.credentials.find(
(credential) => credential.credentialId === options.credentialId,
);
const current = options.currentCredentialFingerprint
? requireCurrentDesktopCredential(before.credentials, options.currentCredentialFingerprint)
: undefined;
if (target?.credentialFingerprint === options.currentCredentialFingerprint) {
throw new Error('Rotate this Desktop credential instead of revoking it');
}
const result = await revokeRuntimeHostAccessCredential(
options,
current
? {
credentialId: options.credentialId,
requiredActiveCredentialId: current.credentialId,
}
: undefined,
);
const listed = await listRuntimeHostAccessCredentials(options);
process.stdout.write(
framed
? encodeRuntimeHostAccessManagementFrame({
schemaVersion: 1,
kind: 'result',
action: 'revoke',
...result,
credentials: mutableCredentialMetadata(listed.credentials),
})
: `${JSON.stringify(result)}\n`,
);
return result.revoked ? 0 : 1;
} catch (error) {
if (!framed) throw error;
writeAccessManagementError('revoke', error);
return 1;
}
}
function requireCurrentDesktopCredential(
credentials: readonly RuntimeHostAccessCredentialMetadata[],
fingerprint: string,
): RuntimeHostAccessCredentialMetadata {
const current = credentials.find(
(credential) => credential.credentialFingerprint === fingerprint,
);
if (
!current ||
current.status !== 'active' ||
current.principalKind !== 'remote_owner' ||
!current.canPublishClientCapabilities ||
current.canUseHostPaths
) {
throw new Error('The current Desktop credential is not active on this Runtime Host');
}
return current;
}
function mutableCredentialMetadata(credentials: readonly RuntimeHostAccessCredentialMetadata[]) {
return credentials.map((credential) => ({
...credential,
operationGrants: [...credential.operationGrants],
}));
}
export async function revokeRuntimeHostAccessCredential(
options: RuntimeHostAccessRevokeOptions,
guardedInput?: AccessCredentialRotationRevokeInput,
) {
const connection = await connectLocalOwner(options.rootPath, options.expectedRootId);
try {
return await connection.request(
guardedInput ? 'access.credential.rotation.revoke' : 'access.credential.revoke',
guardedInput ?? { credentialId: options.credentialId },
);
} finally {
await connection.close();
}
}
async function connectLocalOwner(rootPath: string, expectedRootId?: string) {
const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL }).catch(
(error: unknown) => {
throw new RuntimeHostAccessUnavailableError('connection_failed', { cause: error });
},
);
if (result.kind !== 'connected') {
throw new RuntimeHostAccessUnavailableError(
result.kind === 'unavailable' ? result.reason : result.kind,
);
}
if (expectedRootId && result.connection.rootId !== expectedRootId) {
await result.connection.close();
throw new Error('Runtime Host service is bound to a different State Root');
}
return result.connection;
}
function writeAccessManagementError(
action: RuntimeHostAccessManagementAction,
error: unknown,
): void {
process.stdout.write(
encodeRuntimeHostAccessManagementFrame({
schemaVersion: 1,
kind: 'error',
action,
error: {
code: 'access_management_failed',
message:
truncateUtf8(
error instanceof Error ? error.message : String(error),
RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES,
) || 'Runtime Host access management failed',
},
}),
);
}
function requireOperationGrants(values: readonly string[]): readonly OperationKey[] {
const grants = values.flatMap((value) => value.split(',')).filter((value) => value.length > 0);
if (grants.length === 0) throw new Error('At least one --grant is required');
for (const grant of grants) {
if (!isOperationKey(grant)) throw new Error(`Unknown Runtime Host operation grant: ${grant}`);
}
return [...new Set(grants)] as OperationKey[];
}