This document explains in detail the Capgo RBAC (Role-Based Access Control) permission system, enabling granular access control to platform resources.
- Overview
- System Architecture
- Database Tables
- Available Roles
- Available Permissions
- SQL Functions
- Backend Integration
- Frontend Integration
- Debugging and Troubleshooting
- Best Practices
Capgo uses a hybrid system that supports two permission management modes:
- Main table:
org_users - Simple roles:
super_admin,admin,write,upload,read - Limitation: one role per user per organization
- Granularity: limited, no control at individual app/channel level
- Main tables:
roles,permissions,role_bindings,role_permissions - Multiple roles: a user can have multiple roles at different scopes
- Fine granularity: permissions at org, app, channel, and bundle level
- Flexibility: add/modify permissions without code changes
The system automatically switches between legacy and RBAC via:
- Org-level flag:
use_new_rbaccolumn in theorgstable - Global flag:
rbac_settingstable (singleton) to enable RBAC for all orgs - Auto-detection: the
rbac_is_enabled_for_org()function checks both flags
-- The org uses RBAC if:
-- 1. orgs.use_new_rbac = true OR
-- 2. rbac_settings.use_new_rbac = true
SELECT rbac_is_enabled_for_org('123e4567-e89b-12d3-a456-426614174000');Capgo's RBAC system follows the standard RBAC model with extensions for multi-scope:
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Principal │────▶│ Role Binding │────▶│ Role │
│ (User/API) │ │ (at scope) │ │ │
└─────────────┘ └──────────────┘ └──────┬───────┘
│
│
┌──────▼────────┐
│ Role Perms │
└──────┬────────┘
│
┌──────▼────────┐
│ Permission │
└───────────────┘
-
Principal: The entity performing the action
- User (authenticated user)
- API Key
- Group (user group)
-
Role: Consistent set of permissions
- Example:
org_admin,app_developer,app_uploader - Defined for a specific scope (platform, org, app, channel, bundle)
- Example:
-
Permission: Atomic authorized action
- Example:
app.upload_bundle,channel.promote_bundle - Fine granularity for precise control
- Example:
-
Role Binding: Assignment of a role to a principal in a scope
- Example: User X has the
app_developerrole on app Y - A principal can have multiple bindings at different scopes
- Example: User X has the
-
Scope: Level in the hierarchy where the binding applies
platform: Entire platform (Capgo admins only)org: Organization (applies to all apps in the org)app: Specific applicationchannel: Specific channelbundle: Specific bundle
Permissions propagate downward in the hierarchy:
Platform (global)
│
└─▶ Organization
│
└─▶ Application
│
├─▶ Channel
│
└─▶ Bundle
Propagation example:
- User with
org_adminat org level → access to all apps in this org - User with
app_developerat app level → access to all channels in this app - User with
channel_adminat channel level → access only to this channel
Stores all available roles in the system.
CREATE TABLE public.roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text UNIQUE NOT NULL,
scope_type text NOT NULL CHECK (scope_type IN ('platform', 'org', 'app', 'bundle', 'channel')),
description text,
priority_rank int NOT NULL DEFAULT 0,
is_assignable boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NULL
);Important columns:
name: Unique role name (e.g.,org_admin)scope_type: Native level of the role (where it can be assigned)priority_rank: Priority order (higher = more permissions)is_assignable: Iffalse, cannot be assigned to customers (internal use)
Indexes:
- Primary key on
id - Unique on
name
Defines all available permissions.
CREATE TABLE public.permissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key text UNIQUE NOT NULL,
scope_type text NOT NULL CHECK (scope_type IN ('platform', 'org', 'app', 'bundle', 'channel')),
bundle_id bigint NULL REFERENCES public.app_versions(id) ON DELETE CASCADE,
description text,
created_at timestamptz NOT NULL DEFAULT now()
);Important columns:
key: Unique permission identifier (e.g.,app.upload_bundle)scope_type: Minimum scope required for this permissionbundle_id: Optional bundle reference when scope isbundledescription: Explanation of the authorized action
Naming convention: {scope}.{action}
- Examples:
org.read,app.update_settings,channel.promote_bundle
Junction table between roles and permissions.
CREATE TABLE public.role_permissions (
role_id uuid REFERENCES public.roles(id) ON DELETE CASCADE,
permission_id uuid REFERENCES public.permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);Usage:
- Defines which permissions are granted to each role
- A role can have multiple permissions
- A permission can belong to multiple roles
Examples:
Example for org_admin:
org.read,org.update_settings,org.read_members,org.invite_user- All
app.*permissions (read, update_settings, delete, upload_bundle, update_user_roles, etc.) - All
channel.*permissions (read, update_settings, delete, promote_bundle, etc.) - All
bundle.*permissions (delete)
Example for app_developer:
app.read,app.update_settings,app.upload_bundle,app.create_channelchannel.read,channel.update_settings,channel.promote_bundlebundle.delete
Example for app_uploader:
app.read,app.read_bundles,app.upload_bundle,app.read_channels,app.read_logs,app.read_devices,app.read_audit
Example for org_member:
org.read,org.read_members
Example for bundle_admin:
bundle.read,bundle.update,bundle.delete
Example for bundle_reader:
bundle.read
Assigns roles to principals in specific scopes.
CREATE TABLE public.role_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
principal_type text NOT NULL CHECK (principal_type IN ('user', 'group', 'apikey')),
principal_id uuid NOT NULL,
role_id uuid NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
scope_type text NOT NULL CHECK (scope_type IN ('platform', 'org', 'app', 'bundle', 'channel')),
org_id uuid NULL REFERENCES public.orgs(id) ON DELETE CASCADE,
app_id uuid NULL REFERENCES public.apps(id) ON DELETE CASCADE,
bundle_id bigint NULL REFERENCES public.app_versions(id) ON DELETE CASCADE,
channel_id uuid NULL REFERENCES public.channels(rbac_id) ON DELETE CASCADE,
granted_by uuid NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NULL,
reason text NULL,
is_direct boolean NOT NULL DEFAULT true,
CHECK (
(scope_type = 'platform' AND org_id IS NULL AND app_id IS NULL AND bundle_id IS NULL AND channel_id IS NULL) OR
(scope_type = 'org' AND org_id IS NOT NULL AND app_id IS NULL AND bundle_id IS NULL AND channel_id IS NULL) OR
(scope_type = 'app' AND org_id IS NOT NULL AND app_id IS NOT NULL AND bundle_id IS NULL AND channel_id IS NULL) OR
(scope_type = 'bundle' AND org_id IS NOT NULL AND app_id IS NOT NULL AND bundle_id IS NOT NULL AND channel_id IS NULL) OR
(scope_type = 'channel' AND org_id IS NOT NULL AND app_id IS NOT NULL AND bundle_id IS NULL AND channel_id IS NOT NULL)
)
);Important columns:
principal_type/principal_id: Who receives the role (user, group, apikey)role_id: Which role is assignedscope_type: At what level (org, app, channel, etc.)org_id/app_id/channel_id/bundle_id: Scope identifiersgranted_by: Who granted this role (audit)expires_at: Optional expiration dateis_direct: Iftrue, manually assigned; iffalse, inherited
Integrity constraints:
- SSD (Static Separation of Duty): A principal can only have one role per scope
- Example: User X cannot be both
org_adminANDorg_memberin the same org - Implemented via unique indexes on
(principal_type, principal_id, scope_type, {scope_id})
- Example: User X cannot be both
Indexes:
-- SSD enforcement
CREATE UNIQUE INDEX role_bindings_platform_scope_uniq
ON role_bindings (principal_type, principal_id, scope_type)
WHERE scope_type = 'platform';
CREATE UNIQUE INDEX role_bindings_org_scope_uniq
ON role_bindings (principal_type, principal_id, org_id, scope_type)
WHERE scope_type = 'org';
CREATE UNIQUE INDEX role_bindings_app_scope_uniq
ON role_bindings (principal_type, principal_id, app_id, scope_type)
WHERE scope_type = 'app';
CREATE UNIQUE INDEX role_bindings_bundle_scope_uniq
ON role_bindings (principal_type, principal_id, bundle_id, scope_type)
WHERE scope_type = 'bundle';
CREATE UNIQUE INDEX role_bindings_channel_scope_uniq
ON role_bindings (principal_type, principal_id, channel_id, scope_type)
WHERE scope_type = 'channel';
-- Performance
CREATE INDEX role_bindings_principal_scope_idx
ON role_bindings (principal_type, principal_id, scope_type, org_id, app_id, channel_id);Defines parent-child relationships between roles.
CREATE TABLE public.role_hierarchy (
parent_role_id uuid REFERENCES public.roles(id) ON DELETE CASCADE,
child_role_id uuid REFERENCES public.roles(id) ON DELETE CASCADE,
PRIMARY KEY (parent_role_id, child_role_id),
CHECK (parent_role_id IS DISTINCT FROM child_role_id)
);Usage:
- A parent role automatically inherits all permissions from its children
- Simplifies management:
org_admininherits from all app_* roles
Inheritance examples:
org_super_admin ──▶ org_admin ──▶ app_admin ──▶ app_developer ──▶ app_uploader ──▶ app_reader
│
├──▶ bundle_admin ──▶ bundle_reader
│
└──▶ channel_admin ──▶ channel_reader
Allows grouping users for simplified management.
CREATE TABLE public.groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
is_system boolean NOT NULL DEFAULT false,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT groups_org_name_unique UNIQUE (org_id, name)
);Usage:
- Create groups at org level (e.g., "Backend Team", "Admins")
- Assign a role to the group instead of individual users
- All group members automatically inherit the role
CREATE TABLE public.group_members (
group_id uuid REFERENCES public.groups(id) ON DELETE CASCADE,
user_id uuid REFERENCES public.users(id) ON DELETE CASCADE,
added_by uuid,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, user_id)
);Singleton table to globally enable RBAC.
CREATE TABLE public.rbac_settings (
id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1),
use_new_rbac boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);Usage:
- Single row with
id = 1 - If
use_new_rbac = true, RBAC enabled for ALL orgs (unless overridden at org level)
ALTER TABLE public.orgs
ADD COLUMN use_new_rbac boolean NOT NULL DEFAULT false;- Org-level flag to enable RBAC for a specific org
ALTER TABLE public.apikeys
ADD COLUMN rbac_id uuid DEFAULT gen_random_uuid() UNIQUE NOT NULL;- Stable UUID to reference API keys in
role_bindings
ALTER TABLE public.channels
ADD COLUMN rbac_id uuid DEFAULT gen_random_uuid() UNIQUE NOT NULL;- Stable UUID to reference channels in
role_bindings
ALTER TABLE public.apps
ADD CONSTRAINT apps_id_unique UNIQUE (id);apps.idwas already present but not unique; constraint added for RBAC
The system defines 13 predefined roles covering all hierarchy levels.
- Scope:
platform - Assignable: ❌ No (Capgo team only)
- Priority rank: 100
- Permissions: ALL platform permissions
- Usage: Capgo admins for maintenance, support, emergency operations
- Scope:
org - Assignable: ✅ Yes
- Priority rank: 95
- Permissions:
- Org: read, update_settings, read_members, invite_user, update_user_roles, read_billing, update_billing, read_invoices, read_audit, read_billing_audit
- App: read, update_settings, delete, read_bundles, upload_bundle, create_channel, read_channels, read_logs, manage_devices, read_devices, build_native, read_audit, update_user_roles
- Channel: read, update_settings, delete, read_history, promote_bundle, rollback_bundle, manage_forced_devices, read_forced_devices, read_audit
- Bundle: delete
- Usage: Organization owner, full access including billing and deletions
- Difference from org_admin: Can modify billing and delete apps
- Scope:
org - Assignable: ✅ Yes
- Priority rank: 90
- Permissions:
- Org: read, update_settings, read_members, invite_user, update_user_roles, read_billing, read_invoices, read_audit, read_billing_audit
- App: read, update_settings, read_bundles, upload_bundle, create_channel, read_channels, read_logs, manage_devices, read_devices, build_native, read_audit, update_user_roles
- Channel: read, update_settings, delete, read_history, promote_bundle, rollback_bundle, manage_forced_devices, read_forced_devices, read_audit
- Usage: Organization administrator, full management except billing and deletions
- Limitations: Cannot modify billing or delete apps
- Scope:
org - Assignable: ✅ Yes
- Priority rank: 80
- Permissions:
- Org: read, read_billing, update_billing, read_invoices, read_billing_audit
- Usage: Limited access to billing only (accounting, finance)
- Use case: Finance team that needs to manage payments without app access
- Scope:
org - Assignable: ✅ Yes
- Priority rank: 75
- Permissions:
- Org: read, read_members
- Usage: Basic org member (no app access by default)
- Use case: Users who only need org visibility; grant app access via app-scoped roles
- Scope:
app - Assignable: ✅ Yes
- Priority rank: 70
- Permissions:
- App: read, update_settings, read_bundles, upload_bundle, create_channel, read_channels, read_logs, manage_devices, read_devices, build_native, read_audit, update_user_roles
- Channel: read, update_settings, delete, read_history, promote_bundle, rollback_bundle, manage_forced_devices, read_forced_devices, read_audit
- Bundle: delete
- Usage: Full admin of a specific app (can delete channels)
- Inheritance: Inherits from app_developer, app_uploader, app_reader, bundle_admin, channel_admin
- Scope:
app - Assignable: ✅ Yes
- Priority rank: 68
- Permissions:
- App: read, read_bundles, upload_bundle, read_channels, read_logs, manage_devices, read_devices, build_native, read_audit
- Channel: read, update_settings, read_history, promote_bundle, rollback_bundle, manage_forced_devices, read_forced_devices, read_audit
- Usage: Developer with full access except deletions
- Limitations: Cannot create new channels or delete channels/bundles
- Inheritance: Inherits from app_uploader, app_reader
- Scope:
app - Assignable: ✅ Yes
- Priority rank: 66
- Permissions:
- App: read, read_bundles, upload_bundle, read_channels, read_logs, read_devices, read_audit
- Usage: CI/CD, bundle upload only
- Use case: API keys for continuous integration pipelines
- Inheritance: Inherits from app_reader
- Scope:
app - Assignable: ✅ Yes
- Priority rank: 65
- Permissions:
- App: read, read_bundles, read_channels, read_logs, read_devices, read_audit
- Usage: Read-only on a specific app
- Use case: Auditors, external stakeholders
- Scope:
channel - Assignable: ✅ Yes
- Priority rank: 60
- Permissions:
- Channel: read, update_settings, delete, read_history, promote_bundle, rollback_bundle, manage_forced_devices, read_forced_devices, read_audit
- Usage: Full admin of a specific channel
- Inheritance: Inherits from channel_reader
- Scope:
channel - Assignable: ✅ Yes
- Priority rank: 55
- Permissions:
- Channel: read, read_history, read_forced_devices, read_audit
- Usage: Read-only on a specific channel
- Scope:
bundle - Assignable: ✅ Yes
- Priority rank: 62
- Permissions:
- Bundle: read, update, delete
- Usage: Full management of a specific bundle
- Inheritance: Inherits from bundle_reader
- Scope:
bundle - Assignable: ✅ Yes
- Priority rank: 61
- Permissions:
- Bundle: read
- Usage: Read-only on a specific bundle
platform_super_admin (platform, rank 100)
│
└─▶ ALL permissions
org_super_admin (org, rank 95)
│
└─▶ org_admin (org, rank 90)
│
├─▶ app_admin (app, rank 70)
│ │
│ ├─▶ app_developer (app, rank 68)
│ │ │
│ │ └─▶ app_uploader (app, rank 66)
│ │ │
│ │ └─▶ app_reader (app, rank 65)
│ │
│ ├─▶ bundle_admin (bundle, rank 62)
│ │ │
│ │ └─▶ bundle_reader (bundle, rank 61)
│ │
│ └─▶ channel_admin (channel, rank 60)
│ │
│ └─▶ channel_reader (channel, rank 55)
│
└─▶ org_member (org, rank 75)
org_billing_admin (org, rank 80) [no inheritance]
The system defines 40+ atomic permissions organized by scope.
| Permission | Description | Roles with this permission |
|---|---|---|
org.read |
View organization info | org_super_admin, org_admin, org_billing_admin, org_member |
org.update_settings |
Modify org settings | org_super_admin, org_admin |
org.read_members |
View member list | org_super_admin, org_admin, org_member |
org.invite_user |
Invite members | org_super_admin, org_admin |
org.update_user_roles |
Manage member roles | org_super_admin, org_admin |
org.read_billing |
View billing info | org_super_admin, org_admin, org_billing_admin |
org.update_billing |
Modify billing | org_super_admin, org_billing_admin |
org.read_invoices |
View invoices | org_super_admin, org_admin, org_billing_admin |
org.read_audit |
View org audit logs | org_super_admin, org_admin |
org.read_billing_audit |
View billing audit | org_super_admin, org_admin, org_billing_admin |
| Permission | Description | Roles with this permission |
|---|---|---|
app.read |
View app info | All app_* roles, org_admin, org_super_admin |
app.update_settings |
Modify app settings | app_admin, org_admin, org_super_admin |
app.delete |
Delete app | org_super_admin only |
app.read_bundles |
View bundle metadata | app_admin, app_developer, app_uploader, app_reader, org_admin, org_super_admin |
app.list_bundles |
List bundles | None |
app.upload_bundle |
Upload bundles | app_admin, app_developer, app_uploader, org_admin, org_super_admin |
app.create_channel |
Create channels | app_admin, org_admin, org_super_admin |
app.read_channels |
View channels | app_admin, app_developer, app_uploader, app_reader, org_admin, org_super_admin |
app.list_channels |
List channels | None |
app.read_logs |
View logs | app_admin, app_developer, app_uploader, app_reader, org_admin, org_super_admin |
app.manage_devices |
Manage devices | app_admin, app_developer, org_admin, org_super_admin |
app.read_devices |
View devices | All app_* roles, org_admin, org_super_admin |
app.build_native |
Build native versions | app_admin, app_developer, org_admin, org_super_admin |
app.read_audit |
View app audit | All app_* roles, org_admin, org_super_admin |
app.update_user_roles |
Manage user roles for this app | app_admin, org_admin, org_super_admin |
Note: Bundle permissions use bundle scope with role_bindings.scope_type = 'bundle'. When p_bundle_id is provided, the resolver derives the app/org context from the bundle and includes bundle-scoped bindings where role_bindings.bundle_id matches p_bundle_id.
| Permission | Description | Roles with this permission |
|---|---|---|
bundle.read |
Read bundle metadata | bundle_admin, bundle_reader |
bundle.update |
Modify a bundle | bundle_admin |
bundle.delete |
Delete a bundle | bundle_admin, app_admin, org_admin, org_super_admin |
| Permission | Description | Roles with this permission |
|---|---|---|
channel.read |
View a channel | All channel_* roles, app_admin, app_developer, org_admin, org_super_admin |
channel.update_settings |
Modify channel settings | channel_admin, app_admin, app_developer, org_admin, org_super_admin |
channel.delete |
Delete a channel | channel_admin, app_admin, org_admin, org_super_admin |
channel.read_history |
View deployment history | All channel_* roles, app_admin, app_developer, org_admin, org_super_admin |
channel.promote_bundle |
Promote a bundle | channel_admin, app_admin, app_developer, org_admin, org_super_admin |
channel.rollback_bundle |
Rollback a bundle | channel_admin, app_admin, app_developer, org_admin, org_super_admin |
channel.manage_forced_devices |
Manage forced devices | channel_admin, app_admin, app_developer, org_admin, org_super_admin |
channel.read_forced_devices |
View forced devices | All channel_* roles, app_admin, app_developer, org_admin, org_super_admin |
channel.read_audit |
View channel audit | All channel_* roles, app_admin, app_developer, org_admin, org_super_admin |
Internal use only - Reserved for Capgo team.
| Permission | Description |
|---|---|
platform.impersonate_user |
Impersonate a user (support) |
platform.manage_orgs_any |
Manage any org |
platform.manage_apps_any |
Manage any app |
platform.manage_channels_any |
Manage any channel |
platform.run_maintenance_jobs |
Run maintenance jobs |
platform.delete_orphan_users |
Delete orphan users |
platform.read_all_audit |
View all audit logs |
platform.db_break_glass |
Break-glass DB access (emergencies) |
Determines if RBAC is enabled for a given organization.
CREATE OR REPLACE FUNCTION public.rbac_is_enabled_for_org(p_org_id uuid)
RETURNS boolean
LANGUAGE plpgsql
SET search_path = ''
AS $$
DECLARE
v_org_enabled boolean;
v_global_enabled boolean;
BEGIN
SELECT use_new_rbac INTO v_org_enabled FROM public.orgs WHERE id = p_org_id;
SELECT use_new_rbac INTO v_global_enabled FROM public.rbac_settings WHERE id = 1;
RETURN COALESCE(v_org_enabled, false) OR COALESCE(v_global_enabled, false);
END;
$$;Behavior:
- Returns
trueiforgs.use_new_rbac = trueORrbac_settings.use_new_rbac = true - Returns
falseby default (legacy mode)
Usage:
SELECT rbac_is_enabled_for_org('550e8400-e29b-41d4-a716-446655440000');
-- true if RBAC enabled, false otherwiseConverts a legacy min_right to an equivalent RBAC permission.
CREATE OR REPLACE FUNCTION public.rbac_permission_for_legacy(
p_min_right public.user_min_right,
p_scope text
) RETURNS text
LANGUAGE plpgsql
SET search_path = ''
IMMUTABLE AS $$
BEGIN
IF p_scope = 'org' THEN
IF p_min_right IN ('super_admin', 'admin', 'invite_super_admin', 'invite_admin') THEN
RETURN 'org.update_user_roles';
ELSIF p_min_right IN ('write', 'upload', 'invite_write', 'invite_upload') THEN
RETURN 'org.update_settings';
ELSE
RETURN 'org.read';
END IF;
ELSIF p_scope = 'app' THEN
IF p_min_right IN ('super_admin', 'admin', 'invite_super_admin', 'invite_admin', 'write', 'invite_write') THEN
RETURN 'app.update_settings';
ELSIF p_min_right IN ('upload', 'invite_upload') THEN
RETURN 'app.upload_bundle';
ELSE
RETURN 'app.read';
END IF;
ELSIF p_scope = 'channel' THEN
IF p_min_right IN ('super_admin', 'admin', 'invite_super_admin', 'invite_admin', 'write', 'invite_write') THEN
RETURN 'channel.update_settings';
ELSIF p_min_right IN ('upload', 'invite_upload') THEN
RETURN 'channel.promote_bundle';
ELSE
RETURN 'channel.read';
END IF;
END IF;
RETURN NULL;
END;
$$;Mapping table:
| Min Right (legacy) | Scope | RBAC Permission |
|---|---|---|
| super_admin, admin | org | org.update_user_roles |
| write, upload | org | org.update_settings |
| read | org | org.read |
| super_admin, admin, write | app | app.update_settings |
| upload | app | app.upload_bundle |
| read | app | app.read |
| super_admin, admin, write | channel | channel.update_settings |
| upload | channel | channel.promote_bundle |
| read | channel | channel.read |
Core function of the system that checks if a principal has a given permission.
CREATE OR REPLACE FUNCTION public.rbac_has_permission(
p_principal_type text, -- 'user' or 'apikey' or 'group'
p_principal_id uuid, -- Principal UUID
p_permission_key text, -- 'app.upload_bundle'
p_org_id uuid, -- Optional, derived if NULL
p_app_id character varying, -- App ID (string)
p_bundle_id bigint, -- Bundle ID (integer)
p_channel_id bigint -- Channel ID (integer)
) RETURNS boolean
LANGUAGE plpgsql
SET search_path = ''
SECURITY DEFINER AS $$
-- [See complete implementation in migration]
$$;Detailed algorithm:
-
Identifier resolution
- Converts
app_id(string) toapp.id(uuid) - Resolves
bundle_idto itsapp_idandorg_idwhen provided - Retrieves
channel.rbac_id(uuid) fromchannel_id(bigint) - Derives
org_idfrom app, bundle, or channel if not provided
- Converts
-
Scope catalog construction
scope_catalog: - platform (if applicable) - org (if org_id provided) - app (if app_id provided or derived from bundle) - bundle (if bundle_id provided; scope_type='bundle', scope_id=p_bundle_id) - channel (if channel_id provided)
-
Collect direct role_bindings
- Finds all principal bindings in applicable scopes
- Includes bundle-scoped bindings where
role_bindings.bundle_idmatchesp_bundle_id - Example: User X with
app_developeron app Y
-
Role hierarchy expansion
- Uses recursive CTE to follow
role_hierarchy - If User has
app_admin, automatically includesapp_developer,app_uploader,app_reader
- Uses recursive CTE to follow
-
Permission collection
- Joins with
role_permissionsto get all permissions from roles - Deduplicates permissions
- Joins with
-
Scope verification
- A permission given at org level applies to all apps in that org
- A permission given at app level applies to all channels and bundles in that app
- A permission given at bundle level applies only to that bundle
- Downward propagation only (no upward propagation)
-
Return
trueif permission found in collected setfalseotherwise
Propagation example:
User "Alice" has org_admin role in org "Acme Corp"
→ Alice has app.upload_bundle at org level
→ Alice can upload to ALL apps in "Acme Corp"
User "Bob" has app_developer role on app "com.example.mobile"
→ Bob has channel.promote_bundle at app level
→ Bob can promote on ALL channels of "com.example.mobile"
→ Bob CANNOT promote on other apps
Performance:
- Optimized indexes on
role_bindingsfor fast lookup - Recursive CTE limited in depth (max ~5-6 levels)
- Result caching at application level (backend)
Main function used by the backend to check permissions.
Frontend note: rbac_check_permission_direct() is backend/service-role only. Client code must use rbac_check_permission(), which enforces user context via auth.uid() automatically and does not accept a user id.
Access control (public wrapper / private inner function):
rbac_check_permission_direct(...)is private and must only be granted toservice_role(neverpublic/authenticated).rbac_check_permission(...)is the public wrapper for client usage; it enforces user context viaauth.uid().
Recommended GRANT statements:
REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, bigint, text) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, bigint, text) FROM anon;
REVOKE ALL ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, bigint, text) FROM authenticated;
GRANT EXECUTE ON FUNCTION public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, bigint, text) TO service_role;
GRANT EXECUTE ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint, bigint) TO authenticated;CREATE OR REPLACE FUNCTION public.rbac_check_permission_direct(
p_permission_key text, -- 'app.upload_bundle'
p_user_id uuid, -- User UUID
p_org_id uuid DEFAULT NULL, -- Optional
p_app_id varchar DEFAULT NULL, -- Optional
p_bundle_id bigint DEFAULT NULL, -- Optional
p_channel_id bigint DEFAULT NULL, -- Optional
p_apikey text DEFAULT NULL -- Optional (mutually exclusive with user_id)
) RETURNS boolean
LANGUAGE plpgsql
SET search_path = ''
SECURITY DEFINER AS $$
DECLARE
v_org_id uuid := p_org_id;
v_principal_type text;
v_principal_id uuid;
v_apikey_rbac_id uuid;
BEGIN
-- Determine principal
IF p_apikey IS NOT NULL THEN
SELECT rbac_id, owner_org INTO v_apikey_rbac_id, v_org_id
FROM public.apikeys
WHERE key = p_apikey;
IF v_apikey_rbac_id IS NULL THEN
RETURN false; -- Invalid API key
END IF;
v_principal_type := 'apikey';
v_principal_id := v_apikey_rbac_id;
ELSE
v_principal_type := 'user';
v_principal_id := p_user_id;
END IF;
-- Derive org_id if necessary
IF v_org_id IS NULL AND p_app_id IS NOT NULL THEN
SELECT owner_org INTO v_org_id FROM public.apps WHERE app_id = p_app_id LIMIT 1;
END IF;
IF v_org_id IS NULL AND p_channel_id IS NOT NULL THEN
SELECT owner_org INTO v_org_id FROM public.channels WHERE id = p_channel_id LIMIT 1;
END IF;
IF v_org_id IS NULL AND p_bundle_id IS NOT NULL THEN
SELECT apps.owner_org INTO v_org_id
FROM public.app_versions av
JOIN public.apps apps ON apps.id = av.app_id
WHERE av.id = p_bundle_id
LIMIT 1;
END IF;
-- Check if RBAC is enabled
IF rbac_is_enabled_for_org(v_org_id) THEN
-- New RBAC system
RETURN rbac_has_permission(
v_principal_type,
v_principal_id,
p_permission_key,
v_org_id,
p_app_id,
p_bundle_id,
p_channel_id
);
ELSE
-- Legacy system via check_min_rights
DECLARE
v_min_right public.user_min_right;
v_scope text;
BEGIN
-- Derive scope from parameters
IF p_channel_id IS NOT NULL THEN
v_scope := 'channel';
ELSIF p_bundle_id IS NOT NULL THEN
v_scope := 'bundle';
ELSIF p_app_id IS NOT NULL THEN
v_scope := 'app';
ELSE
v_scope := 'org';
END IF;
-- Map permission → legacy min_right
-- (inverse logic of rbac_permission_for_legacy)
IF p_permission_key LIKE 'org.%' THEN
IF p_permission_key IN ('org.update_user_roles', 'org.update_settings') THEN
v_min_right := 'admin';
ELSE
v_min_right := 'read';
END IF;
ELSIF p_permission_key LIKE 'app.%' THEN
IF p_permission_key IN ('app.delete', 'app.update_user_roles') THEN
v_min_right := 'admin';
ELSIF p_permission_key IN ('app.update_settings', 'app.create_channel') THEN
v_min_right := 'write';
ELSIF p_permission_key = 'app.upload_bundle' THEN
v_min_right := 'upload';
ELSE
v_min_right := 'read';
END IF;
ELSIF p_permission_key LIKE 'channel.%' THEN
IF p_permission_key IN ('channel.delete') THEN
v_min_right := 'admin';
ELSIF p_permission_key IN ('channel.update_settings') THEN
v_min_right := 'write';
ELSIF p_permission_key = 'channel.promote_bundle' THEN
v_min_right := 'upload';
ELSE
v_min_right := 'read';
END IF;
ELSE
v_min_right := 'admin'; -- Default, requires admin
END IF;
-- Call legacy function
RETURN check_min_rights_legacy(
v_min_right,
p_user_id,
v_org_id,
p_app_id,
p_apikey
);
END;
END IF;
END;
$$;Public wrapper (client-safe):
CREATE OR REPLACE FUNCTION public.rbac_check_permission(
p_permission_key text,
p_org_id uuid DEFAULT NULL,
p_app_id varchar DEFAULT NULL,
p_bundle_id bigint DEFAULT NULL,
p_channel_id bigint DEFAULT NULL
) RETURNS boolean
LANGUAGE plpgsql
SET search_path = ''
SECURITY DEFINER AS $$
BEGIN
IF auth.uid() IS NULL THEN
RETURN false;
END IF;
RETURN public.rbac_check_permission_direct(
p_permission_key,
auth.uid(),
p_org_id,
p_app_id,
p_bundle_id,
p_channel_id,
NULL
);
END;
$$;Advantages:
- ✅ Single source of truth for permission checking
- ✅ Automatic legacy/RBAC routing based on org flag
- ✅ Automatic
org_idderivation from app/channel/bundle - ✅ Support for API keys and users
- ✅ Graceful fallback to legacy if RBAC not enabled
Recommended usage:
-- Backend/service-role: user id is supplied explicitly
SELECT rbac_check_permission_direct(
'app.upload_bundle',
'123e4567-e89b-12d3-a456-426614174000'::uuid,
NULL, -- org_id will be derived
'com.example.app',
NULL, -- bundle_id
NULL -- channel_id
);
-- Backend/service-role: API key context
SELECT rbac_check_permission_direct(
'channel.promote_bundle',
NULL::uuid,
NULL,
NULL,
NULL, -- bundle_id
123, -- channel_id
'apikey-string'
);
-- Frontend/authenticated user: user id enforced by auth.uid()
SELECT rbac_check_permission(
'app.upload_bundle',
NULL, -- org_id (derived if app_id provided)
'com.example.app',
NULL, -- bundle_id
NULL -- channel_id
);The backend uses a TypeScript wrapper to simplify usage.
File: supabase/functions/_backend/utils/rbac.ts
import type { Context } from 'hono'
import type { MiddlewareKeyVariables } from './hono.ts'
/**
* Type-safe permission check
*/
export type Permission
= 'org.read' | 'org.update_settings' | 'org.invite_user' | ...
| 'app.read' | 'app.upload_bundle' | 'app.update_settings' | ...
| 'channel.promote_bundle' | 'channel.update_settings' | ...
| 'bundle.read' | 'bundle.delete'
| 'platform.impersonate_user' | ...
export interface PermissionScope {
orgId?: string
appId?: string
bundleId?: number
channelId?: number
}
/**
* Check if the authenticated principal has the given permission
*
* @param c Hono context (must have auth middleware)
* @param permission Permission key (e.g., 'app.upload_bundle')
* @param scope Scope identifiers (orgId, appId, bundleId, channelId)
* @returns Promise<boolean> - true if allowed, false otherwise
*/
export async function checkPermission(
c: Context<MiddlewareKeyVariables>,
permission: Permission,
scope: PermissionScope
): Promise<boolean> {
const requestId = c.get('requestId')
const auth = c.get('auth')
const apikey = c.get('apikey')
let pgClient
try {
const userId = auth?.userId || null
const apikeyString = apikey?.key || null
pgClient = getPgClient(c, true)
const result = await pgClient`
SELECT rbac_check_permission_direct(
${permission},
${userId}::uuid,
${scope.orgId || null}::uuid,
${scope.appId || null}::varchar,
${scope.bundleId || null}::bigint,
${scope.channelId || null}::bigint,
${apikeyString}
) as allowed
`
const allowed = result[0]?.allowed || false
cloudlog({
requestId,
message: `rbac_check: ${permission} ${allowed ? 'GRANTED' : 'DENIED'}`,
userId,
orgId: scope.orgId,
appId: scope.appId,
bundleId: scope.bundleId,
channelId: scope.channelId,
})
return allowed
} catch (error) {
cloudlogErr({
requestId,
message: `rbac_check_error: ${permission}`,
error,
})
return false // Fail closed
} finally {
if (pgClient) {
await closeClient(c, pgClient)
}
}
}
/**
* Require permission or throw 403
*/
export async function requirePermission(
c: Context<MiddlewareKeyVariables>,
permission: Permission,
scope: PermissionScope
): Promise<void> {
const allowed = await checkPermission(c, permission, scope)
if (!allowed) {
throw new HTTPException(403, {
message: `Access denied: missing permission ${permission}`,
})
}
}Usage in an endpoint:
import { checkPermission, requirePermission } from '../utils/rbac.ts'
import { createHono, simpleError } from '../utils/hono.ts'
const app = createHono()
// Example 1: Check with manual handling
app.post('/bundle/upload', middlewareKey(['all', 'write', 'upload']), async (c) => {
const body = await c.req.json()
// Check permission
if (!(await checkPermission(c, 'app.upload_bundle', { appId: body.app_id }))) {
return simpleError('app_access_denied', 'You cannot upload to this app')
}
// ... upload logic
return c.json({ success: true })
})
// Example 2: Require with automatic throw
app.delete('/app/:appId', middlewareAuth, async (c) => {
const appId = c.req.param('appId')
// Throw 403 if permission denied
await requirePermission(c, 'app.delete', { appId })
// ... deletion logic
return c.json({ success: true })
})
// Example 3: Channel-level permission (auto-derives appId and orgId)
app.post('/channel/:channelId/promote', middlewareKey(['all', 'upload']), async (c) => {
const channelId = Number.parseInt(c.req.param('channelId'))
await requirePermission(c, 'channel.promote_bundle', { channelId })
// ... promotion logic
return c.json({ success: true })
})Advantages:
- ✅ Type-safe: Strict
Permissiontype with autocomplete - ✅ Auto-routing: Legacy/RBAC based on org flag (transparent)
- ✅ Logging: Automatic logs in CloudFlare/Supabase
- ✅ Fail-closed: Returns
falseon error (secure) - ✅ Context-aware: Automatically uses
c.get('auth')andc.get('apikey')
/**
* Check if principal has ANY of the given permissions (OR logic)
*/
export async function hasAnyPermission(
c: Context<MiddlewareKeyVariables>,
permissions: Permission[],
scope: PermissionScope
): Promise<boolean> {
for (const perm of permissions) {
if (await checkPermission(c, perm, scope)) {
return true
}
}
return false
}
/**
* Check if principal has ALL of the given permissions (AND logic)
*/
export async function hasAllPermissions(
c: Context<MiddlewareKeyVariables>,
permissions: Permission[],
scope: PermissionScope
): Promise<boolean> {
for (const perm of permissions) {
if (!(await checkPermission(c, perm, scope))) {
return false
}
}
return true
}
/**
* Batch check multiple permissions
*/
export async function checkPermissionsBatch(
c: Context<MiddlewareKeyVariables>,
checks: Array<{ permission: Permission; scope: PermissionScope }>
): Promise<Map<Permission, boolean>> {
const results = new Map<Permission, boolean>()
for (const check of checks) {
const allowed = await checkPermission(c, check.permission, check.scope)
results.set(check.permission, allowed)
}
return results
}Use the canonical permissions tables in the Available Permissions section when wiring frontend checks, so new org/app/bundle/channel/platform permissions stay consistent across the UI.
File: src/stores/organization.ts
The organization store exposes helpers to check roles:
import { useOrganizationStore } from '~/stores/organization'
const orgStore = useOrganizationStore()
// Check if user has one of the required roles
if (orgStore.hasPermissionsInRole('admin', ['org_admin', 'org_super_admin'], orgId)) {
// Show admin UI
}
// Check at app level
if (orgStore.hasPermissionsInRole('write', ['app_developer', 'org_admin'], orgId, appId)) {
// Allow editing
}Behavior:
- If
use_new_rbacenabled: checks cachedrole_bindings - If legacy: checks
org_users.user_right
Limitations:
- ❌ Checks role names, not granular permissions
- ❌ Duplicated mapping logic frontend/backend
- ❌ Cache can be stale (requires manual refresh)
- ❌ Not flexible: access change = Vue code change
File: src/services/permissions.ts
The new service directly calls the backend to check permissions.
import { hasPermission, hasAnyPermission, hasAllPermissions } from '~/services/permissions'
// Simple permission check
const canUpload = await hasPermission('app.upload_bundle', { appId: 'com.example.app' })
if (canUpload) {
// Show upload button
}
// Check org permission
const canInvite = await hasPermission('org.invite_user', { orgId })
if (canInvite) {
// Show invite button
}
// Check channel permission (backend auto-derives appId and orgId)
const canPromote = await hasPermission('channel.promote_bundle', { channelId: 123 })
if (canPromote) {
// Allow promotion
}
// OR logic - at least one permission
const canAccessBilling = await hasAnyPermission(
['org.read_billing', 'org.update_billing'],
{ orgId }
)
// AND logic - all permissions
const canFullyManageApp = await hasAllPermissions(
['app.update_settings', 'app.delete', 'app.update_user_roles'],
{ appId }
)Implementation:
// src/services/permissions.ts
import { supabase } from '~/services/supabase'
export type Permission = // ... (same type as backend)
export interface PermissionScope {
orgId?: string
appId?: string
bundleId?: number
channelId?: number
}
/**
* Check if current user has permission
* Calls backend RPC (single source of truth, uses auth.uid())
*/
export async function hasPermission(
permission: Permission,
scope: PermissionScope
): Promise<boolean> {
try {
const { data, error: rpcError } = await supabase.rpc('rbac_check_permission', {
p_permission_key: permission,
p_org_id: scope.orgId || null,
p_app_id: scope.appId || null,
p_bundle_id: scope.bundleId || null,
p_channel_id: scope.channelId || null,
})
if (rpcError) {
console.error('[hasPermission] RPC error:', rpcError)
return false
}
return data === true
} catch (err) {
console.error('[hasPermission] Exception:', err)
return false
}
}
export async function hasAnyPermission(
permissions: Permission[],
scope: PermissionScope
): Promise<boolean> {
for (const perm of permissions) {
if (await hasPermission(perm, scope))
return true
}
return false
}
export async function hasAllPermissions(
permissions: Permission[],
scope: PermissionScope
): Promise<boolean> {
for (const perm of permissions) {
if (!(await hasPermission(perm, scope)))
return false
}
return true
}
/**
* Batch check for performance (multiple permissions at once)
*/
export async function checkPermissionsBatch(
checks: Array<{ permission: Permission; scope: PermissionScope }>
): Promise<Record<Permission, boolean>> {
const results: Record<Permission, boolean> = {} as Record<Permission, boolean>
// Note: Could be optimized with a batch RPC, but currently sequential.
for (const check of checks) {
results[check.permission] = await hasPermission(check.permission, check.scope)
}
return results
}Benefits:
- ✅ Single source of truth: calls the backend directly
- ✅ Auto-routing: legacy/RBAC handled server-side (transparent)
- ✅ Type-safe: strict
Permissiontype with autocomplete - ✅ Flexible: permission changes in DB, no frontend deploy needed
- ✅ Always up to date: no stale cache
- ✅ Audit: all checks logged server-side
Tradeoffs:
⚠️ Async (requiresawait)⚠️ Network overhead (negligible in practice)
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { hasPermission } from '~/services/permissions'
const props = defineProps<{
appId: string
}>()
const canUpload = ref(false)
const canDeleteApp = ref(false)
onMounted(async () => {
canUpload.value = await hasPermission('app.upload_bundle', { appId: props.appId })
canDeleteApp.value = await hasPermission('app.delete', { appId: props.appId })
})
</script>
<template>
<div>
<button v-if="canUpload" @click="uploadBundle">
Upload Bundle
</button>
<button v-if="canDeleteApp" @click="deleteApp" class="btn-danger">
Delete App
</button>
</div>
</template>Recommended pattern: Computed with cache:
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { hasPermission } from '~/services/permissions'
const props = defineProps<{ appId: string }>()
// Cache results
const permissions = ref<Record<string, boolean>>({})
watchEffect(async () => {
permissions.value = {
canUpload: await hasPermission('app.upload_bundle', { appId: props.appId }),
canUpdate: await hasPermission('app.update_settings', { appId: props.appId }),
canDelete: await hasPermission('app.delete', { appId: props.appId }),
}
})
const canUpload = computed(() => permissions.value.canUpload)
const canUpdate = computed(() => permissions.value.canUpdate)
const canDelete = computed(() => permissions.value.canDelete)
</script>
<template>
<div>
<button v-if="canUpload">Upload</button>
<button v-if="canUpdate">Update Settings</button>
<button v-if="canDelete">Delete</button>
</div>
</template>// src/composables/usePermissions.ts
import { ref, watch } from 'vue'
import { hasPermission, type Permission, type PermissionScope } from '~/services/permissions'
export function usePermissions(
permissionsToCheck: Permission[],
scope: PermissionScope
) {
const permissions = ref<Record<Permission, boolean>>({} as Record<Permission, boolean>)
const loading = ref(true)
async function checkAll() {
loading.value = true
const results: Record<Permission, boolean> = {} as Record<Permission, boolean>
for (const perm of permissionsToCheck) {
results[perm] = await hasPermission(perm, scope)
}
permissions.value = results
loading.value = false
}
// Re-check when scope changes
watch(() => scope, checkAll, { immediate: true, deep: true })
return {
permissions,
loading,
has: (perm: Permission) => permissions.value[perm] || false,
refresh: checkAll,
}
}Usage example:
<script setup lang="ts">
import { usePermissions } from '~/composables/usePermissions'
const props = defineProps<{ appId: string }>()
const { permissions, loading, has } = usePermissions(
['app.upload_bundle', 'app.update_settings', 'app.delete'],
{ appId: props.appId }
)
</script>
<template>
<div v-if="!loading">
<button v-if="has('app.upload_bundle')">Upload</button>
<button v-if="has('app.update_settings')">Settings</button>
<button v-if="has('app.delete')">Delete</button>
</div>
<div v-else>
Loading permissions...
</div>
</template>To facilitate migration, here's the mapping between current role checks and equivalent permissions:
| Current check | Equivalent permission | Notes |
|---|---|---|
hasPermissionsInRole('admin', ['org_admin', 'org_super_admin']) |
hasPermission('org.update_settings') |
Modify org settings |
hasPermissionsInRole('admin', ['org_super_admin']) |
hasPermission('org.update_user_roles') |
Manage member roles |
hasPermissionsInRole('admin', ['org_admin', 'org_billing_admin']) |
hasPermission('org.read_billing') |
Access billing |
| Current check | Equivalent permission | Notes |
|---|---|---|
hasPermissionsInRole('write', ['app_developer', 'org_admin']) |
hasPermission('app.update_settings') |
Modify app settings |
hasPermissionsInRole('upload', ['app_uploader', 'app_developer']) |
hasPermission('app.upload_bundle') |
Upload bundles |
hasPermissionsInRole('admin', ['org_super_admin']) |
hasPermission('app.delete') |
Delete app |
hasPermissionsInRole('admin', ['app_admin', 'org_admin']) |
hasPermission('app.update_user_roles') |
Manage app access |
| Current check | Equivalent permission | Notes |
|---|---|---|
hasPermissionsInRole('write', ['app_developer', 'org_admin']) |
hasPermission('channel.update_settings') |
Modify channel |
hasPermissionsInRole('upload', ['app_uploader']) |
hasPermission('channel.promote_bundle') |
Promote bundle |
| Current check | Equivalent permission | Notes |
|---|---|---|
hasPermissionsInRole('admin', ['org_admin', 'org_super_admin']) |
hasPermission('bundle.delete') |
Delete bundle |
SELECT rbac_is_enabled_for_org('123e4567-e89b-12d3-a456-426614174000');
-- true if RBAC enabled, false if legacySELECT
rb.id,
rb.principal_type,
r.name as role_name,
r.scope_type,
rb.scope_type as binding_scope,
o.name as org_name,
a.app_id as app_id,
c.name as channel_name,
rb.granted_at,
u.email as granted_by_email
FROM role_bindings rb
JOIN roles r ON rb.role_id = r.id
LEFT JOIN orgs o ON rb.org_id = o.id
LEFT JOIN apps a ON rb.app_id = a.id
LEFT JOIN channels c ON rb.channel_id = c.rbac_id
LEFT JOIN users u ON rb.granted_by = u.id
WHERE rb.principal_type = 'user'
AND rb.principal_id = '123e4567-e89b-12d3-a456-426614174000'::uuid
ORDER BY rb.granted_at DESC;SELECT
r.name as role_name,
r.scope_type as role_scope,
p.key as permission_key,
p.scope_type as permission_scope,
p.description
FROM role_permissions rp
JOIN roles r ON rp.role_id = r.id
JOIN permissions p ON rp.permission_id = p.id
WHERE r.name = 'org_admin'
ORDER BY p.key;-- Roles inherited by org_admin
WITH RECURSIVE role_tree AS (
-- Starting role
SELECT
id,
name,
scope_type,
0 as depth
FROM roles
WHERE name = 'org_admin'
UNION ALL
-- Child roles (recursive)
SELECT
r.id,
r.name,
r.scope_type,
rt.depth + 1
FROM roles r
JOIN role_hierarchy rh ON r.id = rh.child_role_id
JOIN role_tree rt ON rh.parent_role_id = rt.id
)
SELECT
REPEAT(' ', depth) || name as role_hierarchy,
scope_type,
depth
FROM role_tree
ORDER BY depth, name;-- Check if a user can upload to an app
SELECT rbac_check_permission_direct(
'app.upload_bundle', -- permission
'123e4567-e89b-12d3-a456-426614174000'::uuid, -- user_id
NULL::uuid, -- org_id (will be derived from app_id)
'com.example.app', -- app_id
NULL::bigint, -- bundle_id
NULL::bigint, -- channel_id
NULL -- apikey
) as has_permission;
-- Check if an API key can promote on a channel
SELECT rbac_check_permission_direct(
'channel.promote_bundle',
NULL::uuid, -- user_id (NULL because API key)
NULL::uuid,
NULL,
NULL::bigint, -- bundle_id
123, -- channel_id
'cap_1234567890abcdef' -- apikey
) as has_permission;SELECT
u.email,
u.id as user_id,
r.name as role_name,
rb.scope_type,
CASE rb.scope_type
WHEN 'org' THEN o.name
WHEN 'app' THEN a.app_id
WHEN 'channel' THEN c.name
ELSE 'N/A'
END as scope_name,
rb.granted_at,
granted_by_user.email as granted_by
FROM role_bindings rb
JOIN roles r ON rb.role_id = r.id
JOIN users u ON rb.principal_id = u.id
LEFT JOIN orgs o ON rb.org_id = o.id
LEFT JOIN apps a ON rb.app_id = a.id
LEFT JOIN channels c ON rb.channel_id = c.rbac_id
LEFT JOIN users granted_by_user ON rb.granted_by = granted_by_user.id
WHERE rb.principal_type = 'user'
AND rb.org_id = '123e4567-e89b-12d3-a456-426614174000'::uuid
ORDER BY u.email, rb.granted_at DESC;SELECT
granted_by_user.email as granter,
recipient_user.email as recipient,
r.name as role_granted,
rb.scope_type,
rb.granted_at,
rb.reason
FROM role_bindings rb
JOIN roles r ON rb.role_id = r.id
JOIN users granted_by_user ON rb.granted_by = granted_by_user.id
JOIN users recipient_user ON rb.principal_id = recipient_user.id
WHERE rb.org_id = '123e4567-e89b-12d3-a456-426614174000'::uuid
AND rb.granted_at > NOW() - INTERVAL '30 days'
ORDER BY rb.granted_at DESC;-- Permissions not granted to a role (example: org_member)
SELECT DISTINCT p.key, p.description
FROM permissions p
WHERE p.scope_type IN ('org', 'app', 'channel')
AND p.id NOT IN (
SELECT permission_id
FROM role_permissions rp
JOIN roles r ON rp.role_id = r.id
WHERE r.name = 'org_member'
)
ORDER BY p.key;Search patterns:
rbac_check: app.upload_bundle GRANTED
rbac_check: app.upload_bundle DENIED
RBAC_CHECK_PERM_DIRECT
RBAC_CHECK_PERM_NO_KEY
rbac_has_permission: checking permission
Log example:
{
"requestId": "req_abc123",
"message": "rbac_check: app.upload_bundle GRANTED",
"userId": "user-uuid",
"orgId": "123e4567-e89b-12d3-a456-426614174000",
"appId": "com.example.app",
"timestamp": "2026-01-08T10:30:00Z"
}// supabase/functions/_backend/utils/rbac.ts
// Uncomment these lines for verbose debug:
cloudlog({
requestId,
message: `rbac_has_permission: checking ${permission}`,
principal: { type: principalType, id: principalId },
scope: { orgId, appId, bundleId, channelId },
raw_result: result,
})// Enable console logs
const allowed = await hasPermission('app.upload_bundle', { appId })
// Search in console: [hasPermission] RPC errorBad:
const allowed = await check_min_rights_legacy('upload', userId, orgId, appId)Good:
const allowed = await checkPermission(c, 'app.upload_bundle', { appId })Reason: Automatic legacy/RBAC routing, structured logs, type-safety
Less good:
// Too broad
await checkPermission(c, 'app.update_settings', { appId })Better:
// Precise to the action
await checkPermission(c, 'app.upload_bundle', { appId })
await checkPermission(c, 'channel.promote_bundle', { channelId })
await checkPermission(c, 'bundle.delete', { appId, bundleId })Reason: Allows finer access control, facilitates audit
const allowed = await checkPermission(c, 'app.delete', { appId })
if (!allowed) {
cloudlog({
requestId: c.get('requestId'),
level: 'warn',
message: `Permission denied: app.delete`,
userId: c.get('auth')?.userId,
appId,
action: 'delete_app_denied',
})
return simpleError('access_denied', 'You cannot delete this app')
}Reason: Helps detect unauthorized access attempts, security audit
// Auto-throw 403 if permission denied
app.delete('/app/:appId', middlewareAuth, async (c) => {
const appId = c.req.param('appId')
await requirePermission(c, 'app.delete', { appId })
// ... deletion logic
// No need for manual check
})Reason: More concise code, consistent error handling
// If action concerns a channel, check at channel level
await checkPermission(c, 'channel.promote_bundle', { channelId })
// Not at app level (too broad)
await checkPermission(c, 'app.upload_bundle', { appId }) // ❌Reason: Respects principle of least privilege
Bad:
const allowed = await checkPermission(c, 'app.upload_bundle', { appId })
if (!allowed) {
// Generic error
return c.json({ error: 'Something went wrong' }, 500)
}Good:
const allowed = await checkPermission(c, 'app.upload_bundle', { appId })
if (!allowed) {
// Clear message
return c.json({
error: 'access_denied',
message: 'You do not have permission to upload bundles to this app',
required_permission: 'app.upload_bundle',
}, 403)
}Reason: Facilitates debugging for developers, clarity for users
Old (avoid):
if (orgStore.hasPermissionsInRole('admin', ['org_admin', 'org_super_admin'], orgId)) {
// Show UI
}New (recommended):
if (await hasPermission('org.update_settings', { orgId })) {
// Show UI
}Reason: Single source of truth (backend), type-safety, flexibility
Less good:
<button :disabled="!canUpload" @click="upload">
Upload Bundle
</button>Better:
<button v-if="canUpload" @click="upload">
Upload Bundle
</button>Reason: Better UX (no frustrating buttons), smaller attack surface
async function uploadBundle() {
// Re-check before critical action
if (!(await hasPermission('app.upload_bundle', { appId }))) {
showToast('You no longer have permission to upload', 'error')
return
}
// ... upload logic
}Reason: Permissions can change (revoked by admin), avoids race conditions
// Reusable composable
const { permissions, loading, has } = usePermissions(
['app.upload_bundle', 'app.update_settings', 'app.delete'],
{ appId }
)
// Simple usage in template
<button v-if="has('app.upload_bundle')">Upload</button>Reason: DRY, performance (batch checks), better readability
Bad:
async function deleteApp() {
if (!(await hasPermission('app.delete', { appId }))) {
// Silent fail
return
}
// ...
}Good:
async function deleteApp() {
if (!(await hasPermission('app.delete', { appId }))) {
showToast('You do not have permission to delete this app', 'error')
return
}
// ...
}Reason: Transparency for user, helps understand why action failed
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const canUpload = ref(false)
const loading = ref(true)
onMounted(async () => {
canUpload.value = await hasPermission('app.upload_bundle', { appId })
loading.value = false
})
</script>
<template>
<div v-if="!loading">
<button v-if="canUpload">Upload</button>
</div>
<div v-else>
<Spinner />
</div>
</template>Reason: Avoids content flash (CLS), better UX
- ✅ Always create a new migration for permission changes
- ✅ Never directly modify
role_permissionsin production - ✅ Test permission changes on dev environment first
- ✅ Document reasons for permission changes in migrations
| File | Description |
|---|---|
| supabase/migrations/20251222140030_rbac_system.sql | Main RBAC migration (tables + seed) |
| supabase/migrations/20260106133353_rbac_check_permission_direct.sql | rbac_check_permission_direct() function |
| supabase/functions/_backend/utils/rbac.ts | Backend TypeScript wrapper |
| src/services/permissions.ts | Frontend permissions service |
| src/stores/organization.ts | Organization store (legacy hasPermissionsInRole) |
20251222140030_rbac_system.sql- Complete RBAC system