Skip to content

Authentication and abilities

View as Markdown

Authentication tells the API who made a request. Abilities decide which product actions that identity may use. This page separates those two decisions from row visibility, then applies an owner-only ability to the task app. You will learn where project abilities live, how a custom route checks one, and when a route should be public. The same pattern supports approvals, publishing, imports, exports, and other app-owned workflows.

Add an owner-only task-completion ability to this Sapporta app. Keep ordinary project and task CRUD available to workspace members, check the custom ability at the route edge, leave row scope under server control, and show me the focused checks you ran.

Project auth resolves a browser session or bearer token into c.get("auth"). That context contains the principal, active membership, abilities, data authority, and request-bound row-security helpers. A route can therefore ask a narrow question such as can("run", "task_completion") without parsing roles itself.

Abilities belong in packages/api/authz/ability.ts. The task app grants normal table actions to every signed-in workspace member and grants the completion workflow only to owners:

import { AbilityBuilder, createMongoAbility } from "@casl/ability";
import type { AppAbility, AppAuthFacts } from "./types.js";
export function buildAbility(ctx: AppAuthFacts): AppAbility {
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
if (ctx.principal.kind === "user") {
can(
["read", "create", "update", "delete", "export"],
["projects", "tasks"],
);
}
if (
ctx.principal.kind === "user" &&
ctx.principal.membership.roles.includes("owner")
) {
can("run", "task_completion");
}
return build();
}

The completion handler checks that feature action before it reads or writes task data:

const auth = c.get("auth");
forbidUnless(c, auth.ability.can("run", "task_completion"));
return completeTask({
db: c.get("db"),
auth,
taskId: request.params.id,
});

An ability check does not filter rows. The workflow still uses scopedRows() or one auth.rowSecurity.forTable(...) guard for every table it touches. This matters even for owners: an owner-only action does not imply cross-workspace database access.

App-owned routes are protected unless their mounted path is intentionally added to publicApiRoutes in packages/api/app.ts. A public entry only lets anonymous traffic reach the handler. The handler still checks an ability and applies row security to table-backed data.

export const publicApiRoutes = [
{ method: "GET", path: "/api/public-status" },
] as const satisfies readonly PublicRoutePattern[];

The task-completion route should not appear in that array.

Run the app as an owner, complete an open task, and then repeat the request as a member. Discover the mounted contract before calling it:

Terminal window
pnpm exec sapporta endpoints show "POST /api/tasks/{id}/complete"
pnpm exec sapporta api post /api/tasks/1/complete --body '{}'

The owner call returns the declared success response. The member call reaches authentication but fails authorization with 403. A missing session or bearer token fails earlier with 401.

The task app now distinguishes identity, feature permission, and data authority. buildAbility() owns named product actions, route handlers enforce those actions at the edge, and row-security helpers remain responsible for database visibility. From here, add separate subjects for workflows such as project_archival or task_import, and test each subject with the least-privileged role that should use it.