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:
@@ -251,14 +251,14 @@ describe("CallHandler", () => {
|
||||
return registry;
|
||||
}
|
||||
|
||||
it("wraps handler return value in localEnvelope", async () => {
|
||||
it("wraps handler return value in localEnvelope and publishes call.responded", async () => {
|
||||
const registry = makeRegistry();
|
||||
const callMap = new PendingRequestMap();
|
||||
const handler = buildCallHandler({ registry, callMap });
|
||||
|
||||
const callPromise = callMap.call("test.echo", { value: "hello" });
|
||||
|
||||
handler({
|
||||
await handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.echo",
|
||||
input: { value: "hello" },
|
||||
@@ -273,14 +273,14 @@ describe("CallHandler", () => {
|
||||
expect(result.data).toEqual({ value: "hello" });
|
||||
});
|
||||
|
||||
it("wraps undefined handler return value in localEnvelope", async () => {
|
||||
it("wraps undefined handler return value and publishes call.responded", async () => {
|
||||
const registry = makeRegistry();
|
||||
const callMap = new PendingRequestMap();
|
||||
const handler = buildCallHandler({ registry, callMap });
|
||||
|
||||
const callPromise = callMap.call("test.voidOp", {});
|
||||
|
||||
handler({
|
||||
await handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.voidOp",
|
||||
input: {},
|
||||
@@ -318,7 +318,7 @@ describe("CallHandler", () => {
|
||||
|
||||
const callPromise = callMap.call("test.mcpOp", {});
|
||||
|
||||
handler({
|
||||
await handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.mcpOp",
|
||||
input: {},
|
||||
@@ -353,7 +353,7 @@ describe("CallHandler", () => {
|
||||
|
||||
const callPromise = callMap.call("test.httpOp", {});
|
||||
|
||||
handler({
|
||||
await handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.httpOp",
|
||||
input: {},
|
||||
@@ -432,64 +432,6 @@ describe("CallHandler", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("applies Value.Cast normalization when outputSchema is not Unknown", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "defaultsFields",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "op with default fields",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Object({ name: Type.String(), count: Type.Number({ default: 0 }) }),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async () => ({ name: "test" }),
|
||||
});
|
||||
|
||||
const callMap = new PendingRequestMap();
|
||||
const handler = buildCallHandler({ registry, callMap });
|
||||
|
||||
const callPromise = callMap.call("test.defaultsFields", {});
|
||||
|
||||
handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.defaultsFields",
|
||||
input: {},
|
||||
});
|
||||
|
||||
const result = await callPromise;
|
||||
expect(result.data).toEqual({ name: "test", count: 0 });
|
||||
});
|
||||
|
||||
it("does not normalize with Value.Cast when outputSchema is Unknown", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "unknownOutput",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "op with unknown output",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Unknown(),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async () => ({ name: "test", extra: "field" }),
|
||||
});
|
||||
|
||||
const callMap = new PendingRequestMap();
|
||||
const handler = buildCallHandler({ registry, callMap });
|
||||
|
||||
const callPromise = callMap.call("test.unknownOutput", {});
|
||||
|
||||
handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.unknownOutput",
|
||||
input: {},
|
||||
});
|
||||
|
||||
const result = await callPromise;
|
||||
expect(result.data).toEqual({ name: "test", extra: "field" });
|
||||
});
|
||||
|
||||
it("publishes call.error when operation not found", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
const callMap = new PendingRequestMap();
|
||||
@@ -609,27 +551,85 @@ describe("CallHandler", () => {
|
||||
resources: { "project:abc": ["read"] },
|
||||
};
|
||||
|
||||
await expect(
|
||||
handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.guarded",
|
||||
input: {},
|
||||
identity,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
const result = await handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.guarded",
|
||||
input: {},
|
||||
identity,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("works without callMap for open operations", async () => {
|
||||
const registry = makeRegistry();
|
||||
const handler = buildCallHandler({ registry });
|
||||
|
||||
await expect(
|
||||
handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.open",
|
||||
input: {},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
const result = await handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.open",
|
||||
input: {},
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies Value.Cast normalization via execute()", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "defaultsFields",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "op with default fields",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Object({ name: Type.String(), count: Type.Number({ default: 0 }) }),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async () => ({ name: "test" }),
|
||||
});
|
||||
|
||||
const callMap = new PendingRequestMap();
|
||||
const handler = buildCallHandler({ registry, callMap });
|
||||
|
||||
const callPromise = callMap.call("test.defaultsFields", {});
|
||||
|
||||
await handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.defaultsFields",
|
||||
input: {},
|
||||
});
|
||||
|
||||
const result = await callPromise;
|
||||
expect(result.data).toEqual({ name: "test", count: 0 });
|
||||
});
|
||||
|
||||
it("does not normalize with Value.Cast when outputSchema is Unknown", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "unknownOutput",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "op with unknown output",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Unknown(),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async () => ({ name: "test", extra: "field" }),
|
||||
});
|
||||
|
||||
const callMap = new PendingRequestMap();
|
||||
const handler = buildCallHandler({ registry, callMap });
|
||||
|
||||
const callPromise = callMap.call("test.unknownOutput", {});
|
||||
|
||||
await handler({
|
||||
requestId: [...callMap["requests"].keys()][0],
|
||||
operationId: "test.unknownOutput",
|
||||
input: {},
|
||||
});
|
||||
|
||||
const result = await callPromise;
|
||||
expect(result.data).toEqual({ name: "test", extra: "field" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -750,14 +750,13 @@ describe("checkAccess resource access control", () => {
|
||||
resources: { "project:abc": ["read"] },
|
||||
};
|
||||
|
||||
await expect(
|
||||
handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.guarded",
|
||||
input: {},
|
||||
identity,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
const result = await handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.guarded",
|
||||
input: {},
|
||||
identity,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("grants access when neither resourceType nor resourceAction are set", async () => {
|
||||
@@ -766,14 +765,13 @@ describe("checkAccess resource access control", () => {
|
||||
|
||||
const identity: Identity = { id: "user1", scopes: [] };
|
||||
|
||||
await expect(
|
||||
handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.open",
|
||||
input: {},
|
||||
identity,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
const result = await handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.open",
|
||||
input: {},
|
||||
identity,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("grants access when identity.resources matches and identity has no scopes required", async () => {
|
||||
@@ -786,13 +784,12 @@ describe("checkAccess resource access control", () => {
|
||||
resources: { "project:xyz": ["read", "write"] },
|
||||
};
|
||||
|
||||
await expect(
|
||||
handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.guarded",
|
||||
input: {},
|
||||
identity,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
const result = await handler({
|
||||
requestId: "r1",
|
||||
operationId: "test.guarded",
|
||||
input: {},
|
||||
identity,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
188
test/env.test.ts
188
test/env.test.ts
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { OperationRegistry, OperationType, buildEnv, type IOperationDefinition, type OperationContext } from "../src/index.js";
|
||||
import * as Type from "@alkdev/typebox";
|
||||
import { PendingRequestMap } from "../src/call.js";
|
||||
import { localEnvelope, httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
|
||||
import { httpEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
|
||||
import { CallError, InfrastructureErrorCode } from "../src/error.js";
|
||||
import type { Identity } from "../src/types.js";
|
||||
|
||||
function makeOperation(name: string, handler?: any): IOperationDefinition {
|
||||
@@ -124,135 +124,95 @@ describe("buildEnv", () => {
|
||||
expect(env.other).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes through callMap in call protocol mode", async () => {
|
||||
it("always uses execute() and sets trusted: true on nested context", async () => {
|
||||
let capturedContext: OperationContext | undefined;
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("readFile"));
|
||||
|
||||
const callMap = {
|
||||
call: async (opId: string, input: unknown, opts?: any): Promise<ResponseEnvelope> => {
|
||||
return localEnvelope({ result: `routed: ${opId}` }, opId);
|
||||
registry.register({
|
||||
name: "inner",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "inner op",
|
||||
inputSchema: Type.Object({ value: Type.String() }),
|
||||
outputSchema: Type.Object({ result: Type.String() }),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async (input: any, ctx: OperationContext) => {
|
||||
capturedContext = ctx;
|
||||
return { result: input.value };
|
||||
},
|
||||
};
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context: {} as OperationContext,
|
||||
callMap,
|
||||
});
|
||||
|
||||
const result = await env.test.readFile({ value: "test" });
|
||||
const outerContext: OperationContext = {
|
||||
requestId: "outer-123",
|
||||
identity: { id: "user1", scopes: ["read"] },
|
||||
};
|
||||
|
||||
const env = buildEnv({ registry, context: outerContext });
|
||||
await env.test.inner({ value: "hello" });
|
||||
|
||||
expect(capturedContext).toBeDefined();
|
||||
expect(capturedContext!.trusted).toBe(true);
|
||||
expect(capturedContext!.requestId).toBe("outer-123");
|
||||
expect(capturedContext!.identity).toEqual({ id: "user1", scopes: ["read"] });
|
||||
});
|
||||
|
||||
it("skips access control for trusted nested calls", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "guarded",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "guarded op",
|
||||
inputSchema: Type.Object({ value: Type.String() }),
|
||||
outputSchema: Type.Object({ result: Type.String() }),
|
||||
accessControl: {
|
||||
requiredScopes: ["admin"],
|
||||
},
|
||||
handler: async (input: any) => ({ result: input.value }),
|
||||
});
|
||||
|
||||
const outerContext: OperationContext = {
|
||||
requestId: "outer-456",
|
||||
identity: { id: "user1", scopes: ["read"] },
|
||||
};
|
||||
|
||||
const env = buildEnv({ registry, context: outerContext });
|
||||
const result = await env.test.guarded({ value: "secret" });
|
||||
expect(isResponseEnvelope(result)).toBe(true);
|
||||
expect(result.data).toEqual({ result: "routed: test.readFile" });
|
||||
expect(result.data).toEqual({ result: "secret" });
|
||||
});
|
||||
|
||||
it("passes parentRequestId through callMap in call protocol mode", async () => {
|
||||
it("propagates identity through nested calls with trusted flag", async () => {
|
||||
let capturedContext: OperationContext | undefined;
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("op1"));
|
||||
|
||||
let capturedOptions: any = null;
|
||||
const callMap = {
|
||||
call: async (opId: string, input: unknown, opts?: any): Promise<ResponseEnvelope> => {
|
||||
capturedOptions = opts;
|
||||
return localEnvelope({ result: "ok" }, opId);
|
||||
registry.register({
|
||||
name: "inner",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "inner op",
|
||||
inputSchema: Type.Object({ value: Type.String() }),
|
||||
outputSchema: Type.Object({ result: Type.String() }),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async (input: any, ctx: OperationContext) => {
|
||||
capturedContext = ctx;
|
||||
return { result: input.value };
|
||||
},
|
||||
};
|
||||
|
||||
const context: OperationContext = {
|
||||
requestId: "parent-req-123",
|
||||
};
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context,
|
||||
callMap,
|
||||
});
|
||||
|
||||
await env.test.op1({ value: "test" });
|
||||
|
||||
expect(capturedOptions).not.toBeNull();
|
||||
expect(capturedOptions.parentRequestId).toBe("parent-req-123");
|
||||
});
|
||||
|
||||
it("passes identity through callMap in call protocol mode", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("op1"));
|
||||
|
||||
let capturedOptions: any = null;
|
||||
const callMap = {
|
||||
call: async (opId: string, input: unknown, opts?: any): Promise<ResponseEnvelope> => {
|
||||
capturedOptions = opts;
|
||||
return localEnvelope({ result: "ok" }, opId);
|
||||
},
|
||||
};
|
||||
|
||||
const identity: Identity = { id: "user1", scopes: ["read"] };
|
||||
const context: OperationContext = {
|
||||
const outerContext: OperationContext = {
|
||||
requestId: "parent-req-456",
|
||||
identity,
|
||||
};
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context,
|
||||
callMap,
|
||||
});
|
||||
const env = buildEnv({ registry, context: outerContext });
|
||||
await env.test.inner({ value: "test" });
|
||||
|
||||
await env.test.op1({ value: "test" });
|
||||
|
||||
expect(capturedOptions).not.toBeNull();
|
||||
expect(capturedOptions.parentRequestId).toBe("parent-req-456");
|
||||
expect(capturedOptions.identity).toEqual(identity);
|
||||
});
|
||||
|
||||
it("does not pass identity when context has no identity in call protocol mode", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("op1"));
|
||||
|
||||
let capturedOptions: any = null;
|
||||
const callMap = {
|
||||
call: async (opId: string, input: unknown, opts?: any): Promise<ResponseEnvelope> => {
|
||||
capturedOptions = opts;
|
||||
return localEnvelope({ result: "ok" }, opId);
|
||||
},
|
||||
};
|
||||
|
||||
const context: OperationContext = {
|
||||
requestId: "parent-req-789",
|
||||
};
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context,
|
||||
callMap,
|
||||
});
|
||||
|
||||
await env.test.op1({ value: "test" });
|
||||
|
||||
expect(capturedOptions).not.toBeNull();
|
||||
expect(capturedOptions.parentRequestId).toBe("parent-req-789");
|
||||
expect(capturedOptions.identity).toBeUndefined();
|
||||
});
|
||||
|
||||
it("works with PendingRequestMap as callMap", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("echo"));
|
||||
|
||||
const callMap = new PendingRequestMap();
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context: {} as OperationContext,
|
||||
callMap,
|
||||
});
|
||||
|
||||
const callPromise = env.test.echo({ value: "hello" });
|
||||
|
||||
const requestId = [...(callMap as any).requests.keys()][0];
|
||||
callMap.respond(requestId, localEnvelope({ result: "echoed" }, "test.echo"));
|
||||
|
||||
const result = await callPromise;
|
||||
expect(isResponseEnvelope(result)).toBe(true);
|
||||
expect(result.data).toEqual({ result: "echoed" });
|
||||
expect(result.meta.source).toBe("local");
|
||||
expect(capturedContext).toBeDefined();
|
||||
expect(capturedContext!.identity).toEqual(identity);
|
||||
expect(capturedContext!.trusted).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty env when registry has no specs", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { OperationRegistry } from "../src/registry.js";
|
||||
import { OperationType, type IOperationDefinition, type OperationContext, type OperationSpec, type OperationHandler } from "../src/index.js";
|
||||
import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
|
||||
import { CallError, InfrastructureErrorCode } from "../src/error.js";
|
||||
import * as Type from "@alkdev/typebox";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
|
||||
@@ -158,19 +159,27 @@ describe("OperationRegistry", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws on missing operation", async () => {
|
||||
it("throws CallError on missing operation", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
await expect(
|
||||
registry.execute("missing.op", {}, {} as OperationContext)
|
||||
).rejects.toThrow("Operation not found");
|
||||
try {
|
||||
await registry.execute("missing.op", {}, {} as OperationContext);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on missing handler", async () => {
|
||||
it("throws CallError on missing handler", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.registerSpec(makeSpec());
|
||||
await expect(
|
||||
registry.execute("test.testOp", { value: "hello" }, {} as OperationContext)
|
||||
).rejects.toThrow("No handler registered");
|
||||
try {
|
||||
await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it("registerSpec and registerHandler separately", async () => {
|
||||
@@ -221,4 +230,175 @@ describe("OperationRegistry", () => {
|
||||
expect(spec.name).toBe("testOp");
|
||||
expect((spec as any).handler).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OperationRegistry access control", () => {
|
||||
it("denies access when requiredScopes are set and no identity provided", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: ["admin"] },
|
||||
}));
|
||||
|
||||
try {
|
||||
await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it("denies access when identity lacks required scopes", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: ["admin", "write"] },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {
|
||||
identity: { id: "user1", scopes: ["read"] },
|
||||
};
|
||||
|
||||
try {
|
||||
await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants access when identity has all required scopes", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: ["read", "write"] },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {
|
||||
identity: { id: "user1", scopes: ["read", "write", "admin"] },
|
||||
};
|
||||
|
||||
const envelope = await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect(envelope.data).toEqual({ result: "processed: hello" });
|
||||
});
|
||||
|
||||
it("grants access when no scopes are required", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: [] },
|
||||
}));
|
||||
|
||||
const envelope = await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext);
|
||||
expect(envelope.data).toEqual({ result: "processed: hello" });
|
||||
});
|
||||
|
||||
it("denies access when requiredScopesAny requires at least one scope and identity has none", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: [], requiredScopesAny: ["admin", "write"] },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {
|
||||
identity: { id: "user1", scopes: ["read"] },
|
||||
};
|
||||
|
||||
try {
|
||||
await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants access when requiredScopesAny and identity has one matching scope", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: [], requiredScopesAny: ["admin", "write"] },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {
|
||||
identity: { id: "user1", scopes: ["write"] },
|
||||
};
|
||||
|
||||
const envelope = await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect(envelope.data).toEqual({ result: "processed: hello" });
|
||||
});
|
||||
|
||||
it("denies access when resourceType/resourceAction are set and identity has no resources", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: [], resourceType: "project", resourceAction: "read" },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {
|
||||
identity: { id: "user1", scopes: [] },
|
||||
};
|
||||
|
||||
try {
|
||||
await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants access when resourceType/resourceAction match identity resources", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: [], resourceType: "project", resourceAction: "read" },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {
|
||||
identity: { id: "user1", scopes: [], resources: { "project:abc": ["read", "write"] } },
|
||||
};
|
||||
|
||||
const envelope = await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect(envelope.data).toEqual({ result: "processed: hello" });
|
||||
});
|
||||
|
||||
it("skips access control when context.trusted is true", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: ["admin"] },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {
|
||||
identity: { id: "user1", scopes: ["read"] },
|
||||
trusted: true,
|
||||
};
|
||||
|
||||
const envelope = await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect(envelope.data).toEqual({ result: "processed: hello" });
|
||||
});
|
||||
|
||||
it("skips access control when context.trusted is true even without identity", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: ["admin"] },
|
||||
}));
|
||||
|
||||
const context: OperationContext = { trusted: true };
|
||||
|
||||
const envelope = await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect(envelope.data).toEqual({ result: "processed: hello" });
|
||||
});
|
||||
|
||||
it("denies access when identity is missing but requiredScopes are set", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
accessControl: { requiredScopes: ["read"] },
|
||||
}));
|
||||
|
||||
const context: OperationContext = {};
|
||||
|
||||
try {
|
||||
await registry.execute("test.testOp", { value: "hello" }, context);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||
expect((error as CallError).message).toContain("identity required");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,10 @@ import { OperationType } from "../src/types.js";
|
||||
import type { OperationContext } from "../src/types.js";
|
||||
import { localEnvelope, httpEnvelope, mcpEnvelope, isResponseEnvelope } from "../src/response-envelope.js";
|
||||
import type { ResponseEnvelope, LocalResponseMeta } from "../src/response-envelope.js";
|
||||
import { CallError, InfrastructureErrorCode } from "../src/error.js";
|
||||
|
||||
function makeContext(): OperationContext {
|
||||
return { requestId: "test-req-1" };
|
||||
function makeContext(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
return { requestId: "test-req-1", ...overrides };
|
||||
}
|
||||
|
||||
function makeRegistry(
|
||||
@@ -139,17 +140,21 @@ describe("subscribe", () => {
|
||||
expect(done.done).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when operation spec not found", async () => {
|
||||
it("throws CallError when operation spec not found", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
|
||||
await expect(async () => {
|
||||
try {
|
||||
for await (const _ of subscribe(registry, "nonexistent.op", {}, makeContext())) {
|
||||
// should not reach here
|
||||
}
|
||||
}).rejects.toThrow("Operation not found: nonexistent.op");
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when handler not registered", async () => {
|
||||
it("throws CallError when handler not registered", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.registerSpec({
|
||||
name: "unhandled",
|
||||
@@ -162,11 +167,15 @@ describe("subscribe", () => {
|
||||
accessControl: { requiredScopes: [] },
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
try {
|
||||
for await (const _ of subscribe(registry, "test.unhandled", {}, makeContext())) {
|
||||
// should not reach here
|
||||
}
|
||||
}).rejects.toThrow("No handler registered for operation: test.unhandled");
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.OPERATION_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles generator that yields nothing", async () => {
|
||||
@@ -294,4 +303,81 @@ describe("subscribe", () => {
|
||||
|
||||
expect(returnCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("denies access when requiredScopes are set and no identity provided", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "guardedSub",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.SUBSCRIPTION,
|
||||
description: "guarded sub",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Unknown(),
|
||||
accessControl: { requiredScopes: ["admin"] },
|
||||
handler: async function* (_input: unknown, _context: OperationContext) {
|
||||
yield "should not reach";
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _ of subscribe(registry, "test.guardedSub", {}, makeContext())) {
|
||||
// should not reach here
|
||||
}
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants access when identity has required scopes", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "guardedSub",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.SUBSCRIPTION,
|
||||
description: "guarded sub",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Unknown(),
|
||||
accessControl: { requiredScopes: ["read"] },
|
||||
handler: async function* (_input: unknown, _context: OperationContext) {
|
||||
yield "event1";
|
||||
},
|
||||
});
|
||||
|
||||
const context = makeContext({ identity: { id: "user1", scopes: ["read"] } });
|
||||
const results: ResponseEnvelope[] = [];
|
||||
for await (const envelope of subscribe(registry, "test.guardedSub", {}, context)) {
|
||||
results.push(envelope);
|
||||
}
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].data).toBe("event1");
|
||||
});
|
||||
|
||||
it("skips access control when context.trusted is true", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "trustedSub",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.SUBSCRIPTION,
|
||||
description: "trusted sub",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Unknown(),
|
||||
accessControl: { requiredScopes: ["admin"] },
|
||||
handler: async function* (_input: unknown, _context: OperationContext) {
|
||||
yield "secret-event";
|
||||
},
|
||||
});
|
||||
|
||||
const context = makeContext({ trusted: true });
|
||||
const results: ResponseEnvelope[] = [];
|
||||
for await (const envelope of subscribe(registry, "test.trustedSub", {}, context)) {
|
||||
results.push(envelope);
|
||||
}
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].data).toBe("secret-event");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user