Security

rbac-implementation

$npx skills add blunotech-dev/agents --skill rbac-implementation

Implement or audit role-based access control (RBAC). Use when the user asks about roles, permissions, admin restrictions, route protection, permission checks, or validating existing RBAC for gaps.

References

2 files
namedescriptioncategory
rbac-implementationImplement or audit role-based access control (RBAC). Use when the user asks about roles, permissions, admin restrictions, route protection, permission checks, or validating existing RBAC for gaps.Security

RBAC Implementation

End-to-end role-based access control: schema design, permission matrix, server enforcement, data-layer scoping, and common mistakes.


Core Concepts

User → has Role(s) → Role has Permissions → Permission gates Resources + Actions
TermExample
Roleadmin, editor, viewer
Permissioninvoices:write, users:delete
Resourceinvoices, users, reports
Actionread, write, delete, publish

Flat roles (one role per user) — simple, works for most apps.
Permission-based (roles map to permission sets) — flexible, query once, check many gates.
Hierarchical rolesadmin inherits editor inherits viewer. Avoid unless necessary; adds complexity.


Schema Design

Flat roles (simplest)

ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'viewer'
  CHECK (role IN ('admin', 'editor', 'viewer'));

Permission-based (recommended for non-trivial apps)

CREATE TABLE roles (
  id   TEXT PRIMARY KEY,  -- 'admin', 'editor', 'viewer'
  name TEXT NOT NULL
);

CREATE TABLE permissions (
  id     TEXT PRIMARY KEY,  -- 'invoices:write'
  label  TEXT NOT NULL
);

CREATE TABLE role_permissions (
  role_id       TEXT REFERENCES roles(id),
  permission_id TEXT REFERENCES permissions(id),
  PRIMARY KEY (role_id, permission_id)
);

ALTER TABLE users ADD COLUMN role_id TEXT REFERENCES roles(id) DEFAULT 'viewer';

Permission matrix (define before coding)

invoices:readinvoices:writeusers:readusers:managereports:read
admin
editor
viewer

Write this matrix before writing any middleware. It's the source of truth.


Embedding Permissions in the JWT

Fetch permissions at login, encode in the token — avoids a DB hit on every request.

async function issueToken(user: User): Promise<string> {
  const permissions = await db.permissions
    .findMany({ where: { role_permissions: { some: { role_id: user.role_id } } } })
    .then(p => p.map(p => p.id));

  return jwt.sign(
    { sub: user.id, role: user.role_id, permissions },
    PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '15m' }
  );
}
// Token payload: { sub, role: 'editor', permissions: ['invoices:read', 'invoices:write', ...] }

Tradeoff: permissions baked into short-lived tokens are slightly stale between refreshes (up to 15 min). Acceptable for most apps. For instant revocation needs, check DB on every request instead.


Server-Side Enforcement

Middleware

// requireAuth — validates JWT, attaches req.user
export function requireAuth(req, res, next) {
  const token = req.cookies.access_token || req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).send();
  try {
    req.user = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] });
    next();
  } catch { res.status(401).send(); }
}

// requireRole — checks role string
export function requireRole(...roles: string[]) {
  return (req, res, next) =>
    roles.includes(req.user.role) ? next() : res.status(403).send();
}

// requirePermission — checks permission bit
export function requirePermission(permission: string) {
  return (req, res, next) =>
    req.user.permissions?.includes(permission) ? next() : res.status(403).send();
}

Route-level enforcement

// Role gate
app.delete('/admin/users/:id',  requireAuth, requireRole('admin'), deleteUser);

// Permission gate (preferred — decoupled from specific role names)
app.post('/invoices',           requireAuth, requirePermission('invoices:write'), createInvoice);
app.get('/invoices',            requireAuth, requirePermission('invoices:read'),  listInvoices);
app.get('/reports',             requireAuth, requirePermission('reports:read'),   getReports);

// Multiple roles
app.get('/dashboard/analytics', requireAuth, requireRole('admin', 'analyst'), getAnalytics);

Prefer permission checks over role checks in route handlers. Role names change; permissions are stable. Exception: coarse admin-only gates where a role check is clearer.


Data-Layer Enforcement

Route middleware stops unauthorized requests, but data-layer scoping ensures queries never return data the caller shouldn't see — a second line of defense.

// ❌ Middleware guards the route but DB fetches all rows
app.get('/invoices', requireAuth, requirePermission('invoices:read'), async (req, res) => {
  const invoices = await db.invoices.findMany(); // returns everyone's invoices
});

// ✅ Scope the query to the caller's org/ownership
app.get('/invoices', requireAuth, requirePermission('invoices:read'), async (req, res) => {
  const invoices = await db.invoices.findMany({
    where: {
      org_id: req.user.org_id,
      // admins see all; editors/viewers see only their own
      ...(req.user.role !== 'admin' && { owner_id: req.user.id }),
    }
  });
  res.json(invoices);
});

Client-Side Checks: UI Only, Never Security

Client-side permission checks are UI affordances — hide buttons, disable routes. They are never a security boundary.

// React — fine for hiding UI elements
function InvoiceActions({ invoice }) {
  const { permissions } = useAuth();
  return (
    <>
      {permissions.includes('invoices:write') && <EditButton />}
      {permissions.includes('invoices:delete') && <DeleteButton />}
    </>
  );
}

The corresponding API endpoints must still have server-side requirePermission checks. A user can bypass the React UI trivially with curl.


Common Mistakes

MistakeFix
Auth middleware on router, not individual routesExplicitly gate each sensitive route
Role check but no resource ownership checkCombine with owner_id / org_id scope (see broken-access-control)
Permissions checked client-side onlyEvery permission check must exist server-side
Role stored in request body or JWT claims writable by clientSource role from DB or server-signed JWT only
Super-admin bypass that skips all checksAdmins should pass the same middleware; just have broader permissions
Permission matrix lives only in developer's headDocument in code or DB — see schema above
Flat isAdmin boolean instead of permission setGrows unmanageable; use permission strings from the start

Role Assignment Safety

// ❌ User can escalate themselves
app.put('/users/me', requireAuth, async (req, res) => {
  await db.users.update(req.user.id, req.body); // role field writable
});

// ✅ Allowlist fields; role changes admin-only
app.put('/users/me', requireAuth, async (req, res) => {
  const { name, email } = req.body;
  await db.users.update(req.user.id, { name, email });
});

app.put('/users/:id/role', requireAuth, requireRole('admin'), async (req, res) => {
  const { role } = req.body;
  if (!VALID_ROLES.includes(role)) return res.status(400).send();
  await db.users.update(req.params.id, { role_id: role });
});

Audit Checklist

  • Permission matrix documented before implementation
  • Every non-public route has requireAuth + requireRole/requirePermission
  • Permissions embedded in JWT (or fetched from DB) — not derived from request
  • Data queries scoped to org/owner, not just role-gated at route level
  • Role assignment endpoint is admin-only; user profile update allowlists fields
  • No permission logic lives only in the client
  • Token refresh re-fetches permissions (role changes take effect within one token TTL)
  • VALID_ROLES constant used to validate role values before writing to DB

Reference Files

  • references/patterns.md — Permission inheritance, multi-role users, dynamic permissions, ReBAC pointer
  • references/stack-examples.md — Full RBAC setup for Node/Express + Prisma and Python/FastAPI