The most common RBAC mistake is treating "admin" as a single role that grants everything. In practice, the tasks your org admin users need to perform are specific and bounded: invite members, manage billing, configure SSO. Treating all of those as a single undifferentiated "admin" flag means a compromised admin account, or a legitimate admin acting outside their authority, can take any action in the system. The principle of least privilege applies to admin roles the same way it applies to service accounts.
The problem with monolithic admin
When "org admin" means "can do everything in the org", you create several problems:
- A billing admin who needs to update a credit card can also delete all members, configure SSO, and export all user data — permissions they have no business need for.
- An admin account compromise gives the attacker the same broad access as the legitimate admin.
- Audit logs show "admin performed action" without clarity on whether the action was within the admin's intended scope.
- Enterprise customers' security teams reject your product because they cannot satisfy the principle of least privilege for their own users.
Decomposing admin into permission sets
Rather than a single admin role, define a set of admin permission groups that can be composed onto users independently:
-- Permission sets stored as an enum or set of boolean flags CREATE TABLE org_member_permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organizations(id), user_id UUID NOT NULL REFERENCES users(id), -- Member management can_invite_members BOOLEAN NOT NULL DEFAULT FALSE, can_remove_members BOOLEAN NOT NULL DEFAULT FALSE, can_change_roles BOOLEAN NOT NULL DEFAULT FALSE, -- Billing can_manage_billing BOOLEAN NOT NULL DEFAULT FALSE, can_view_invoices BOOLEAN NOT NULL DEFAULT FALSE, -- SSO and security can_configure_sso BOOLEAN NOT NULL DEFAULT FALSE, can_configure_mfa_policy BOOLEAN NOT NULL DEFAULT FALSE, can_view_audit_logs BOOLEAN NOT NULL DEFAULT FALSE, -- Data and export can_export_data BOOLEAN NOT NULL DEFAULT FALSE, can_delete_org BOOLEAN NOT NULL DEFAULT FALSE, UNIQUE(org_id, user_id), FOREIGN KEY (org_id, user_id) REFERENCES org_members(org_id, user_id) );
Alternatively, model these as named roles that are additive, not a single flag:
-- Role-based model: a user can hold multiple named roles
CREATE TABLE org_member_roles (
org_id UUID NOT NULL,
user_id UUID NOT NULL,
role TEXT NOT NULL CHECK (role IN (
'member',
'billing_admin',
'member_admin',
'security_admin',
'org_owner'
)),
granted_by UUID REFERENCES users(id),
granted_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (org_id, user_id, role)
);
-- Check permission in application code
async function canManageBilling(orgId, userId) {
const result = await db.query(
`SELECT 1 FROM org_member_roles
WHERE org_id = $1 AND user_id = $2
AND role IN ('billing_admin', 'org_owner')`,
[orgId, userId]
);
return result.rowCount > 0;
}
Per-tenant admin vs global admin
In multi-tenant SaaS, the org admin controls their own organization. A separate class of user — global admin or platform operator — can access any organization for support purposes. These must be strictly separated in both the data model and the permission checks. A per-tenant admin should never be able to elevate themselves to global admin through any user-facing API.
// Authorization middleware: never conflate org admin with global admin
async function authorize(req, action, resourceOrgId) {
const user = req.user; // from JWT
// Global admins (Bastionary staff) — checked separately
if (user.is_platform_admin && action.startsWith('platform:')) {
await recordAdminAction(user.id, action, resourceOrgId);
return true;
}
// Org-level check — user must be a member of the specific org
const orgId = resourceOrgId;
if (user.org_id !== orgId) {
return false; // cannot act on another org, even with admin role in own org
}
const hasPermission = await checkOrgPermission(user.id, orgId, action);
return hasPermission;
}
The org_owner special case
Every organization needs at least one user with full administrative authority — someone who can add/remove admins and in extremis delete the org. This is the org_owner role. It should be distinct from ordinary admin roles and come with additional safeguards:
- Only one or two users should hold this role (enforced by the application).
- Transferring ownership requires MFA re-authentication from the current owner.
- Removing the last owner is blocked — an org without an owner is an orphan.
- Owner-level actions (delete org, change SSO configuration) should require a re-authentication challenge even mid-session.
// Prevent org from becoming ownerless
async function removeOrgRole(orgId, targetUserId, roleToRemove) {
if (roleToRemove === 'org_owner') {
const ownerCount = await db.query(
`SELECT COUNT(*) FROM org_member_roles
WHERE org_id = $1 AND role = 'org_owner'`,
[orgId]
);
if (parseInt(ownerCount.rows[0].count) <= 1) {
throw new Error('Cannot remove the last org owner. Transfer ownership first.');
}
}
await db.query(
`DELETE FROM org_member_roles
WHERE org_id = $1 AND user_id = $2 AND role = $3`,
[orgId, targetUserId, roleToRemove]
);
}
Admin actions in JWT claims
Including admin permission sets in JWT claims enables downstream services to authorize without a database call. The challenge is keeping claims fresh when permissions change. Short token lifetimes (15 minutes) with refresh tokens work well here: permission changes take effect at the next refresh.
// JWT payload for an org admin
{
"sub": "user_01HXYZ",
"org": "org_ABC",
"roles": ["billing_admin", "member_admin"],
"permissions": {
"invite_members": true,
"manage_billing": true,
"configure_sso": false,
"export_data": false
},
"iat": 1652700000,
"exp": 1652700900 // 15-minute lifetime
}
Audit requirements for admin actions
Every admin action — role grants, member removals, SSO configuration changes, billing updates — must be recorded in the audit log with the actor, the target, the action, the before/after state, and the timestamp. This is both a security requirement and a practical necessity: when a customer's admin claims they did not change a setting, the audit log is your evidence.
Key fields for admin audit events: actor user ID, actor IP, actor MFA method, target resource type and ID, action name, previous value (for updates), new value, and the request correlation ID for cross-service tracing. Store audit logs in append-only storage with a separate retention period from other application logs — typically 2–7 years for compliance purposes.