feat(unified-execute): implement ADR-006 unified invocation path with access control
- Add access control to registry.execute(): checks requiredScopes, requiredScopesAny, and resourceType/resourceAction; rejects with ACCESS_DENIED when identity required but absent; skips when context.trusted is true - Add trusted field to OperationContext schema (internal, set by buildEnv for nested calls to skip redundant scope checks) - Simplify CallHandler to thin adapter: delegates to registry.execute() instead of duplicating lookup, validation, and access control - Remove callMap option from buildEnv(): always uses execute(), propagates context with trusted: true for nested calls - Add access control to subscribe(): same default-deny logic as execute() - Change execute() to throw CallError instead of plain Error for not found, no handler, and validation errors - Export checkAccess from call.ts and index.ts for external use - Remove CallMap type export, update EnvOptions - Update architecture docs: api-surface.md, call-protocol.md, ADR-006 status to implemented, source vs spec drift sections - All 228 tests passing
This commit is contained in:
83
src/call.ts
83
src/call.ts
@@ -1,15 +1,10 @@
|
||||
import { Type, type Static, KindGuard } from "@alkdev/typebox";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
import { Type, type Static } from "@alkdev/typebox";
|
||||
import { createPubSub, type PubSub } from "@alkdev/pubsub";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { OperationRegistry } from "./registry.js";
|
||||
import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||
import { validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
|
||||
import { ResponseEnvelopeSchema, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
|
||||
import { ResponseEnvelopeSchema } from "./response-envelope.js";
|
||||
import type { ResponseEnvelope } from "./response-envelope.js";
|
||||
import type { Identity, OperationContext, AccessControl, OperationSpec } from "./types.js";
|
||||
|
||||
const logger = getLogger("operations:call");
|
||||
import type { Identity, OperationContext, AccessControl } from "./types.js";
|
||||
|
||||
export const CallEventSchema = {
|
||||
"call.requested": Type.Object({
|
||||
@@ -191,66 +186,18 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
|
||||
return async (event: CallRequestedEvent): Promise<void> => {
|
||||
const { requestId, operationId, input, identity } = event;
|
||||
|
||||
const context: OperationContext = {
|
||||
requestId,
|
||||
parentRequestId: event.parentRequestId,
|
||||
identity,
|
||||
};
|
||||
|
||||
try {
|
||||
const spec = registry.getSpec(operationId);
|
||||
|
||||
if (!spec) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`Operation not found: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
const handler = registry.getHandler(operationId);
|
||||
if (!handler) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`No handler registered for operation: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
const accessControl: AccessControl = spec.accessControl as AccessControl;
|
||||
|
||||
if (identity && !checkAccess(accessControl, identity)) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.ACCESS_DENIED,
|
||||
`Access denied for operation: ${operationId}`,
|
||||
{ requiredScopes: accessControl.requiredScopes },
|
||||
);
|
||||
}
|
||||
|
||||
const context: OperationContext = {
|
||||
requestId,
|
||||
parentRequestId: event.parentRequestId,
|
||||
identity,
|
||||
};
|
||||
|
||||
validateOrThrow(spec.inputSchema, input, `Input validation for ${operationId}`);
|
||||
|
||||
const result = await handler(input, context);
|
||||
|
||||
let envelope: ResponseEnvelope;
|
||||
if (isResponseEnvelope(result)) {
|
||||
envelope = result as ResponseEnvelope;
|
||||
} else {
|
||||
envelope = localEnvelope(result, operationId);
|
||||
}
|
||||
|
||||
if (!KindGuard.IsUnknown(spec.outputSchema)) {
|
||||
envelope.data = Value.Cast(spec.outputSchema, envelope.data);
|
||||
}
|
||||
|
||||
const errors = collectErrors(spec.outputSchema, envelope.data);
|
||||
if (errors.length > 0) {
|
||||
logger.warn(`Output validation failed for ${operationId}:\n${formatValueErrors(errors)}`);
|
||||
}
|
||||
const envelope = await registry.execute(operationId, input, context);
|
||||
|
||||
if (callMap) {
|
||||
callMap.respond(requestId, envelope);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const callError = mapError(error);
|
||||
if (callMap) {
|
||||
@@ -262,7 +209,7 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
|
||||
};
|
||||
}
|
||||
|
||||
function checkAccess(accessControl: AccessControl, identity: Identity): boolean {
|
||||
export function checkAccess(accessControl: AccessControl, identity: Identity): boolean {
|
||||
const { requiredScopes, requiredScopesAny, resourceType, resourceAction } = accessControl;
|
||||
|
||||
if (requiredScopes.length > 0) {
|
||||
@@ -286,4 +233,12 @@ function checkAccess(accessControl: AccessControl, identity: Identity): boolean
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isResponseEnvelope(value: unknown): value is ResponseEnvelope {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (!("data" in obj) || !("meta" in obj)) return false;
|
||||
if (typeof obj.meta !== "object" || obj.meta === null) return false;
|
||||
return ["local", "http", "mcp"].includes((obj.meta as Record<string, unknown>).source as string);
|
||||
}
|
||||
33
src/env.ts
33
src/env.ts
@@ -1,24 +1,18 @@
|
||||
import { OperationType } from "./types.js";
|
||||
import type { OperationContext, OperationEnv, Identity } from "./types.js";
|
||||
import type { OperationContext, OperationEnv } from "./types.js";
|
||||
import type { OperationRegistry } from "./registry.js";
|
||||
import type { ResponseEnvelope } from "./response-envelope.js";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger("operations:env");
|
||||
|
||||
export interface CallMap {
|
||||
call(operationId: string, input: unknown, options?: { parentRequestId?: string; deadline?: number; identity?: Identity }): Promise<ResponseEnvelope>;
|
||||
}
|
||||
|
||||
export interface EnvOptions {
|
||||
registry: OperationRegistry;
|
||||
context: OperationContext;
|
||||
allowedNamespaces?: string[];
|
||||
callMap?: CallMap;
|
||||
}
|
||||
|
||||
export function buildEnv(options: EnvOptions): OperationEnv {
|
||||
const { registry, context, allowedNamespaces, callMap } = options;
|
||||
const { registry, context, allowedNamespaces } = options;
|
||||
const specs = registry.getAllSpecs();
|
||||
|
||||
const namespaces: OperationEnv = {};
|
||||
@@ -38,20 +32,15 @@ export function buildEnv(options: EnvOptions): OperationEnv {
|
||||
|
||||
const operationId = `${spec.namespace}.${spec.name}`;
|
||||
|
||||
if (callMap) {
|
||||
namespaces[spec.namespace][spec.name] = async (input: unknown) => {
|
||||
logger.debug(`Call protocol: ${operationId}`);
|
||||
return await callMap.call(operationId, input, {
|
||||
parentRequestId: context.requestId,
|
||||
identity: context.identity,
|
||||
});
|
||||
};
|
||||
} else {
|
||||
namespaces[spec.namespace][spec.name] = async (input: unknown) => {
|
||||
logger.debug(`Executing: ${operationId}`);
|
||||
return await registry.execute(operationId, input, context);
|
||||
};
|
||||
}
|
||||
const nestedContext: OperationContext = {
|
||||
...context,
|
||||
trusted: true,
|
||||
};
|
||||
|
||||
namespaces[spec.namespace][spec.name] = async (input: unknown) => {
|
||||
logger.debug(`Executing: ${operationId}`);
|
||||
return await registry.execute(operationId, input, nestedContext);
|
||||
};
|
||||
}
|
||||
|
||||
return namespaces;
|
||||
|
||||
@@ -3,7 +3,7 @@ export type { IOperationDefinition, OperationHandler, SubscriptionHandler, Ident
|
||||
export { OperationRegistry } from "./registry.js";
|
||||
export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js";
|
||||
export { buildEnv } from "./env.js";
|
||||
export type { CallMap, EnvOptions } from "./env.js";
|
||||
export type { EnvOptions } from "./env.js";
|
||||
export { FromSchema } from "./from_schema.js";
|
||||
export { FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl } from "./from_openapi.js";
|
||||
export type { OpenAPISpec, OpenAPIOperation, OpenAPIParameter, HTTPServiceConfig, OpenAPIFS } from "./from_openapi.js";
|
||||
@@ -11,7 +11,7 @@ export { scanOperations } from "./scanner.js";
|
||||
export type { OperationManifest, ScannerFS } from "./scanner.js";
|
||||
export { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||
export type { CallErrorCode } from "./error.js";
|
||||
export { PendingRequestMap, buildCallHandler } from "./call.js";
|
||||
export { PendingRequestMap, buildCallHandler, checkAccess } from "./call.js";
|
||||
export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js";
|
||||
export { subscribe } from "./subscribe.js";
|
||||
export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler } from "./types.js";
|
||||
import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler, Identity, AccessControl } from "./types.js";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
import { KindGuard } from "@alkdev/typebox";
|
||||
import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
|
||||
import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.js";
|
||||
import { CallError, InfrastructureErrorCode } from "./error.js";
|
||||
import { checkAccess } from "./call.js";
|
||||
|
||||
const logger = getLogger("operations:registry");
|
||||
|
||||
@@ -86,12 +88,40 @@ export class OperationRegistry {
|
||||
): Promise<ResponseEnvelope<TOutput>> {
|
||||
const spec = this.specs.get(operationId);
|
||||
if (!spec) {
|
||||
throw new Error(`Operation not found: ${operationId}`);
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`Operation not found: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
const handler = this.handlers.get(operationId);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for operation: ${operationId}`);
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`No handler registered for operation: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.trusted) {
|
||||
const accessControl: AccessControl = spec.accessControl as AccessControl;
|
||||
if (accessControl.requiredScopes.length > 0 || accessControl.requiredScopesAny?.length || accessControl.resourceType) {
|
||||
if (!context.identity) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.ACCESS_DENIED,
|
||||
`Access denied for operation: ${operationId} — identity required`,
|
||||
{ operationId, requiredScopes: accessControl.requiredScopes },
|
||||
);
|
||||
}
|
||||
if (!checkAccess(accessControl, context.identity)) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.ACCESS_DENIED,
|
||||
`Access denied for operation: ${operationId}`,
|
||||
{ requiredScopes: accessControl.requiredScopes },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateOrThrow(spec.inputSchema, input, `Input validation failed for ${operationId}`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { OperationContext } from "./types.js";
|
||||
import type { OperationContext, AccessControl } from "./types.js";
|
||||
import { OperationRegistry } from "./registry.js";
|
||||
import { type ResponseEnvelope, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
|
||||
import { CallError, InfrastructureErrorCode } from "./error.js";
|
||||
import { checkAccess } from "./call.js";
|
||||
|
||||
export async function* subscribe(
|
||||
registry: OperationRegistry,
|
||||
@@ -11,13 +13,41 @@ export async function* subscribe(
|
||||
const spec = registry.getSpec(operationId);
|
||||
|
||||
if (!spec) {
|
||||
throw new Error(`Operation not found: ${operationId}`);
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`Operation not found: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
const handler = registry.getHandler(operationId);
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for operation: ${operationId}`);
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`No handler registered for operation: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.trusted) {
|
||||
const accessControl: AccessControl = spec.accessControl as AccessControl;
|
||||
if (accessControl.requiredScopes.length > 0 || accessControl.requiredScopesAny?.length || accessControl.resourceType) {
|
||||
if (!context.identity) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.ACCESS_DENIED,
|
||||
`Access denied for operation: ${operationId} — identity required`,
|
||||
{ operationId, requiredScopes: accessControl.requiredScopes },
|
||||
);
|
||||
}
|
||||
if (!checkAccess(accessControl, context.identity)) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.ACCESS_DENIED,
|
||||
`Access denied for operation: ${operationId}`,
|
||||
{ requiredScopes: accessControl.requiredScopes },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generator = handler(input, context) as AsyncGenerator<unknown, void, unknown>;
|
||||
|
||||
@@ -24,6 +24,7 @@ export const OperationContextSchema = Type.Object({
|
||||
scopes: Type.Array(Type.String()),
|
||||
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String())))
|
||||
})),
|
||||
trusted: Type.Optional(Type.Boolean({ description: "INTERNAL: set by buildEnv(), not by callers" })),
|
||||
}, {
|
||||
description: "Context provided to all operation handlers"
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user