Search the docs
1.1.0GitHub
RecipesA Resource With Its Policy

A Resource With Its Policy

On this page

Introduction

This recipe adds a record that belongs to the signed-in user, such as a bookmark, with everything around it: a table, a repository, a policy, the routes, the controllers and their requests, two pages and a test. One command writes all of it on the starter kit, and the page shows the parts that decide who may see and change a record.

The code protects against three mistakes that this kind of page tends to make:

  • Reading or changing somebody else's record by its id (IDOR). Every route that takes a bookmark from the path binds it through the repository and then asks BookmarkPolicy. The policy answers a stranger with a 404, as it does for an id that does not exist, so nobody can probe which ids are taken.
  • A list that shows other people's records. The list asks the repository for ctx.user.id and nothing else.
  • Mass assignment of user_id. A new bookmark gets its owner from the session. The controller writes ctx.user.id and the title from the body and nothing else, so a user_id in the form never reaches the table.

You need an app made from the starter kit, as your first app creates it. The commands use pnpm.

Generate

pnpm marmeon make:resource Bookmark
pnpm marmeon migrate
pnpm marmeon db:types
pnpm marmeon route:types

make:resource writes the module and adds it to modules in bootstrap/app.ts. migrate creates the table, db:types writes its columns into the app's types, and route:types writes the types of the new routes, so the pages and Link know them. The command stops before it writes anything when modules/bookmarks exists or when the app lacks the auth module. Pass --module=<name> to pick another module than the plural of the record. See adding a resource.

What it writes

FilePurpose
modules/bookmarks/index.tsThe module: its name, its migrations and its routes.
modules/bookmarks/routes.tsThe routes behind authenticate() and verified(); the three that take a bookmark from the path bind it.
modules/bookmarks/BookmarkRepository.tsThe queries; pageOf() reads one page of a user's bookmarks.
modules/bookmarks/migrations/<timestamp>_create_bookmarks_table.tsThe table: a UUIDv7 key, user_id (deleted with the user), title and the timestamps.
modules/bookmarks/policies/BookmarkPolicy.tsview, update and delete: only the owner, everyone else a 404.
modules/bookmarks/controllers/ListBookmarksController.tsThe signed-in user's bookmarks, ten to a page.
modules/bookmarks/controllers/CreateBookmarkController.tsThe page with the empty form.
modules/bookmarks/controllers/StoreBookmarkController.tsSaves a new bookmark for the signed-in user.
modules/bookmarks/controllers/EditBookmarkController.tsThe page with the form filled in.
modules/bookmarks/controllers/UpdateBookmarkController.tsSaves the changes, after the policy said yes.
modules/bookmarks/controllers/DeleteBookmarkController.tsDeletes the bookmark, after the policy said yes.
modules/bookmarks/views/ListBookmarks.tsxThe list page, with pagination.
modules/bookmarks/views/Form.tsxThe form page, for a new bookmark and for an edit.
modules/bookmarks/bookmarks.test.tsThe tests of the module, with the stranger's bookmark on every route.

The code

The routes sort themselves into two groups. The list and the two routes that create a bookmark never take one from the path, so they say everyone: any signed-in user. The other four bind :bookmark through the repository, and the request of each asks the policy. The last of them, share, is explained below.

modules/bookmarks/routes.ts
import { authenticate, verified } from '@marmeon/auth';
import { defineRoutes, everyone } from '@marmeon/http';
import { CreateBookmarkController } from './controllers/CreateBookmarkController.ts';
import { DeleteBookmarkController } from './controllers/DeleteBookmarkController.ts';
import { EditBookmarkController } from './controllers/EditBookmarkController.ts';
import { ListBookmarksController } from './controllers/ListBookmarksController.ts';
import { ShareBookmarkController } from './controllers/ShareBookmarkController.ts';
import { StoreBookmarkController } from './controllers/StoreBookmarkController.ts';
import { UpdateBookmarkController } from './controllers/UpdateBookmarkController.ts';
import { BookmarkRepository } from './BookmarkRepository.ts';

// Signed in, with a confirmed address. A bookmark in the URL is looked up by its id and then checked
// against the signed-in user by BookmarkPolicy: somebody else's bookmark is a 404.
export default defineRoutes((Route) => {
  const bookmarks = Route.prefix('/bookmarks').middleware(authenticate()).middleware(verified()).name('bookmarks.');

  // These three never take a bookmark from the path: the list asks for `ctx.user.id`, and a new bookmark is created for the signed-in user.
  const mine = bookmarks.authorize(everyone);
  mine.get('/', ListBookmarksController).name('index');
  mine.get('/create', CreateBookmarkController).name('create');
  mine.post('/', StoreBookmarkController).name('store');

  const bookmark = bookmarks.bind('bookmark', BookmarkRepository);
  bookmark.get('/:bookmark/edit', EditBookmarkController).name('edit');
  bookmark.put('/:bookmark', UpdateBookmarkController).name('update');
  bookmark.delete('/:bookmark', DeleteBookmarkController).name('destroy');
  bookmark.post('/:bookmark/share', ShareBookmarkController).name('share');
});

