In a multi-tenant ERP system, a poorly designed permission model can lose customer trust in five minutes. The Netorigo Admin RBAC module was built on two years of customer feedback. Here is the structure and the trade-offs.
14 default roles
The platform creates 14 roles for every new tenant:
- super_admin - Netorigo-internal engineers only (see below).
- tenant_admin - the tenant owner, sees and changes everything inside the tenant.
- catalog_manager - catalog module only (products, categories, prices).
- order_manager - order lifecycle, refunds, returns.
- finance_admin - billing, payments, ledgers (delegates to the Finance app).
- finance_viewer - read only.
- logistics_admin - warehouse management, carriers (Logistics app).
- logistics_picker - mobile-first, only order-pick + scan.
- support_agent - customer tickets, order read + comment.
- content_editor - blog, pages, CMS.
- marketing_manager - campaigns, promotions, email templates.
- analytics_viewer - dashboards, reports.
- integration_developer - API keys, webhooks, technical integrations.
- custom_role - template, the tenant_admin clones and freely edits.
Permission keys
Every permission is identified by a <module>.<resource>.<action> triplet. Examples:
catalog.product.readcatalog.product.writecatalog.product.deletefinance.invoice.viewfinance.invoice.createlogistics.shipment.scantenant.user.invite
184 keys total at the moment. On the backend every controller method is decorated with @RequiresPermission('catalog.product.write') and a NestJS guard checks the request against the session's role-derived keys.
The matrix UI
/admin/roles/[roleId] is a large table: rows = roles (14 defaults + customs), columns = modules (catalog, finance, logistics, ...). Opening a cell pops up a modal where you set the module's resources to allow/deny/inherit:
- allow - explicit grant (green check)
- deny - explicit denial (red X), overrides allow when the role inherits from multiple sources
- inherit - default, inherits from the parent
The tristate is needed for inheritance: a custom_role might start as a clone of catalog_manager, then the operator explicitly denies one specific permission without disturbing the rest.
super_admin stays hardcoded
super_admin cannot be created or modified from the UI. The user record exists in the database (user.is_super_admin = true), but the user management page doesn't show super_admin in the role dropdown and refuses any escalation.
Our CTO insisted on this after one security audit: if a misbehaving SQL injection could let a tenant_admin promote themselves to super_admin, multi-tenant isolation would collapse. So super_admin can only be set via direct psql writes, and the audit log records every such change in a separate append-only super_admin_grants table.
Delegate-only mode
Sometimes a role should only activate when a higher role explicitly delegates it to a specific user. For example, a finance_viewer user may only see invoices for 3 customers named by the CFO, not all of them.
The delegate-only flag is at the role level: role.delegation_required = true. When true, the guard doesn't check role-derived keys but a specific delegation record on the user (user_resource_delegation table, each row is a user_id + resource_type + resource_id triplet, e.g. customer:12345).
Audit-friendly: every change writes an audit_events row
Every RBAC change (role assignment, permission flip, delegation grant) writes one audit_events row with a diff: who, what, when, before, after. We cover this in detail in article #4 (Operator audit log).