From ac28c9308cd1f3fc9f59ecc24bdb73bec5148c82 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 11 May 2026 01:50:12 +0000 Subject: [PATCH] fix(checkAccess): deny access when resourceType set but identity.resources undefined The resource access check in checkAccess() was bypassed when identity.resources was undefined because the condition evaluated to false, falling through to . Changed to with an explicit check inside the block, implementing default-deny semantics per ADR-006. Added 7 test cases covering: - undefined resources with resourceType set (denied) - empty resources with resourceType set (denied) - non-matching resource type (denied) - matching type but wrong action (denied) - matching type and action (granted) - no resourceType/resourceAction set (granted) - matching resources with extra scopes (granted) --- src/call.ts | 3 +- test/call.test.ts | 170 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/call.ts b/src/call.ts index 59723f1..7aa268e 100644 --- a/src/call.ts +++ b/src/call.ts @@ -245,7 +245,8 @@ function checkAccess(accessControl: AccessControl, identity: Identity): boolean if (!hasAny) return false; } - if (resourceType && resourceAction && identity.resources) { + if (resourceType && resourceAction) { + if (!identity.resources) return false; for (const [key, actions] of Object.entries(identity.resources)) { if (key.startsWith(`${resourceType}:`) && actions.includes(resourceAction)) { return true; diff --git a/test/call.test.ts b/test/call.test.ts index 71fa10a..84f918e 100644 --- a/test/call.test.ts +++ b/test/call.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; -import { PendingRequestMap } from "../src/call.js"; +import { PendingRequestMap, buildCallHandler } from "../src/call.js"; import { CallError, InfrastructureErrorCode } from "../src/error.js"; +import { OperationRegistry } from "../src/registry.js"; +import { Type } from "@alkdev/typebox"; +import { OperationType } from "../src/types.js"; +import type { Identity } from "../src/types.js"; describe("PendingRequestMap", () => { it("creates instance without event target", () => { @@ -84,4 +88,168 @@ describe("PendingRequestMap", () => { await callPromise; expect(map.getPendingCount()).toBe(0); }); +}); + +describe("checkAccess resource access control", () => { + function makeRegistry(accessControlOverrides: Record = {}) { + const registry = new OperationRegistry(); + registry.register({ + name: "guarded", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "guarded op", + inputSchema: Type.Object({}), + outputSchema: Type.Object({ ok: Type.Boolean() }), + accessControl: { + requiredScopes: [], + resourceType: "project", + resourceAction: "read", + ...accessControlOverrides, + }, + handler: async () => ({ ok: true }), + }); + registry.register({ + name: "open", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "open op", + inputSchema: Type.Object({}), + outputSchema: Type.Object({ ok: Type.Boolean() }), + accessControl: { + requiredScopes: [], + }, + handler: async () => ({ ok: true }), + }); + return registry; + } + + it("denies access when resourceType/resourceAction are set and identity.resources is undefined", async () => { + const registry = makeRegistry(); + const handler = buildCallHandler({ registry }); + + const identity: Identity = { id: "user1", scopes: [] }; + + await expect( + handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }), + ).rejects.toThrow("Access denied"); + }); + + it("denies access when resourceType/resourceAction are set and identity.resources is empty", async () => { + const registry = makeRegistry(); + const handler = buildCallHandler({ registry }); + + const identity: Identity = { id: "user1", scopes: [], resources: {} }; + + await expect( + handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }), + ).rejects.toThrow("Access denied"); + }); + + it("denies access when identity.resources has no matching resource type", async () => { + const registry = makeRegistry(); + const handler = buildCallHandler({ registry }); + + const identity: Identity = { + id: "user1", + scopes: [], + resources: { "document:abc": ["read"] }, + }; + + await expect( + handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }), + ).rejects.toThrow("Access denied"); + }); + + it("denies access when identity.resources has matching type but wrong action", async () => { + const registry = makeRegistry(); + const handler = buildCallHandler({ registry }); + + const identity: Identity = { + id: "user1", + scopes: [], + resources: { "project:abc": ["write"] }, + }; + + await expect( + handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }), + ).rejects.toThrow("Access denied"); + }); + + it("grants access when identity.resources has matching type and action", async () => { + const registry = makeRegistry(); + const handler = buildCallHandler({ registry }); + + const identity: Identity = { + id: "user1", + scopes: [], + resources: { "project:abc": ["read"] }, + }; + + await expect( + handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }), + ).resolves.toBeUndefined(); + }); + + it("grants access when neither resourceType nor resourceAction are set", async () => { + const registry = makeRegistry(); + const handler = buildCallHandler({ registry }); + + const identity: Identity = { id: "user1", scopes: [] }; + + await expect( + handler({ + requestId: "r1", + operationId: "test.open", + input: {}, + identity, + }), + ).resolves.toBeUndefined(); + }); + + it("grants access when identity.resources matches and identity has no scopes required", async () => { + const registry = makeRegistry(); + const handler = buildCallHandler({ registry }); + + const identity: Identity = { + id: "user1", + scopes: ["some:scope"], + resources: { "project:xyz": ["read", "write"] }, + }; + + await expect( + handler({ + requestId: "r1", + operationId: "test.guarded", + input: {}, + identity, + }), + ).resolves.toBeUndefined(); + }); }); \ No newline at end of file