The policy compares the record's owner with the user. denyAsNotFound() turns a refusal into a 404, which is why a missing bookmark and somebody else's look the same.

modules/bookmarks/policies/BookmarkPolicy.ts
import { denyAsNotFound, type AuthUser } from '@marmeon/auth';

import type { Row } from '@marmeon/database';

/**
 * Only the owner of a record may act on it; everyone else gets a 404, so nobody can probe which ids exist.
 * Use it in a request: `authorize: can(BookmarkPolicy, 'update', '<param>')`. See docs/authorization.md#writing-policies.
 */
export class BookmarkPolicy {
  view(user: AuthUser, record: Row<'bookmarks'>) {
    return record.user_id === user.id || denyAsNotFound();
  }

  update(user: AuthUser, record: Row<'bookmarks'>) {
    return record.user_id === user.id || denyAsNotFound();
  }

  delete(user: AuthUser, record: Row<'bookmarks'>) {
    return record.user_id === user.id || denyAsNotFound();
  }
}

A request ties a route to the policy with authorize: can(…). The third argument names the route parameter that holds the record. The schema lists the one field the form sends, and handle() reads the record the router bound as ctx.params.bookmark:

modules/bookmarks/controllers/UpdateBookmarkController.ts
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { rules as r } from '@marmeon/validation';
import { BookmarkRepository } from '../BookmarkRepository.ts';
import { BookmarkPolicy } from '../policies/BookmarkPolicy.ts';

export const UpdateBookmarkRequest = defineRequest({
  schema: r.object({
    title: r.string().trim().required().max(120),
  }),
  authorize: can(BookmarkPolicy, 'update', 'bookmark'),
});

/** Saves the changes to one of the user's bookmarks. */
export class UpdateBookmarkController extends Controller {
  static request = UpdateBookmarkRequest;

  readonly #bookmarks: BookmarkRepository;

  constructor(bookmarks: BookmarkRepository) {
    super();
    this.#bookmarks = bookmarks;
  }

  async handle(ctx: ContextOf<typeof UpdateBookmarkRequest>) {
    await this.#bookmarks.update(ctx.params.bookmark.id, { title: ctx.body.title });
    return this.redirect().route('bookmarks.index').flash('toast', { kind: 'success', message: 'Bookmark saved.' });
  }
}

The store controller is where the owner comes from. It writes an object it builds field by field, and never the body as it arrived:

modules/bookmarks/controllers/StoreBookmarkController.ts
  async handle(ctx: ContextOf<typeof StoreBookmarkRequest> & Authenticated) {
    await this.#bookmarks.insert({ user_id: ctx.user.id, title: ctx.body.title });
    return this.redirect().route('bookmarks.index').flash('toast', { kind: 'success', message: 'Bookmark saved.' });
  }

A lookup by address answers the same

A form that finds a user by what someone types, such as an e-mail address to share a bookmark with, must not tell a known address from an unknown one. A validation error like "No user has this address" lets a stranger test a list of addresses against your users. Do the work only when the user exists, and answer with the same redirect and flash in both cases:

modules/bookmarks/controllers/ShareBookmarkController.ts
import { UserRepository } from '#modules/auth';
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { rules as r } from '@marmeon/validation';
import { BookmarkPolicy } from '../policies/BookmarkPolicy.ts';

export const ShareBookmarkRequest = defineRequest({
  // Only the format of the address is checked here, never whether an account has it.
  schema: r.object({ email: r.string().trim().lowercase().required().max(255).email() }),
  authorize: can(BookmarkPolicy, 'update', 'bookmark'),
});

/** Shares one of the user's bookmarks with the person who has this address, if there is one. */
export class ShareBookmarkController extends Controller {
  static request = ShareBookmarkRequest;

  readonly #users: UserRepository;

  constructor(users: UserRepository) {
    super();
    this.#users = users;
  }

  async handle(ctx: ContextOf<typeof ShareBookmarkRequest>) {
    const user = await this.#users.findByEmail(ctx.body.email);
    // An account whose address is not confirmed counts as not found: private registration lets anybody sign up with somebody
    // else's address. The bookmark is `ctx.params.bookmark`. Write the share with `tryInsert()` and treat its `duplicate` as
    // success, so an address that already has it, or the user's own, gets the same answer.
    if (user?.email_verified_at) {
      // Share the bookmark with `user`, or queue a mail to their address.
    }
    // The same answer, found or not.
    return this.redirect().route('bookmarks.index').flash('toast', { kind: 'success', message: 'If that address has an account, the bookmark is shared.' });
  }
}

