Implementing Basic Permission Systems
IntroductionDev EnvironmentClientTransportServerAppDataMore

Implementing Basic Permission Systems

March 21, 2025

You know what a typical permission system failure looks like? Not someone deliberately granting too much access. It usually goes like this: a new endpoint, a quick deployment, "we'll add authorization in a moment" — and that moment never comes. Or the permission check is in handler A, but someone forgot it in handler B because they lost that one line while copying code. Or the default value for the role field in the database is null, which in practice grants access to everything.

Permission systems don't fail because of bad intentions. They fail when there's no structure to enforce correctness.

The Threat — Inconsistent or Missing Permission Checks

When a permission system has no clear structure, several categories of problems emerge:

Missing checks — an endpoint exists, requests reach it, but nobody asks "can this user do this?" All it takes is knowing the URL.

Overly broad default permissions — new accounts, new endpoints, new features start with access to everything, and restrictions are added reactively. Every new feature is open by default.

Inconsistent implementations — permission checks are written ad-hoc in each handler, with no central logic. One developer checks user.role === 'admin', another checks user.isAdmin, a third queries the database. Refactoring one place doesn't fix the whole thing.

No auditability — when permissions live in if/else conditions scattered through the code, you can't easily answer: "who has access to this operation?" Every security review means manually searching the entire codebase.

Consequences

An attacker or unauthorized user can: read or modify other users' data, perform operations reserved for higher roles (vertical privilege escalation), export data that isn't theirs. Without auditability — such violations can go unnoticed for a long time.


Core Concepts of a Permission System

Every permission system is built on three elements:

Resources — objects in the system whose access we control: documents, user profiles, reports, admin panels, payment operations.

Actions — what can be done with a resource: read, create, edit, delete, approve, share.

Permissions — the combination of an action and a resource:

view_document
edit_product
delete_user
run_report
approve_payment

From these elements you build a dictionary: what exists in the system and what can be done with it.


Connecting Permissions to Roles

Roles are sets of permissions corresponding to typical user responsibility scopes. Instead of assigning permissions directly to a user, you assign them a role:

viewer  →  [view_document, view_product]
editor  →  [view_document, create_document, edit_document, view_product, edit_product]
admin   →  [everything above + delete_*, manage_users, run_report]

Many-to-many relationship: a role has many permissions, a permission can belong to many roles.

When a new user joins, you assign them a role — not a list of permissions. When a role needs a new permission, you change the role definition once, and all users with that role get the change automatically.


Permission Check Models

In practice you have several patterns to choose from, depending on your needs:

Direct check — does the user have a specific permission?

can(currentUser, "edit_document")

Role check — does the user have a role with the required permission?

hasRole(currentUser, "editor") OR hasRole(currentUser, "admin")

Avoid this pattern if you can — a direct permission check is cleaner and doesn't require knowing which roles have which permissions at every point in the code.

Resource-level permission — can the user edit THIS document?

can(currentUser, "edit", documentId)

Useful when accessing resources owned by a user or with granular ACLs (access control lists).


Storing Permissions

Three typical approaches:

Database tables — most common. Tables for users, roles, permissions, join tables. Flexible, easy to audit, good for dynamic permissions.

Config files — for small applications with static roles. Simple implementation, limited flexibility.

In-memory structures — for applications requiring high performance; permissions loaded at startup, refreshed infrequently.


Implementation Patterns

Permission Guards — a central function that checks permissions before executing an action:

function guardAccess(user, permission, resource = null):
    if not can(user, permission, resource):
        raise AccessDeniedError("Insufficient permissions")
    // continue

// usage:
guardAccess(request.user, "delete_document", documentId)
deleteDocument(documentId)

One entry point — if the permission check logic needs to change, it changes in one place.

Permission Decorators / Middleware — declarative assignment of required permissions to an endpoint or function:

@requiresPermission("edit_document")
function editDocument(documentId):
    // function code
    // permission check happens automatically before execution

The check is inseparable from the endpoint — there's no way to "forget" to add authorization.

Role Inheritance — a role can inherit permissions from another role:

admin  inherits  editor
editor inherits  viewer

Reduces duplication when defining roles.


Why It Works

A permission system works when it is centralized and unambiguous. A few mechanisms:

Deny-by-default — a user starts with no permissions and receives them explicitly. This means a new feature, a new endpoint, a new account are closed by default. The only path to access is explicit granting. Models that allow by default and block exceptions are much harder to maintain correctly — every oversight opens a hole.

Central check function — one can() or guardAccess() instead of if/else conditions scattered across the entire codebase. One audit point; changing permission logic requires editing one place. Static code analysis can detect endpoints that don't call this function.

Separation of permissions from business logic — the handler doesn't know "is the user an admin?"; it knows "can the user delete_document?" That difference is what lets you change role structure without touching business logic.

Server-side enforcement — the check happens on the server, in an environment the client cannot modify. Hiding a button in the UI doesn't replace a permission check. A direct API call via curl or Postman bypasses the UI entirely — the server-side check remains.


Summary

A permission system isn't a feature — it's security infrastructure. A few principles to start with:

  • Deny by default — no explicitly granted permission = no access
  • Least privilege — grant only the permissions needed for a specific role
  • Central logic — one permission check point, not ad-hoc conditions in each handler
  • Regular audits — who has access to what; especially during employee departures and role changes

If you haven't seen how RBAC organizes roles and permissions, read Role-Based Access Control (RBAC) for Beginners — there you'll find the role model diagram. Common authorization implementation mistakes — including IDOR, missing checks, and insecure defaults — are covered in Common Authorization Pitfalls. And if you're looking for an explanation of what distinguishes authentication from authorization as a concept: Authentication vs. Authorization.


Sources: OWASP — Authorization Cheat Sheet, OWASP — Access Control Cheat Sheet