Auth & Multi-Tenant
RBAC where agents are users, 63 granular permissions, tenant context, and the permission gating model.
Overview
The Sprinter Platform uses a unified RBAC system where agents and users share the same permission model. Both have roles, both are gated by the same 63 granular permissions, and both operate within tenant boundaries.
Tenant Context
Every operation is scoped to a tenant. The platform resolves the active tenant from the request:
import { getTenantContext, getActiveTenantId } from "@sprinterai/supabase";
// Full context (userId, tenantId, role, permissions)
const ctx = await getTenantContext();
// Just the tenant ID (soft fallback to default)
const tenantId = await getActiveTenantId();Tenant context is cached per request via React.cache(). Multiple calls in the same request share one database lookup.
Tenant-scoped URLs
URLs follow the pattern /t/[tenantSlug]/...:
import { tenantUrl } from "@sprinterai/supabase";
tenantUrl("acme-corp", "/dashboard");
// "/t/acme-corp/dashboard"Middleware extracts the tenant slug, sets the x-tenant-slug header, and rewrites the URL.
Roles
Six built-in roles, ordered by privilege:
| Role | App Label | Capabilities |
|---|---|---|
system_admin | Owner | Full access, tenant management |
tenant_admin | Admin | Full access within tenant |
editor | Editor | Create, update entities and agents |
member | Member | Create entities, limited updates |
viewer | Viewer | Read-only access |
guest | Guest | Minimal read access |
Default signup role is guest (read-only). Promote via Admin > Members.
import { ROLE_IDS, isAdminRole, mapRoleSlug } from "@sprinterai/supabase";
ROLE_IDS.system_admin; // UUID
isAdminRole("tenant_admin"); // true
mapRoleSlug("system_admin"); // "owner"Permissions
63 granular permissions in the format {resource}.{level}.{action}:
entities.team.read
entities.team.create
entities.team.update
entities.team.delete
agents.team.configure
admin.tenant.manage
...Checking permissions
import {
hasPermission,
requirePermission,
getUserPermissions,
} from "@sprinterai/supabase";
// Boolean check
const canEdit = await hasPermission("entities.team.update");
// Throws if missing
await requirePermission("admin.tenant.manage");
// Full permission set
const perms = await getUserPermissions();Permission storage
rolestable -- role definitionsrole_permissionstable -- permission grants per roleuser_permissionsview -- denormalized user permissions (for fast lookup)
Auth Adapter
All auth operations go through centralized functions. Never import Supabase clients directly for auth:
import {
getUserId, // 0 DB calls (local JWT)
requireAuth, // 1 DB call (cached)
requireAdmin, // 1 DB call (cached)
hasPermission, // 1-2 DB calls (cached)
requirePermission,
getClaims, // 0 DB calls (local JWT)
} from "@sprinterai/supabase";In API routes
export async function GET() {
const { userId, tenantId, role } = await requireAuth();
// Guaranteed authenticated + tenant resolved
}
export async function POST(request: Request) {
await requirePermission("entities.team.create");
// Guaranteed to have permission, or throws 403
}In server actions
"use server";
import { requireAuth } from "@sprinterai/supabase";
export async function updateEntity(id: string, data: unknown) {
const { tenantId } = await requireAuth();
// proceed with tenant-scoped operation
}Agents as Users
Agents share the user permission system:
- Each agent has a
role_idin theagentstable - The role determines what tools the agent can use
- Two execution modes determine whose permissions apply
Supervised mode (chat)
The user is in the loop. The agent inherits the user's permissions:
const userPerms = await getUserPermissions();
const tools = await resolveAgentTools({
agent,
stores,
permissions: userPerms, // user's permissions
});Autonomous mode (heartbeat)
The agent acts independently. It uses its own role's permissions:
import { getPermissionsForRole } from "@sprinterai/supabase";
const agentPerms = await getPermissionsForRole(agent.role_id);
const tools = await resolveAgentTools({
agent,
stores,
permissions: agentPerms, // agent's own permissions
});Tenant Operations
import {
createTenant,
switchTenant,
addTenantMember,
removeTenantMember,
getTenantMembers,
ensureUserProvisioned,
} from "@sprinterai/supabase";
// Create a new tenant
await createTenant({ name: "Acme Corp", slug: "acme-corp" });
// Add a member
await addTenantMember(tenantId, userId, ROLE_IDS.editor);
// Switch active tenant
await switchTenant(userId, "acme-corp");
// List members
const members = await getTenantMembers(tenantId);RLS Enforcement
All database tables enforce Row Level Security by tenant_id. Key RLS patterns:
- Always wrap
auth.uid()in a SELECT:(SELECT auth.uid())-- evaluated once per query - Use
user_tenantsfor membership checks - The admin client (
createAdminClient) bypasses RLS for system operations - Service role policies (
TO service_role) handle background jobs and provisioning
User Provisioning
On signup, a database trigger automatically:
- Creates a user profile
- Creates a default tenant membership (guest role)
Custom provisioning logic goes in ensureUserProvisioned().
Starter Auth Pages
The Next.js starter (starters/next/) ships a complete password-based auth flow with four pages under app/(auth)/:
| Route | Page | Description |
|---|---|---|
/login | login/page.tsx | Email + password sign-in; links to signup and forgot-password |
/signup | signup/page.tsx | New account creation |
/forgot-password | forgot-password/page.tsx | Sends a password reset email |
/reset-password | reset-password/page.tsx | Accepts the reset token and sets a new password |
All four pages call createBrowserClient() from @sprinterai/supabase and never import the Supabase client directly.
Forgot-password flow
const supabase = createBrowserClient();
await supabase.auth.resetPasswordForEmail(email, {
redirectTo: window.location.origin + "/reset-password",
});Supabase sends an email containing a link that includes a one-time token. Clicking the link opens /reset-password with the token in the URL fragment; Supabase SSR automatically exchanges it for a session before the page renders.
Reset-password flow
const supabase = createBrowserClient();
await supabase.auth.updateUser({ password });The page validates that both password fields match and that the new password is at least 8 characters before calling updateUser. On success, it redirects to /login?message=Password+updated+successfully.
Middleware and the auth routes
The starter middleware lists /forgot-password and /reset-password in its public route allowlist so unauthenticated users can reach these pages:
const PUBLIC_ROUTE_PREFIXES = [
'/login',
'/signup',
'/forgot-password',
'/reset-password',
'/auth/callback',
'/api',
] as const;