The route is the share line of routes.ts above, under the bookmark binding next to update. Never add exists(UserRepository, 'email') to the schema, and let a unique index decide the rest (the unique index is the truth). A test sends the form with a confirmed account's address, an unconfirmed one, an unknown one, the user's own and a repeat, and expects the same redirect and flash every time:

modules/bookmarks/share.test.ts
import { createTestApp, type TestApp } from '@marmeon/testing';
import { beforeEach, describe, it } from 'vitest';
import application from '../../bootstrap/app.ts';
import { UserFactory } from '../auth/index.ts';
import { BookmarkRepository } from './BookmarkRepository.ts';

let app: TestApp;
beforeEach(async () => {
  app = await createTestApp(application, { database: 'refresh' });
}, 30_000);

describe('sharing a bookmark', { timeout: 30_000 }, () => {
  it('answers the same for every address, and refuses a stranger', async () => {
    const ada = await app.factory(UserFactory).create();
    const bob = await app.factory(UserFactory).create();
    const kj = await app.factory(UserFactory).state('unverified').create();
    const bookmark = await app.make(BookmarkRepository).insert({ user_id: ada.id, title: 'Shared' });
    const message = 'If that address has an account, the bookmark is shared.';
    const share = (email: string) =>
      app.actingAs(ada).post(`/bookmarks/${bookmark.id}/share`, { form: { email } }).assertRedirect('/bookmarks').assertFlashed('toast', { kind: 'success', message });

    await share(bob.email);
    await share(kj.email);
    await share('nobody@example.com');
    await share(ada.email);
    await share(bob.email);
    await app.actingAs(bob).post(`/bookmarks/${bookmark.id}/share`, { form: { email: ada.email } }).assertNotFound();
  });
});

If the owner sees the shares, record each one under the typed address in both branches, outside the if, and list it the same way whether or not it has an account; only the recipient's mail or acceptance may reveal more. The private registration of the starter kit works this way. Keep the time of both paths alike too: queue the slow work (a mail) instead of doing it inline.

The test

bookmarks.test.ts tries every route as the owner and as a stranger. This is the part for the stranger: Ada asks for Bob's bookmark on every route that takes one from the path, and an id that does not exist gets the same answer. The test then reads the record back, to show that nothing changed.

modules/bookmarks/bookmarks.test.ts
  it("answers 404 for somebody else's bookmark, however it is reached", async () => {
    const ada = await app.factory(UserFactory).create();
    const bob = await app.factory(UserFactory).create();
    const bookmark = await bookmarks().insert({ user_id: bob.id, title: 'Secret' });
    const asAda = app.actingAs(ada);
    await asAda.get(`/bookmarks/${bookmark.id}/edit`).assertNotFound();
    await asAda.put(`/bookmarks/${bookmark.id}`, { form: { title: 'Mine now' } }).assertNotFound();
    await asAda.delete(`/bookmarks/${bookmark.id}`).assertNotFound();
    await asAda.get('/bookmarks/no-such-id/edit').assertNotFound();
    expect(await bookmarks().find(bookmark.id)).toMatchObject({ title: 'Secret' });
  });

Run the module's tests:

pnpm test modules/bookmarks

The other tests of the file cover a guest, the list that shows only the owner's bookmarks, a user_id in the form that changes nothing, the title rules, and the deletion of the bookmarks together with their user. See HTTP tests for the helpers.

Check

pnpm marmeon check
pnpm marmeon route:list

marmeon check runs the type check and the lint, and then boots the app. A route that does not say who may call it does not boot, so a route you add to routes.ts without a policy or everyone fails the boot check. raw-sql warns when you sort the list by a value from the request with sql.raw(input); the check page shows the fix. throttle warns about a route that anyone may call to change something and nothing limits. route:list prints the ACCESS column of each route: everyone for the list and the two routes that create, can BookmarkPolicy.update(bookmark) for edit, update and share, and can BookmarkPolicy.delete(bookmark) for destroy.

To add a field, write a second migration with pnpm marmeon make:migration add_url_to_bookmarks_table --module=bookmarks. Then add the field in every place that names fields one by one: the schemas of StoreBookmarkRequest and UpdateBookmarkRequest, the object that StoreBookmarkController writes and the one that UpdateBookmarkController writes, the props that EditBookmarkController passes to the form (it hands on only { id, title }), the form, and the select of the repository's pageOf(). Writing the fields one by one is what protects against mass assignment: a field you do not name never reaches the table, so do not replace those objects with ctx.body, and keep user_id out of both schemas. The migrations page shows how to write the change, and validation the rules for the field.

Where to go next