Apply task access rules
View as MarkdownThe seeded app is ready for an explicit access policy. This page gives every workspace member ordinary project and task CRUD while reserving the upcoming task-completion transition for workspace owners. It also keeps the active workspace under server control. These separate boundaries support shared task records, owner-only workflows, and consistent behavior across generated and app-owned APIs.
Let workspace members manage projects and tasks, reserve task completion for owners, and prove that neither role can choose or cross the active workspace.Keep the three access decisions separate
Section titled “Keep the three access decisions separate”Authentication identifies the caller. buildAbility() decides which named
actions that caller may perform. Request data authority decides which rows and
trusted ownership values are available. A successful ability check never widens
row visibility, and an owner role does not imply access to every workspace.
Define the task-app grants in packages/api/authz/ability.ts. Keep any existing
rules for authentication support routes, and add these domain rules:
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);
// Preserve the starter's agent-token and intentionally public rules here.
if (ctx.principal.kind === "user") { can( ["read", "create", "update", "delete", "export"], ["projects", "tasks"], );
if (ctx.principal.membership.roles.includes("owner")) { can("run", "task_completion"); } }
return build();}Generated routes ask for concrete actions such as read, create, and update
against the table’s SQL name. The custom completion route added later will ask
can("run", "task_completion") at its route edge. Keeping that product action
separate means a member can edit one task’s ordinary fields without receiving
permission to run the two-table completion transaction.
The starter already resolves trusted row authority in
packages/api/authz/request-data-authority.ts. Signed-in requests receive the
active membership’s workspace; request bodies and query strings do not take part
in this decision:
export async function resolveRequestDataAuthority(input: { principal: AppPrincipal; c: Context;}): Promise<RequestDataAuthority> { if (input.principal.kind !== "user") { return requestDataAuthority({ systemGlobalOnly: systemGlobalOnlyAuthority(), }); }
const workspace = input.principal.membership.workspace; return requestDataAuthority({ systemGlobalOnly: systemGlobalOnlyAuthority(), workspaceGlobalOnly: workspaceGlobalOnlyAuthority(workspace), workspaceUserScoped: workspaceUserScopedAuthority({ workspace, user: input.principal.user, }), });}Both projects and tasks declare rowScope: "workspaceGlobal". Their
generated handlers combine this authority with every list, lookup, get, create,
update, delete, and export. Clients never choose workspace_id, owner, role, or
scope columns. Custom table code later uses scopedRows() for ordinary work or
auth.rowSecurity.forTable(table) inside the completion transaction.
Add a focused ability test so the future route has one stable policy question:
expect(buildAbility(memberFacts).can("run", "task_completion")).toBe(false);expect(buildAbility(ownerFacts).can("run", "task_completion")).toBe(true);expect(buildAbility(memberFacts).can("update", "tasks")).toBe(true);Exercise the generated API boundary
Section titled “Exercise the generated API boundary”Use tokens for an owner and member in Workspace A and a user in Workspace B. Discover the mounted route, then run the same query with each identity:
pnpm exec sapporta endpoints show "GET /api/tables/tasks"
curl --silent http://localhost:3000/api/tables/tasks \ -H "Authorization: Bearer $OWNER_TOKEN"curl --silent http://localhost:3000/api/tables/tasks \ -H "Authorization: Bearer $MEMBER_TOKEN"curl --silent http://localhost:3000/api/tables/tasks \ -H "Authorization: Bearer $OTHER_WORKSPACE_TOKEN"The owner and member in Workspace A receive the same five seeded rows because they share row authority. The Workspace B response does not reveal those rows:
{ "data": [], "meta": { "total": 0, "page": 1, "limit": 50, "pages": 0 }}Now try to choose a workspace during an otherwise valid create:
POST /api/tables/tasksAuthorization: Bearer <member-token>Content-Type: application/json
{ "project_id": 1, "title": "Cross-workspace task", "status": "open", "priority": "medium", "workspace_id": "another-workspace"}Payload policy rejects the server-managed field before table validation or the database write:
{ "error": "Validation failed", "code": "VALIDATION_FAILED", "details": [ { "field": "workspace_id", "message": "This field is managed by Sapporta auth and cannot be submitted through the table API." } ]}Generated get, update, and delete routes return the same not-found behavior for a missing row and a row hidden by workspace scope. That equivalence prevents an ID probe from revealing that another workspace owns a record.
The task app now treats identity, feature abilities, and row visibility as three different controls. Members can use generated project and task CRUD. Owners also receive the named completion ability, but their data remains bounded by the active workspace. Next, record task completion history in an immutable child table before the owner-only transition is implemented. The broader design is covered in authentication and abilities and the auth and row-security reference.