Skip to main content
The Tuturuuu platform implements comprehensive Row-Level Security (RLS) policies to enforce workspace-based multi-tenancy and granular permission controls.

Security Model Overview

Core Principles

  1. Workspace Isolation: All workspace-scoped resources are isolated via RLS policies
  2. Permission-Based Access: Actions require specific workspace role permissions
  3. User Ownership: Users can only access resources they own or have permission to view
  4. Fail-Secure Default: No access unless explicitly granted by policy

Architecture

Policy Patterns

Pattern 1: Workspace Membership Check

Use Case: User must be a member of the workspace to access resources. Example: workspace_tasks SELECT policy
Key Components:
  • auth.uid(): Gets the authenticated user’s ID
  • Subquery checks workspace_members table
  • Returns only rows matching user’s workspaces

Pattern 2: Permission-Based Access

Use Case: User needs specific workspace permission to perform action. Example: Task UPDATE policy
Permission Enum Values: Permissions come from the workspace_role_permission enum. The generated list is the single source of truth — see the workspace_role_permission array in packages/types/src/supabase.ts (regenerated by bun sb:typegen) rather than hard-coding values, since the set evolves with new features. A representative subset at the time of writing:
  • manage_workspace_settings
  • manage_workspace_roles
  • manage_workspace_members
  • manage_workspace_security
  • manage_workspace_audit_logs
  • manage_projects
  • manage_calendar
  • manage_documents
  • manage_drive
  • manage_users
  • manage_finance
  • manage_inventory
  • ai_chat
  • ai_lab
Task and project access is governed by manage_projects; there is no manage_tasks permission. Several values that older docs referenced (manage_infrastructure_settings, manage_user_groups, manage_user_roles, ai_lab_assistant, view_disabled_users, disable_user) are no longer part of the enum. Always confirm a permission string against the generated enum before using it in a policy — a typo or removed value will fail the permission::workspace_role_permission cast at runtime.

Pattern 3: User Ownership

Use Case: User can only access resources they created. Example: notes policies

Pattern 4: Combined Workspace + Permission

Use Case: Workspace membership AND specific permission required. Example: Calendar event management

Pattern 5: Public Read, Restricted Write

Use Case: All workspace members can view, only privileged users can modify. Example: Workspace settings

Pattern 6: Cross-Table Permission Inheritance

Use Case: Permission check depends on related table’s policies. Example: Task project members inherit from project permissions

Pattern 7: Platform User Profile Visibility

Use Case: Platform profile rows are useful for names and avatars, but the Data API must not let any authenticated user enumerate every account. public.users is not a globally readable directory. Authenticated callers can read their own row and rows for users who share at least one workspace with them. Private account fields such as email and full name belong in public.user_private_details. Account state and preferences such as services, timezone, first_day_of_week, time_format, and deleted also belong there instead of on the public profile row. public.user_private_details may be used for self-profile and shared-workspace profile lookups, but the policy must correlate the target user to the current requesting user. Do not use an uncorrelated EXISTS check that only proves the target user belongs to some workspace. Use a SECURITY DEFINER helper for the shared-workspace check so the profile policy does not recurse through workspace_members RLS under the caller’s permissions. Do not restore a policy equivalent to USING (true) on public.users. If a feature needs a global user directory or public profile page, expose only the intended columns through an application route or a purpose-built API contract instead of widening the table policy.

Pattern 8: Client-Facing Public Views

Use Case: A view in the exposed public schema is granted to anon or authenticated. Postgres views are security definer by default when created by privileged owners. In Supabase, that means a public view can bypass RLS on the underlying tables even when every base table has RLS enabled. Any public-schema view that is directly selectable by anon or authenticated must be created or altered with security_invoker = true. Do not use a public view as a shortcut around table policies. If a view needs privileged data such as audit records or private profile details, revoke direct client-role SELECT and expose the data through a permission-checked API route or RPC instead.
New public views should be covered by public-view-security.sql, which fails when a Data API-exposed view is missing security_invoker.

Pattern 9: Workspace-Scoped Security Definer RPCs

Use Case: An RPC must run with elevated database privileges to read audit, private, or RLS-protected tables. Every workspace-scoped SECURITY DEFINER RPC that is reachable through the Supabase Data API must enforce authorization inside the database function. Page, route, or server action permission checks are not sufficient because clients can call exposed RPCs directly. Required controls:
  • Read the caller with auth.uid() and deny missing callers unless the JWT role is service_role.
  • Verify workspace membership and the workspace permission in the function before reading sensitive rows. Do not rely on permission helpers alone when a default permission could be enabled workspace-wide.
  • For identity-linking flows, resolve candidate identity inside the function from the current verified auth.users row. Gate privileged lookups with the workspace feature config before returning candidate IDs, and do not accept caller-supplied emails or user-editable profile fields as proof of identity.
  • Revoke direct execution from public and anon.
  • Avoid exposing lower-level privileged helper functions to authenticated when they do not perform their own permission check.
  • Cover the grant matrix and allowed/denied calls with pgTAP when practical.

