--- title: "Protect a resource" description: "Add session-derived ownership, filtered lists, and policy checks to a generated resource, then test owner, other-account, and anonymous access through use cases and HTTP." url: "https://www.beignetjs.com/guides/protect-a-resource" markdown_url: "https://www.beignetjs.com/markdown/guides/protect-a-resource.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "ba3f49c103f3c4515e8ca14e07651fe5b83e2cec" source_modified: true --- # Protect a resource Build a private Notes API in the SQLite starter. Each account can create notes, list its own notes, and read, update, or delete only the notes it owns. You will verify the rules through use cases and HTTP against both repository adapters. ## Before you start Use the Next.js and SQLite app from [Quickstart](https://www.beignetjs.com/markdown/getting-started.md), with working sign-in and Todos. Run commands from the app root. You do not need the Projects tutorial, a Notes screen, or another provider. This guide creates a new `notes` resource. If your app already has that feature, choose another name and adapt the examples; do not regenerate over its source. For an existing resource, preserve its authorization and data model and use [Adapt this to your app](https://www.beignetjs.com/markdown/guides/protect-a-resource.md#adapt-this-to-your-app). The access rules will be: | Caller | List | Read, update, or delete a note | | --------------- | -------------------- | ------------------------------ | | Signed out | `401` | `401` | | Owner | Only their notes | Allowed | | Another account | Only their own notes | `403` | A signed-in caller receives `404` for a missing ID and `403` for someone else's note. That distinction reveals whether an ID exists. If your application must hide that, use a consistent `404` response for inaccessible and missing records. ## Generate the resource ```bash bun beignet make resource notes --authorization bun run format ``` The generator creates contracts, use cases, a policy, repository adapters, and tests. It registers the policy with the app's gate and adds authorization checks to mutation use cases. The generated policy requires a signed-in actor for writes; it does **not** assign ownership, filter lists, or protect reads. Complete the edits below before exposing this resource to users. Keep the generated registrations in `ports/index.ts`, `infra/port-wiring.ts`, `infra/db/repositories.ts`, and `server/routes.ts`. Your changes belong in: | Responsibility | Files | | ---------------------------------------- | --------------------------------------------------------- | | Record ownership | `features/notes/schemas.ts`, `infra/db/schema/notes.ts` | | Filter lists and persist the owner | `features/notes/ports.ts`, `infra/notes/` | | Enforce identity and access | `features/notes/policy.ts`, `features/notes/use-cases.ts` | | Describe HTTP errors and verify behavior | `features/notes/contracts.ts`, `features/notes/tests/` | ## Store the owner Add `userId` to the response schema and database table. Keep it out of `CreateNoteInputSchema` and `UpdateNoteBodySchema`: callers supply a name, and the use case chooses the owner from the session. ```diff --- a/features/notes/schemas.ts +++ b/features/notes/schemas.ts @@ -3,6 +3,7 @@ export const NoteSchema = z.object({ id: z.uuid(), name: z.string().min(1), + userId: z.string().min(1), version: z.number().int().min(1), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), ``` ```diff --- a/infra/db/schema/notes.ts +++ b/infra/db/schema/notes.ts @@ -3,6 +3,7 @@ export const notes = sqliteTable("notes", { id: text("id").primaryKey(), name: text("name").notNull(), + userId: text("user_id").notNull(), version: integer("version").notNull(), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), ``` The column is required because this is a new, empty table. For an existing table, plan an owner backfill before making the column required; do not assign all existing records to whichever account runs the migration. ## Filter lists in the repository Make the owner a required argument to `list` and a required field in the repository's create input. The repository port accepts application data, while the HTTP input remains limited to the fields callers may set. ```diff --- a/features/notes/ports.ts +++ b/features/notes/ports.ts @@ -22,8 +22,8 @@ }; export interface NoteRepository { - list(query: ListNotesQuery): Promise; - create(input: CreateNoteInput): Promise; + list(query: ListNotesQuery, userId: string): Promise; + create(input: CreateNoteInput & { userId: string }): Promise; findById(id: string): Promise; update(input: UpdateNoteInput): Promise; delete(id: string): Promise; ``` Persist and return `userId` in both adapters. Filter by owner **before** sorting and taking a page, so another account's records cannot occupy page slots or leak through search results. ```diff --- a/infra/notes/in-memory-note-repository.ts +++ b/infra/notes/in-memory-note-repository.ts @@ -12,6 +12,7 @@ return { id: note.id, name: note.name, + userId: note.userId, version: note.version, createdAt: note.createdAt, updatedAt: note.updatedAt, @@ -70,9 +71,10 @@ ); return { - async list(query) { + async list(query, userId) { const name = query.name?.toLocaleLowerCase(); const allNotes = Array.from(notes.values()) + .filter((note) => note.userId === userId) .filter((note) => !name || note.name.toLocaleLowerCase().includes(name)) .sort((left, right) => compareNotes(left, right, query)) .filter((note) => isAfterNoteCursor(note, query)); @@ -89,6 +91,7 @@ const note: Note = { id: crypto.randomUUID(), name: input.name, + userId: input.userId, version: 1, createdAt: now, updatedAt: now, ``` ```diff --- a/infra/notes/drizzle-note-repository.ts +++ b/infra/notes/drizzle-note-repository.ts @@ -17,6 +17,7 @@ return { id: row.id, name: row.name, + userId: row.userId, version: row.version, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -44,8 +45,11 @@ ) as SQL; } -function noteListWhere(query: ListNotesQuery): SQL | undefined { - const filters: SQL[] = []; +function noteListWhere( + query: ListNotesQuery, + userId: string, +): SQL | undefined { + const filters: SQL[] = [eq(schema.notes.userId, userId)]; const cursor = noteCursorFilter(query); if (query.name) { @@ -76,8 +80,8 @@ db: DrizzleSqliteDatabase, ): NoteRepository { return { - async list(query) { - const where = noteListWhere(query); + async list(query, userId) { + const where = noteListWhere(query, userId); const rows = await db .select() .from(schema.notes) @@ -99,6 +103,7 @@ .values({ id: crypto.randomUUID(), name: input.name, + userId: input.userId, version: 1, createdAt: now, updatedAt: now, ``` Keep the existing name filter, cursor predicates, and version checks. `findById` still loads by ID; the use cases below authorize the loaded record before returning or changing it. Repository methods are internal dependencies, not entrypoints for callers to bypass those use cases. ## Define the ownership policy Replace the generated policy with this complete file. The same ownership rule applies to reading, updating, and deleting a note: ```typescript // features/notes/policy.ts import { type ActivityActor, definePolicy, deny } from "@beignet/core/ports"; import type { Note } from "./schemas"; export type AuthorizationContext = { actor: ActivityActor }; function signedIn(ctx: AuthorizationContext) { return ctx.actor.type === "user" || deny("You must be signed in."); } function ownsNote(ctx: AuthorizationContext, note: Note) { if (ctx.actor.type !== "user") return deny("You must be signed in."); return ( ctx.actor.id === note.userId || deny("Only the owner can access this note.") ); } export const notePolicy = definePolicy({ "notes.create": signedIn, "notes.view": ownsNote, "notes.update": ownsNote, "notes.delete": ownsNote, }); ``` The policy receives the current actor and the stored note. Neither the browser nor the request body gets to choose the owner used for this comparison. The generated gate maps a denied decision to the app's `Forbidden` catalog error. ## Enforce access in every use case Use the starter's `requireUser` helper to require a session before any read or write. It throws the app's `Unauthorized` error when the caller is signed out. Pass that user's ID into list and create operations, and authorize individual notes after loading them. ```diff --- a/features/notes/use-cases.ts +++ b/features/notes/use-cases.ts @@ -1,4 +1,5 @@ import "@beignet/core/server-only"; +import { requireUser } from "@/lib/auth"; import { normalizeCursorPage } from "@beignet/core/pagination"; import { useCase } from "../../lib/use-case"; import { @@ -18,18 +19,22 @@ .input(ListNotesInputSchema) .output(ListNotesOutputSchema) .run(async ({ ctx, input }) => { + const user = requireUser(ctx); const page = normalizeCursorPage(input, { defaultLimit: 20, maxLimit: 100, }); - return ctx.ports.notes.list({ - page, - cursor: page.cursor ? decodeNoteCursor(page.cursor) : null, - name: input.name, - sortBy: input.sortBy, - sortDirection: input.sortDirection, - }); + return ctx.ports.notes.list( + { + page, + cursor: page.cursor ? decodeNoteCursor(page.cursor) : null, + name: input.name, + sortBy: input.sortBy, + sortDirection: input.sortDirection, + }, + user.id, + ); }); export const createNoteUseCase = useCase @@ -37,9 +42,10 @@ .input(CreateNoteInputSchema) .output(NoteSchema) .run(async ({ ctx, input }) => { + const user = requireUser(ctx); await ctx.gate.authorize("notes.create"); - const note = await ctx.ports.notes.create(input); + const note = await ctx.ports.notes.create({ ...input, userId: user.id }); return note; }); @@ -49,6 +55,7 @@ .input(NoteIdInputSchema) .output(NoteSchema) .run(async ({ ctx, input }) => { + requireUser(ctx); const note = await ctx.ports.notes.findById(input.id); if (!note) { throw appError("NoteNotFound", { @@ -56,6 +63,7 @@ }); } + await ctx.gate.authorize("notes.view", note); return note; }); @@ -64,6 +72,7 @@ .input(UpdateNoteInputSchema) .output(NoteSchema) .run(async ({ ctx, input }) => { + requireUser(ctx); const existing = await ctx.ports.notes.findById(input.id); if (!existing) { throw appError("NoteNotFound", { @@ -95,6 +104,7 @@ .input(NoteIdInputSchema) .output(z.void()) .run(async ({ ctx, input }) => { + requireUser(ctx); const existing = await ctx.ports.notes.findById(input.id); if (!existing) { throw appError("NoteNotFound", { ``` Keep the generated update and delete gate calls. Update authorization runs before the version-conflict check, so another account cannot use a stale version to learn the note's current version. The existing optimistic concurrency check continues to reject stale writes by the owner. These rules live in use cases so a direct call from a script or another workflow cannot bypass them. A service actor does not become a note owner automatically; background work needs its own explicit authorization design. ## Describe the protected HTTP reads The generator already declares authentication and catalog errors on create, update, and delete contracts. Add them to list and get as well: ```diff --- a/features/notes/contracts.ts +++ b/features/notes/contracts.ts @@ -36,6 +36,8 @@ sortDirection: query.string(), }), ) + .meta({ auth: "required" }) + .errors({ Unauthorized: errors.Unauthorized }) .responses({ 200: ListNotesOutputSchema, }); @@ -55,7 +57,12 @@ export const getNote = notes .get("/api/notes/:id") .pathParams(NoteIdInputSchema) - .errors({ NoteNotFound: errors.NoteNotFound }) + .meta({ auth: "required", authorization: { ability: "notes.view" } }) + .errors({ + Unauthorized: errors.Unauthorized, + Forbidden: errors.Forbidden, + NoteNotFound: errors.NoteNotFound, + }) .responses({ 200: NoteSchema, }); ``` The starter's `server/context.ts` resolves the session for each request. Contract metadata documents the requirements; it does not replace the identity and policy checks in the use cases. Keep route handlers as the generated thin bindings to those use cases. ## Update fixtures and migrate Keep the generated CRUD and policy tests. Give their seeded notes an owner and supply the same user in the use-case test's auth session. Extend the policy matrix to cover the protected read as well: ```diff --- a/features/notes/tests/notes.test.ts +++ b/features/notes/tests/notes.test.ts @@ -36,4 +36,5 @@ id: "00000000-0000-4000-8000-000000000101", name: "Alpha Note", + userId: "user_test", version: 1, createdAt: "2024-01-01T00:00:00.000Z", @@ -43,4 +44,5 @@ id: "00000000-0000-4000-8000-000000000102", name: "Beta Note", + userId: "user_test", version: 1, createdAt: "2024-01-02T00:00:00.000Z", @@ -50,4 +52,5 @@ id: "00000000-0000-4000-8000-000000000103", name: "Gamma Note", + userId: "user_test", version: 1, createdAt: "2024-01-03T00:00:00.000Z", @@ -80,4 +83,5 @@ AppContext["ports"] >({ + auth: { user: { id: "user_test", name: "Test User" } }, ports: testFixture.ports, actor: createTestUserActor("user_test"), ``` ```diff --- a/features/notes/tests/policy.test.ts +++ b/features/notes/tests/policy.test.ts @@ -8,6 +8,7 @@ return { id: "00000000-0000-4000-8000-000000000001", name: "Test Note", + userId: "user_test", version: 1, createdAt: now, updatedAt: now, @@ -15,7 +16,7 @@ } describe("notePolicy", () => { - it("documents generated notes authorization rules", async () => { + it("allows the owner to create, read, update, and delete notes", async () => { const tester = createPolicyTester({ policies: [notePolicy], }); @@ -33,14 +34,21 @@ expected: "allow", }, { - name: "authenticated actor can update note", + name: "owner can read note", + ctx, + ability: "notes.view", + subject: note, + expected: "allow", + }, + { + name: "owner can update note", ctx, ability: "notes.update", subject: note, expected: "allow", }, { - name: "authenticated actor can delete note", + name: "owner can delete note", ctx, ability: "notes.delete", subject: note, ``` Generate and review the migration, then apply it to your local database: ```bash bun run format bun beignet db generate bun beignet db migrate bun beignet db status bun run format bun run test bun beignet check ``` Expect the database status to be current and the generated tests to pass. Commit the migration with the application change. Restart `bun run dev` if it is already running, then check the HTTP boundary without a session cookie: ```bash curl -i http://localhost:3000/api/notes ``` Expect `401`. Sign in through the app and open [localhost:3000/api/notes](http://localhost:3000/api/notes) in the same browser. Expect a successful response with an empty `items` array for the new account. The next test exercises creation and access by a second account without needing a Notes UI. ## Verify owner, other-account, and anonymous access Add this test. It uses the real policy, use cases, contracts, and context blueprint. It supplies a test auth port for each identity and repeats the assertions against memory and an isolated SQLite database using your checked-in migrations. ```typescript // features/notes/tests/ownership.test.ts import { createUseCaseTester } from "@beignet/core/application"; import { createAnonymousActor, createStaticAuth, createUserActor, } from "@beignet/core/ports"; import { defineRoutes } from "@beignet/core/server"; import { createTestContextFactory, createTestPorts, } from "@beignet/core/testing"; import { createTestApp } from "@beignet/web/testing"; import type { AppContext } from "@/app-context"; import { createTestDatabase } from "@/infra/db/test-database"; import { createInMemoryNoteRepository } from "@/infra/notes/in-memory-note-repository"; import { initialPorts } from "@/infra/port-wiring"; import { expect, test } from "@/lib/beignet-test"; import { appContext } from "@/server/context"; import { createNote, deleteNote, getNote, listNotes, updateNote, } from "../contracts"; import type { NoteRepository } from "../ports"; import { noteRoutes } from "../routes"; import { createNoteUseCase, deleteNoteUseCase, getNoteUseCase, listNotesUseCase, updateNoteUseCase, } from "../use-cases"; function fixture(userId: string | null, notes: NoteRepository) { const auth = userId ? { user: { id: userId, name: userId } } : null; const { ports } = createTestPorts({ base: initialPorts, overrides: { notes, gate: initialPorts.gate, auth: createStaticAuth(auth) }, }); const context = createTestContextFactory({ ports, actor: userId ? createUserActor(userId) : createAnonymousActor(), auth, }); return { ports, tester: createUseCaseTester(context) }; } for (const backend of ["memory", "sqlite"]) { test(`${backend}: ownership holds in use cases and HTTP`, async () => { const database = backend === "sqlite" ? await createTestDatabase() : null; const notes = database?.repositories.notes ?? createInMemoryNoteRepository(); const owner = fixture("owner", notes); const other = fixture("other", notes); const anonymous = fixture(null, notes); const openApp = (ports: AppContext["ports"]) => createTestApp({ ports, context: appContext, routes: defineRoutes([noteRoutes]), }); const [ownerApp, otherApp, anonymousApp] = await Promise.all([ openApp(owner.ports), openApp(other.ports), openApp(anonymous.ports), ]); try { const note = await owner.tester.run(createNoteUseCase, { name: "Alpha" }); const theirs = await other.tester.run(createNoteUseCase, { name: "Beta", }); const last = await owner.tester.run(createNoteUseCase, { name: "Gamma" }); expect(note.userId).toBe("owner"); const first = await owner.tester.run(listNotesUseCase, { limit: 1, sortBy: "name", sortDirection: "asc", }); expect(first.items).toEqual([note]); expect(first.page.hasMore).toBe(true); const second = await owner.tester.run(listNotesUseCase, { limit: 1, sortBy: "name", sortDirection: "asc", cursor: first.page.nextCursor!, }); expect(second.items).toEqual([last]); expect(second.page.hasMore).toBe(false); expect( (await owner.tester.run(listNotesUseCase, { name: "Beta" })).items, ).toEqual([]); await expect( anonymous.tester.run(listNotesUseCase, {}), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); await expect( anonymous.tester.run(createNoteUseCase, { name: "Blocked" }), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); for (const [caller, code] of [ [anonymous, "UNAUTHORIZED"], [other, "FORBIDDEN"], ] as const) { await expect( caller.tester.run(getNoteUseCase, { id: note.id }), ).rejects.toMatchObject({ code }); await expect( caller.tester.run(updateNoteUseCase, { id: note.id, name: "Changed", version: 999, }), ).rejects.toMatchObject({ code }); await expect( caller.tester.run(deleteNoteUseCase, { id: note.id }), ).rejects.toMatchObject({ code }); } expect(await notes.findById(note.id)).toEqual(note); expect( await anonymousApp.safeRequest(listNotes, { query: {} }), ).toMatchObject({ ok: false, status: 401 }); expect( await anonymousApp.safeRequest(createNote, { body: { name: "Blocked" }, }), ).toMatchObject({ ok: false, status: 401 }); for (const [app, status] of [ [anonymousApp, 401], [otherApp, 403], ] as const) { for (const method of ["GET", "PATCH", "DELETE"]) { const response = await app.fetch( new Request(`http://beignet.test/api/notes/${note.id}`, { method, headers: { "content-type": "application/json" }, body: method === "PATCH" ? JSON.stringify({ name: "Changed", version: 999 }) : undefined, }), ); expect(response.status).toBe(status); } } expect((await otherApp.request(listNotes, { query: {} })).items).toEqual([ theirs, ]); expect(await notes.findById(note.id)).toEqual(note); expect( await ownerApp.request(getNote, { path: { id: note.id } }), ).toEqual(note); const forged = await ownerApp.fetch( new Request("http://beignet.test/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Forged owner", userId: "other" }), }), ); expect(forged.status).toBe(201); expect(await forged.json()).toMatchObject({ userId: "owner" }); const updated = await ownerApp.request(updateNote, { path: { id: note.id }, body: { name: "Updated", version: note.version }, }); expect(updated).toMatchObject({ name: "Updated", userId: "owner", version: note.version + 1, }); expect( await ownerApp.safeRequest(updateNote, { path: { id: note.id }, body: { name: "Stale", version: note.version }, }), ).toMatchObject({ ok: false, status: 409 }); await ownerApp.request(deleteNote, { path: { id: note.id } }); expect( await ownerApp.safeRequest(getNote, { path: { id: note.id } }), ).toMatchObject({ ok: false, status: 404 }); } finally { await Promise.all([ ownerApp.stop(), otherApp.stop(), anonymousApp.stop(), ]); await database?.close(); } }); } ``` ```bash bun run format bun run test features/notes/tests/ownership.test.ts bun beignet check ``` Expect both ownership tests to pass. They verify that: - Lists and name filters return only the current account's notes, including across cursor pages. - Anonymous calls fail with `401`; another account's direct reads, updates, and deletes fail with `403`, leaving the note unchanged. - Supplying `userId` in a create body cannot assign the note to another account. - The owner can read, update, and delete; stale updates return `409`, and reads after deletion return `404`. The tests run the HTTP pipeline with controlled sessions. They do not exercise Better Auth's sign-in cookies; the browser check above uses your running app's real session. See [Test a feature](https://www.beignetjs.com/markdown/guides/test-a-feature.md) for more testing patterns. As a local regression check, temporarily remove the `notes.update` gate call from `updateNoteUseCase`. The new tests should fail. Restore the call immediately and rerun the checks before committing. ## Troubleshooting | Symptom | Check | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | A signed-in request returns `401` | Send the app's session cookie. A terminal does not share the browser session; verify the auth provider and `server/context.ts` in a customized app. | | Another account can read or change a note | Check that get, update, and delete authorize the stored note through the real policy. Authentication alone permits every signed-in account. | | Lists or search expose other notes | Apply the owner predicate in the repository before pagination, and derive its argument from the authenticated user. | | Tests pass even with a broken policy | Supply `gate: initialPorts.gate` so the fixture uses the app's real gate. Test doubles must not silently allow access. | | The required owner column or fixture fails | Apply the migration and add `userId` to seeded records. Existing data needs an explicit owner backfill. | ## Adapt this to your app Keep existing auth setup and error mapping. Use your feature's owner field and repository, and preserve its transaction, pagination, and concurrency behavior. When adding the rule to an existing app, update every entrypoint that can read or mutate the resource, including exports and background workflows. For workspace-owned records, use [tenant-scoped repositories](https://www.beignetjs.com/markdown/tenancy.md) and membership-aware policies. Individual ownership, workspace membership, and roles are different rules; a caller-supplied workspace ID is not evidence of access. See [Authorization](https://www.beignetjs.com/markdown/authorization.md) for policy composition and gate APIs. Keep ownership immutable unless you deliberately add a transfer workflow. Review indexes for owner-filtered queries, foreign keys, and account-deletion behavior when integrating the schema into your application's data lifecycle. ## Work with your agent ```text Read https://beignetjs.com/markdown/agent-instructions.md and this app's local instructions. Follow https://beignetjs.com/markdown/guides/protect-a-resource.md. Identify the resource, auth setup, owner field, policy, repositories, and tests. For a fresh starter, generate notes with --authorization and follow the guide. For an existing resource, preserve its data, custom rules, and transaction wiring; do not regenerate over it. Use installed package guidance when versions differ. Derive the owner from the session, filter lists before pagination, and authorize stored records in get, update, and delete use cases. Keep ownership out of caller inputs. Test the owner, another account, and anonymous access through both use cases and HTTP, including pagination, forged owner input, unchanged denied writes, and SQLite persistence. Confirm the tests catch a removed ownership check, then restore it. Run format and beignet check. Explain changed files, migration needs, failed or skipped checks, and how I can repeat the verification. ``` Continue with [Test a feature](https://www.beignetjs.com/markdown/guides/test-a-feature.md), or return to [Guides](https://www.beignetjs.com/markdown/guides.md) to choose another task. For a guided UI built on these concepts, follow the [Projects tutorial](https://www.beignetjs.com/markdown/learn.md).