diff --git a/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts new file mode 100644 index 0000000000..67ad90b627 --- /dev/null +++ b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts @@ -0,0 +1,145 @@ +/** + * What `callerMayPerform` answers for a READ of an entity, for every kind of + * caller. + * + * Each case pins one cell of that decision — a kind of caller against a state + * of the entity's rule — so a change to any single answer fails a named test. + * + * The RBAC service is a stub that says yes to everything a session asks. A + * scoped API key is judged on its own grant and the entity's code rule, never + * on that service, so an implementation that consulted the owner's authority + * would answer `true` where these expect `false`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CollectionAccessControl } from "../../shared/types/access"; +import { apiKeyScope } from "../authenticated-scope"; + +const rbac = vi.hoisted(() => ({ + registered: undefined as { read?: unknown } | undefined, + available: true, + checkAccess: vi.fn(async () => true), +})); + +vi.mock("../code-access", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + getRBACService: () => + rbac.available + ? { + getRegisteredAccess: () => rbac.registered, + checkAccess: rbac.checkAccess, + } + : undefined, + }; +}); + +const { callerMayPerform } = await import("../authenticated-scope"); + +const OWNER = { id: "owner-1", roles: [] as string[] }; +const SUPER_ADMIN_OWNER = { id: "root-1", roles: ["super-admin"] }; + +/** A scoped key holding exactly these grants on `posts`. */ +function keyHolding(...actions: string[]) { + return apiKeyScope( + actions.map(action => ({ + slug: `${action}-posts`, + action, + resource: "posts", + })) + ); +} + +function rule(read: CollectionAccessControl["read"]): void { + rbac.registered = { read }; +} + +beforeEach(() => { + rbac.registered = undefined; + rbac.available = true; + rbac.checkAccess.mockClear(); + rbac.checkAccess.mockResolvedValue(true); +}); + +describe("callerMayPerform(read) — a caller with no identity", () => { + it("refuses, and asks nothing", async () => { + expect(await callerMayPerform(undefined, "read", "posts", { id: "" })).toBe( + false + ); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); +}); + +describe("callerMayPerform(read) — a session caller", () => { + it("answers exactly what the RBAC service answers", async () => { + expect(await callerMayPerform(undefined, "read", "posts", OWNER)).toBe( + true + ); + rbac.checkAccess.mockResolvedValue(false); + expect(await callerMayPerform(undefined, "read", "posts", OWNER)).toBe( + false + ); + expect(rbac.checkAccess).toHaveBeenCalledWith({ + userId: "owner-1", + operation: "read", + resource: "posts", + }); + }); +}); + +describe("callerMayPerform(read) — a scoped API key", () => { + it("refuses a key without the read grant, however the service answers", async () => { + rule(true); + expect( + await callerMayPerform(keyHolding("create"), "read", "posts", OWNER) + ).toBe(false); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); + + it("admits a key holding the grant when the entity declares no read rule", async () => { + expect( + await callerMayPerform(keyHolding("read"), "read", "posts", OWNER) + ).toBe(true); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); + + it("holds a key with the grant to the entity's rule in each form", async () => { + const key = keyHolding("read"); + + rule(true); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(true); + rule(false); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(false); + rule(() => true); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(true); + rule(() => false); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(false); + rule(() => { + throw new Error("rule failed"); + }); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(false); + }); + + it("gives a key owned by a super-admin no bypass", async () => { + // Same answer as any key without the grant: the owner's role is not read. + expect( + await callerMayPerform( + keyHolding("create"), + "read", + "posts", + SUPER_ADMIN_OWNER + ) + ).toBe(false); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); +}); + +describe("callerMayPerform(read) — no RBAC service registered", () => { + it("refuses a session caller", async () => { + rbac.available = false; + expect(await callerMayPerform(undefined, "read", "posts", OWNER)).toBe( + false + ); + }); +}); diff --git a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts new file mode 100644 index 0000000000..14f8341e4c --- /dev/null +++ b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts @@ -0,0 +1,228 @@ +/** + * What populating a relationship answers about its TARGET, for each kind of + * caller. + * + * Expansion decides read access to the target by itself rather than through + * the collection read gate. These cases pin the answers that hold whether or + * not expansion also consults the caller's grants: a refusing or throwing rule + * withholds the target, a super-admin session bypasses the rule, and a key + * owned by a super-admin gets no bypass. Each is pinned cell by cell, so a + * change to any one fails a named test. + * + * 🔴 The key cases come in two halves, and only together do they say what their + * names claim. A key WITHOUT `read-` cannot separate the rule deciding + * from the grant deciding: an implementation refusing on the missing grant, + * before it evaluates `access.read` or reaches the super-admin carve-out, + * answers every one of them correctly for the wrong reason. The cases holding + * the grant leave the rule as the only thing left to decide, and one of them + * requires the target to be POPULATED — so an implementation that withholds + * every target from every key, which every without-grant case accepts, fails + * there and only there. + * + * Not pinned here: expansion with no RBAC service registered. Every instance + * `createTestNextly` builds registers one. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiKeyScope } from "../../../auth/authenticated-scope"; +import { defineCollection, relationship, text } from "../../../config"; +import { + createTestNextly, + type TestNextly, +} from "../../../plugins/test-nextly"; +import type { CollectionsHandler } from "../../../services/collections-handler"; + +let current: TestNextly | undefined; +afterEach(async () => { + vi.restoreAllMocks(); + await current?.destroy(); + current = undefined; +}); + +type ReadRule = (ctx: { user?: { id?: string } | null }) => boolean; + +/** `refs.plain` points at one `pages` row whose read rule is `pagesRead`. */ +async function boot(pagesRead: ReadRule | undefined): Promise<{ + handler: CollectionsHandler; + refId: string; + pageId: string; +}> { + current = await createTestNextly({ + collections: [ + defineCollection({ + slug: "pages", + ...(pagesRead ? { access: { read: pagesRead } } : {}), + fields: [text({ name: "title" })], + }), + defineCollection({ + slug: "refs", + fields: [ + text({ name: "name" }), + relationship({ name: "plain", relationTo: "pages" }), + ], + }), + ], + }); + const handler = current.getService("collectionsHandler"); + const page = await handler.createEntry( + { collectionName: "pages", overrideAccess: true }, + { title: "Target page" } + ); + const pageId = (page.data as { id: string }).id; + const ref = await handler.createEntry( + { collectionName: "refs", overrideAccess: true }, + { name: "r", plain: pageId } + ); + return { handler, refId: (ref.data as { id: string }).id, pageId }; +} + +/** Whether the populated relationship on `refs` carries the target row. */ +async function populates( + handler: CollectionsHandler, + refId: string, + caller: Pick< + Parameters[0], + "user" | "authenticatedScope" + > +): Promise { + const result = await handler.getEntry({ + collectionName: "refs", + entryId: refId, + depth: 1, + routeAuthorized: true, + ...caller, + }); + // The parent read is served in every case; only the target varies. + expect(result.success).toBe(true); + const plain = (result.data as Record).plain; + return JSON.stringify(plain ?? null).includes("Target page"); +} + +/** A scoped key that may read `refs` but holds no grant on `pages`. */ +const keyWithoutTargetGrant = apiKeyScope([ + { slug: "read-refs", action: "read", resource: "refs" }, +]); + +describe("relationship expansion — a scoped API key without read-", () => { + // Only refusals are pinned for a key without the target grant. Both hold + // whether or not expansion also asks for that grant: a refusing rule + // withholds, and an owner's role lends the key no bypass. + it("withholds a target whose rule refuses", async () => { + const { handler, refId } = await boot(() => false); + + expect( + await populates(handler, refId, { + user: { id: "key-owner", roles: [] as string[] }, + authenticatedScope: keyWithoutTargetGrant, + }) + ).toBe(false); + }); + + it("gives a key owned by a super-admin no bypass", async () => { + const { handler, refId } = await boot(() => false); + + expect( + await populates(handler, refId, { + user: { id: "root-owner", roles: ["super-admin"] }, + authenticatedScope: keyWithoutTargetGrant, + }) + ).toBe(false); + }); +}); + +/** + * A scoped key holding `read-pages` as well as `read-refs`. + * + * 🔴 The control the cases above cannot supply for themselves. Both of them use + * a scope WITHOUT the target grant, so an implementation that refuses on the + * missing grant — before it evaluates `access.read` or reaches the + * scoped-key/super-admin carve-out — answers both correctly without ever + * reaching the behaviour their names claim. Holding the grant leaves the + * target's own rule as the only thing left to decide, so these cases separate + * the two. + * + * Roles are passed as an EXPLICIT empty list rather than omitted. `apiKeyScope` + * leaves `roles` off the scope when given nothing, and the key decision reads + * `scope.roles ?? user.roles`, so an omitted list lets the owner's roles stand + * in for the key's — which is the very substitution the super-admin case below + * exists to rule out. + */ +const keyWithTargetGrant = apiKeyScope( + [ + { slug: "read-refs", action: "read", resource: "refs" }, + { slug: "read-pages", action: "read", resource: "pages" }, + ], + [] +); + +describe("relationship expansion — a scoped API key WITH read-", () => { + it("populates a target whose rule admits", async () => { + // The must-move control for this whole file. Every other key case asserts + // a target is WITHHELD, and an implementation that withholds every target + // from every key satisfies all of them. This one must come back populated, + // so that implementation fails here. + const { handler, refId } = await boot(() => true); + + expect( + await populates(handler, refId, { + user: { id: "key-owner", roles: [] as string[] }, + authenticatedScope: keyWithTargetGrant, + }) + ).toBe(true); + }); + + it("withholds a target whose rule refuses", async () => { + // With the grant held, the refusal can only have come from the target's + // rule. The same assertion on a key without the grant cannot say that. + const { handler, refId } = await boot(() => false); + + expect( + await populates(handler, refId, { + user: { id: "key-owner", roles: [] as string[] }, + authenticatedScope: keyWithTargetGrant, + }) + ).toBe(false); + }); + + it("gives a key owned by a super-admin no bypass, grant or no grant", async () => { + // The owner carries the role and the key does not, so a decision reading + // the owner's roles admits this caller. Pinned with the grant held so the + // refusal is the carve-out at work rather than the missing grant. + const { handler, refId } = await boot(() => false); + + expect( + await populates(handler, refId, { + user: { id: "root-owner", roles: ["super-admin"] }, + authenticatedScope: keyWithTargetGrant, + }) + ).toBe(false); + }); +}); + +describe("relationship expansion — session callers", () => { + it("populates a target whose rule refuses, for a super-admin session", async () => { + const { handler, refId } = await boot(() => false); + + // Control first: the same rule withholds the target from an ordinary + // session, so the populated relationship below is the bypass at work. + expect(await populates(handler, refId, { user: { id: "ordinary" } })).toBe( + false + ); + expect( + await populates(handler, refId, { + user: { id: "root", roles: ["super-admin"] }, + }) + ).toBe(true); + }); + + it("withholds a target whose rule throws", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { handler, refId } = await boot(() => { + throw new Error("rule failed"); + }); + + expect(await populates(handler, refId, { user: { id: "ordinary" } })).toBe( + false + ); + }); +});