Pattern 10: API-Only Workspace Tables

Use Case: The apps/web API performs stricter permission checks than a plain workspace membership policy can express safely, then reads or writes with createAdminClient(). For these tables, do not grant anon or authenticated direct Data API access and do not leave member-only RLS policies in place. A browser or REST client can call exposed Supabase tables directly with the publishable key, bypassing route checks such as manage_users or send_user_group_post_emails. Required controls:
  • Revoke table privileges from public, anon, and authenticated.
  • Keep table access behind service-role API routes that perform the workspace permission check first.
  • Keep service-role grants explicit. The proxy route is the public contract; the table is not a browser, mobile, REST, or GraphQL contract.
  • Drop permissive member-only RLS policies on the underlying tables; use service-role-only policies when an explicit policy is useful for review.
  • Add the tables to PROXY_ONLY_PUBLIC_TABLES in packages/supabase/src/next/protected-tables.ts so deprecated direct clients fail loudly during app development.
  • Revoke direct EXECUTE from exposed helper RPCs unless the function performs its own caller authorization.
The migration 20260522172925_harden_proxy_only_public_tables.sql applies this transition guardrail to the current PROXY_ONLY_PUBLIC_TABLES set and also revokes automatic grants for future public tables, functions, and sequences. New migrations that intentionally expose a public Data API surface must now add the matching explicit GRANT statements in the same migration.

Pattern 11: Private Schema Server-Owned Tables

Use Case: A table, helper function, or protected query surface is only needed by cron jobs, service-role routes, Tuturuuu API routes, or database internals and should not be exposed through Supabase’s generated REST API at all. Default new protected database access to the existing private schema unless the data is intentionally public. Do not add private to the Supabase Data API exposed schemas or extra search path. New migrations should prefer direct server-owned access from apps/web: call private RPCs through the server-side Supabase admin client with schema('private').rpc(...), and use getPlatformSql() only for private table access that is not modeled as an RPC. This keeps browser and mobile consumers on centralized apps/web API routes while avoiding new Supabase REST endpoints for private data. Legacy public bridging RPCs may exist while older code is migrated, but do not add new public RPCs for tables or protected helper queries that can be reached through the server-side database connection. If a public table has no app/package consumers and is not part of an active database contract, prefer dropping it in a forward migration instead of moving dead schema surface into private. Notification delivery queue internals follow this pattern: private.notification_batches and private.notification_delivery_log are processed only by the server-owned batch cron, immediate-send route, and service-role notification helper functions. Required controls:
  • Keep anon and authenticated without USAGE on private.
  • Revoke direct table privileges from public, anon, and authenticated.
  • Keep RLS enabled with service-role-only policies for reviewability.
  • Keep private table reads and writes inside server-only modules used by apps/web routes, server actions, or server components.
  • Cover private placement, table privileges, and schema usage with pgTAP tests.

Common RLS Helpers

Get User Workspaces

Check Workspace Permission

The deployed helper is public.has_workspace_permission(p_ws_id, p_user_id, p_permission). The workspace id comes first, the user id second, and the permission is passed as text (cast inside the function to workspace_role_permission). It checks both role-based grants (workspace_role_membersworkspace_role_permissions) and workspace-wide default permissions (workspace_default_permissions), and only counts rows where enabled = true. Match this signature when calling it from policies or RPCs (see Pattern 9).
Call it as public.has_workspace_permission(ws_id, auth.uid(), 'manage_projects'). Argument order matters: (p_ws_id, p_user_id, p_permission). Passing the user id and workspace id in the wrong order silently checks the wrong workspace.

Policy Testing

Enable RLS (Always Required)

Test Policy as User

Verify Policy Coverage

Security Best Practices

1. Always Enable RLS

2. Use SECURITY DEFINER Carefully

Only use SECURITY DEFINER for helper functions that need elevated privileges:

3. Test Policies Thoroughly

Always test:
  • ✅ Users can access their own data
  • ✅ Users cannot access other workspaces’ data
  • ✅ Permission checks work correctly
  • ✅ Edge cases (pending invites, disabled users)

4. Avoid Policy Conflicts

5. Performance Considerations

Common Policy Patterns by Table Type

Workspace-Scoped Resources

User-Owned Resources

Public Read Resources

Debugging RLS Issues

Check Active Policies

Test as Specific User

Bypass RLS for Admin Operations

Use createAdminClient() from @tuturuuu/supabase in server-side code:
Always name createAdminClient() locals sbAdmin, not supabase, so service-role access is visually distinct from request-scoped clients during review.

Migration Example

When adding a new table, always include RLS:

Privilege Grants vs RLS Scope

RLS policies only apply after SQL privileges have already allowed the role to touch a table. For sensitive workspace configuration tables, avoid granting insert, update, or delete to the broad authenticated role when the matching RLS policies only check workspace membership. Keep authenticated grants read-only unless the table policy itself enforces the same granular permission that the application route requires. Route privileged writes through permission-gated API handlers using elevated service-role access.