Skip to Content
TutorialsDeveloper Series4. Role-Based Access

Tutorial 4: Role-Based Access Control

Right now, only admin users have access to tasks — no permissions are configured for regular users yet. In a real team app you need fine-grained control: Managers should be able to do everything, while Members can only manage their own tasks.

Buildpad’s RBAC system maps roles to permission policies on each DaaS collection. The UI component CollectionForm automatically enforces these permissions — no manual field-hiding logic required.

By the end you’ll have:

  • Two roles: Manager and Member
  • Policies that let Members read all tasks but only create/edit their own
  • A permission-aware form that hides forbidden fields automatically
  • An usePermissions hook powering conditional action buttons

Prerequisites

How RBAC works in Buildpad — Roles are defined in DaaS and assigned to users. Each role carries one or more policies; a policy bundles permission rules for specific collections and actions (read, create, update, delete), optionally filtered by a rule (e.g., “own items only”). A user’s effective access is the additive union of all policies across all their roles — policies can only expand access, never revoke it. Users with admin_access = true bypass all permission checks entirely. CollectionForm reads these policies at runtime and hides or disables fields the current user cannot edit.

Learn more about the access control model → 

Scaffold the Roles

Open Copilot Chat and type:

/create-rbac

When prompted, define two roles:

Manager

  • tasks: read (all), create (all), update (all), delete (all)
  • projects: read (all), create (all), update (all), delete (all)

Member

  • tasks: read (all), create (all), update (own), delete: none
  • projects: read (all), create: none, update: none, delete: none

Why “create (all)” not “create (own)”? — A permission filter can’t reference user_created at creation time because the item doesn’t exist yet. Instead, /create-rbac adds a preset that automatically sets user_created = $CURRENT_USER when a Member creates a task, recording ownership at save time. Only update and delete use the own item filter.

The skill uses MCP tools to configure these roles, policies, and permissions directly in your DaaS backend — no migration file is generated.

Verify the Configuration

/create-rbac configures roles, policies, and permissions directly in DaaS via MCP tools — no Supabase migration is needed. Verify the roles were created in DaaS Studio under Roles & Policies, or via the API:

curl -H "Authorization: Bearer <your-token>" \ https://<your-project>.daas.buildpad.ai/api/roles

Add the Permissions Proxy Route

CollectionList and CollectionForm resolve permissions by calling GET /api/permissions/me on your Next.js app, which proxies to DaaS. If this route is missing, the service falls back to an empty response — and the UI treats unknown permissions as full access, making RBAC appear to have no effect.

Check whether the route exists:

ls app/api/permissions/me/route.ts

If it’s missing, create it:

app/api/permissions/me/route.ts
import { type NextRequest, NextResponse } from "next/server"; import { getAuthHeaders, getDaaSUrl } from "@/lib/api/auth-headers"; export async function GET(request: NextRequest) { try { const daasUrl = getDaaSUrl(); const headers = await getAuthHeaders(); const searchParams = request.nextUrl.searchParams.toString(); const url = `${daasUrl}/permissions/me${searchParams ? `?${searchParams}` : ""}`; const response = await fetch(url, { method: "GET", headers, cache: "no-store" }); const data = await response.json(); return NextResponse.json(data, { status: response.status }); } catch (error) { const message = error instanceof Error ? error.message : "Proxy error"; return NextResponse.json({ errors: [{ message }] }, { status: 500 }); } }

Without this route, CollectionList silently grants full UI access to all users. The DaaS API still enforces permissions server-side (writes from unauthorised users will 403), but the UI will show Create/Edit/Delete buttons to everyone — making RBAC appear broken.

Assign Roles to Users

In DaaS Studio, go to Users, select a user, and assign them a role. For testing:

  • Assign yourself the Manager role.
  • Create a second test account and assign it the Member role.

New accounts start with the built-in User role, which has admin_access = false and no collection permissions by default. Until you assign a Manager or Member role, the test account will see empty lists and no create/delete buttons — this is expected. Assign the role first, then test.

You can also ask /create-rbac to seed test users with roles — it will assign roles programmatically via the DaaS access API. Users can hold multiple roles simultaneously; role assignment is M2M, not a single field on the user.

