A SaaS RBAC starter should answer two questions clearly: who can enter the owner admin panel, and who can act on a protected resource. The base answers them with explicit role guards in the application and matching helpers in the database.
The generic role list contains three assignable roles: super_admin, admin, and member. The database applies the same three-value constraint to a user's role. The first two are owner-side roles. A member is the baseline end-user role that products can extend.
The owner-side split is deliberate. A super_admin can enter the most sensitive operations. An admin can use the normal platform admin surface. The route guard accepts only super_admin for top-level operations and accepts either owner role when an admin-level route is enough.
Access checks exist at several layers
The application has guards for page routes and API routes. A page can require a session or one of a set of roles and redirect on failure. An API route can return 401 when there is no session, 404 when the profile is missing, and 403 when the role is not allowed.
The application proxy adds an admin-shell check. For /admin, it loads the current profile and admits only super_admin or admin. Suspended, banned, and deleted profiles are handled before the admin page renders.
Postgres has matching helpers. One returns the current user's role. Another checks super_admin. A third checks either owner-side admin role. Row-level policies combine those helpers with organization ownership where necessary.
That combination matters. A hidden navigation link is not authorization. The page shell, route, and database each have a job. A product can still make a mistake in a new route, but the base gives it established guard patterns instead of leaving authorization to scattered string comparisons.
The admin can manage roles
The role-change endpoint is available inside the admin API. It validates the requested role against the central assignable-role list and requires a super_admin session. The same underlying service is shared with the versioned Connect operation, so session-driven and API-key-driven role changes use the same business checks.
Two lockout guards are built in. An acting user cannot change their own role through that service. The last remaining super_admin cannot be demoted until another user has been promoted. Successful changes write an audit entry and dispatch a role-change event.
The service also verifies that the target user belongs to the caller's organization. A cross-organization target is returned as not found rather than revealing that the account exists elsewhere. That keeps role administration inside the same tenant boundary as the rest of the user model.
There is also a database trigger against self-escalation. It runs before updates to the role column and blocks a non-admin user from changing their own role through the database API. Backend service operations and authorized admin changes are allowed. That protects the path outside the admin screen, where an authenticated user might otherwise patch their profile directly.
Tenant-aware roles can extend the base
The template contains helpers for richer products. requireOrgAdmin admits an organization owner or a platform role above it. Tenant guards can combine a role tier with a tenant ID for operator-side and client-side access. The central role configuration is designed to be extended by a product.
The database also has a roles table with an organization ID and a JSON permissions field. It gives a product a place to model named, tenant-specific permission sets.
Those extension points are useful, but they should not be confused with what the baseline already enforces.
The honest limit
The base is role-based, not a complete dynamic permission engine. Runtime guards check role strings. The generic roles.permissions JSON is not evaluated by the reviewed authorization helpers, and the baseline admin role editor assigns one of three configured user roles. A product that needs per-action permissions has to wire those permissions into its route and database checks.
There is also a current mismatch around org_admin. The template contains an org_admin guard, a database helper, and an onboarding path that assigns the role. But the baseline users constraint and the central assignable-role list include only super_admin, admin, and member. A multi-organization product must widen both the database constraint and the TypeScript list before org_admin is usable. Until it does, the role is scaffolding, not a baseline role.
Role helpers do not remove the need for tenant filters. The organization-owner helper explicitly says it must be combined with organization_id = auth_org_id() in database policies. A privileged role without a resource boundary can still see too much.
Finally, role names are not a product strategy. The base cannot decide whether a marketplace needs seller, buyer, moderator, and support roles, or whether a learning product needs instructor and student. Those belong to the product. The base supplies the owner roles, end-user default, guard shapes, and safe extension points.
Why roles belong in every product
An admin panel creates a privileged surface on day one. Without a clear role model, the product either exposes owner operations to ordinary users or hardcodes a single account forever.
The included role layer gives the buyer a usable starting split and protects the most dangerous transition: changing who has power. It gates the admin shell, validates admin API calls, blocks self-escalation, and prevents the last owner from being removed.
Products can add their own roles, but they add them to an existing contract. The database constraint, central list, route guards, tenant policies, and admin editor all have named places to change. That is much safer than discovering five unrelated role checks after customers are already using the product.
