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
| Role | Who | What they can do |
|---|---|---|
| owner | Created the workspace (or received transferred ownership) | Everything: billing, members, feature flags, org settings, delete org, transfer ownership |
| admin | Trusted team member | Same as owner except cannot delete org or transfer ownership |
| member | Standard team member | Full CRUD in CRM, Projects, Forms, Support. No org settings or billing. |
| viewer | Read-only stakeholder | View 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
| Action | Allowed 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 members | owner, 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.