Verify Permission Enforcement in the Form

Sign in as the Member test account and navigate to a task you did not create (/content/tasks/<id>).

CollectionForm will automatically:

  • Show all fields as read-only (because the Member policy only allows update on own items)
  • Hide the Delete button entirely

Sign in as Manager and notice full edit access is restored. No code changes needed — the form reads permissions from the DaaS API at runtime.

If the form is still editable for tasks the Member didn’t create, CollectionForm may not be evaluating the item-level permission filter against the loaded item. See Troubleshooting below.

See Permission-Aware UI in Action

CollectionList automatically fetches GET /permissions/me on mount and gates its built-in create and delete buttons — no manual permission checks required. The tasks page from Tutorial 3 already works correctly:

app/(authenticated)/content/tasks/page.tsx
"use client"; import { useRouter } from "next/navigation"; import { CollectionList } from "@/components/ui"; export default function TasksListPage() { const router = useRouter(); return ( <CollectionList collection="tasks" enableSearch enableFilter enableCreate enableDelete enableSort enableSelection limit={25} onCreate={() => router.push("/content/tasks/+")} onItemClick={(item) => router.push(`/content/tasks/${item.id}`)} /> ); }
  • Manager: the Create button is visible and active; bulk delete is available.
  • Member: the Create button is visible and active (Members have create (all) on tasks); the bulk Delete button is disabled with a “Not allowed” tooltip.

No code changes are needed — CollectionList reads permissions at runtime.

If you need to gate a custom action beyond CRUD (e.g., an “Export” button), use the usePermissions hook:

import { usePermissions } from "@/lib/buildpad/hooks"; const { canPerform } = usePermissions({ collections: ["tasks"] }); {canPerform("tasks", "create") && <Button onClick={handleExport}>Export</Button>}

Test with Both Accounts

  • Can see the Create button
  • Can open any task and edit all fields
  • Can delete any task

Troubleshooting

Form is editable for tasks the Member didn’t create

Symptom: You sign in as the Member test account, open a task created by another user, and all fields are editable — nothing is read-only and the Delete button is visible.

Root cause: CollectionForm checks whether an update action exists in the user’s permissions (it does, because Members can update their own tasks). It does not evaluate the permissions filter — { "user_created": { "_eq": "$CURRENT_USER" } } — against the loaded item. So any task opens as editable regardless of who created it. DaaS still enforces ownership server-side (a save attempt on a non-owned task returns 403), but the UI does not reflect this restriction.

Diagnose: In DaaS Studio, open Roles & Policies → Member policy → tasks → update. Confirm the permission rule shows user_created = $CURRENT_USER. If it does, the policy is correct — the issue is in CollectionForm.

Fix: Open Copilot Chat and paste:

CollectionForm shows editable fields when the Member opens a task they didn't create. The Member update permission has an item-level filter: { "user_created": { "_eq": "$CURRENT_USER" } }. Fix CollectionForm to evaluate the update permissions filter against the loaded item. If the item's user_created field doesn't match the current user's ID, set updateAllowed = false and mark all fields as readonly.

How usePermissions Works

usePermissions({ collections }) returns a canPerform(collection, action) function that checks whether the current user’s policies allow a given action on a collection.

import { usePermissions } from "@/lib/buildpad/hooks"; const { canPerform } = usePermissions({ collections: ["tasks"] }); canPerform("tasks", "read") // → true for all authenticated users canPerform("tasks", "create") // → true for Managers; false for Members canPerform("tasks", "update") // → true for Managers; true for Members (DaaS enforces own-items filter server-side) canPerform("tasks", "delete") // → true for Managers; false for Members

Item-level ownership is enforced server-side. canPerform("tasks", "update") returns true for Members because they do have an update policy — DaaS applies the user_created = $CURRENT_USER filter when the actual save request is made. If a Member tries to update a task they don’t own, DaaS returns a 403.

What’s Next

Your app now has proper access control. In the next tutorial you’ll add a task workflow — so tasks move through defined stages with role-based transition rules.

Continue to Tutorial 5: Workflow Automation →

Last updated on