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:
@@ -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