Architecture

Roles & permissions

OpsDash uses a four-role RBAC system layered on top of subscription-based feature access. Roles are per-org — a user can be an admin in one workspace and a viewer in another.

The four roles

RoleWhoWhat they can do
ownerCreated the workspace (or received transferred ownership)Everything: billing, members, feature flags, org settings, delete org, transfer ownership
adminTrusted team memberSame as owner except cannot delete org or transfer ownership
memberStandard team memberFull CRUD in CRM, Projects, Forms, Support. No org settings or billing.
viewerRead-only stakeholderView all accessible modules. No create, edit, delete, or export.

The viewer role is available on every plate (platform:rbac-viewer has minPlate: 'free' in packages/config/src/defaults.ts). Custom per-org roles beyond the four built-ins are a separate, higher-rung feature (platform:rbac-custom-roles, Agency).

Action-level gating

ActionAllowed roles
Export (CSV, PDF)owner, admin, member (viewer blocked)
Billing (upgrade, manage subscription)owner, admin only
Feature flags (enable/disable, allowed roles)owner, admin only
Invite members, change roles, suspend membersowner, admin only
Create, edit, delete (CRM, Projects, etc.)owner, admin, member (viewer blocked)
API keys (create, revoke)owner, admin only

Restricting access per feature

Org admins can narrow access per feature via Settings → Admin → Feature Flags. Each feature has an Allowed Roles setting:

  • All roles (default): allowed_roles = null — every role with the feature enabled can access it
  • Specific roles: e.g. allowed_roles = ['owner', 'admin'] — only those roles can access

Server-side enforcement

Row-level security (RLS) enforces tenant isolation and admin-only config, but it does not distinguish member from viewer — that boundary lives entirely in the server-action layer. So every mutation must self-gate:

// Block the viewer role from create/edit/delete (2-arg: supabase + orgId)
const ctx = await withOrg(slug);
const check = await requireNonViewer(ctx.supabase, ctx.orgId);
if (!check.ok) return { success: false, error: check.error };

// Block non-admin roles from admin actions (owner/admin only)
const guard = await withOrgAdmin(slug);
if (!guard.ok) return { success: false, error: guard.error };

// Feature flag enabled + plan entitlement + per-feature allowed_roles
const req = await requireFeatureAccess(slug, 'forms');
if (!req.ok) return { success: false, error: 'Access denied' };

The per-feature Allowed Roles setting is enforced two ways: every module page runs ensureFeatureAccess in its layout (so a restricted role is redirected on navigation), and write-heavy modules (CRM, Projects, Invoicing, AP, Forms, Files) also call requireFeatureAccess inside their server actions to close the direct-call path. Support inherits the crm flag (it is a crm:support sub-feature, not a standalone module).

Platform admin

Users listed in PLATFORM_ADMIN_EMAILS have cross-org visibility via the /admin panel. They can view all orgs, users, and subscriptions. This is separate from the org-level admin role and bypasses RLS through the service role client.

Roles & permissions — OpsDash Docs | OpsDash