Designing admin roles that don't over-grant

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;
}
Global admin access to customer data must always be gated by a "reason" field and go through an approval workflow in regulated environments. Your customers' security teams will ask whether your support staff can access their data — the answer should be "yes, with MFA, time-limited, audited, and requiring an active support ticket as justification."

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.

← Back to blog Try Bastionary free →