-
Notifications
You must be signed in to change notification settings - Fork 6
test(nextly): pin what relationship expansion and callerMayPerform answer for a read #1877
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b9aab4d
ac70d6e
ce210ae
37d1cf2
ebe1f81
3ab0ac3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof import("../code-access")>(); | ||
| 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); | ||
|
Comment on lines
+114
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
AGENTS.md reference: AGENTS.md:L216-L233 Useful? React with 👍 / 👎. |
||
| 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"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the owner is a super-admin, this case still passes solely because AGENTS.md reference: AGENTS.md:L216-L233 Useful? React with 👍 / 👎. |
||
| "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 | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-<target>` 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<CollectionsHandler["getEntry"]>[0], | ||
| "user" | "authenticatedScope" | ||
| > | ||
| ): Promise<boolean> { | ||
| 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<string, unknown>).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" }, | ||
| ]); | ||
|
Comment on lines
+102
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Both tests reuse a scope that lacks AGENTS.md reference: AGENTS.md:L216-L233 Useful? React with 👍 / 👎. |
||
|
|
||
| describe("relationship expansion — a scoped API key without read-<target>", () => { | ||
| // 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-<target>", () => { | ||
| 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 | ||
| ); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This “declares no read rule” case only covers
getRegisteredAccessreturningundefined; it never covers a collection that has an access object for another operation, such as{ update: false }. Making the API-key branch deny whenever a registered object lacks the requested member leaves this characterization file and the existing route-agreement suite green, but would refuse a read-scoped key merely because the collection configures writes. Register an unrelated rule here and retain the expectedtrueresult to cover the operation-level fallback.AGENTS.md reference: packages/nextly/AGENTS.md:L46-L48
Useful? React with 👍 / 👎.