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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user