System DesignIntermediate 14 min Lesson 21 of 42

Authorization — What You May Do

Once you know who someone is, decide what they may do. Roles, permissions, ownership checks and the mistakes that leak data.

System Design · Lesson 21 of 42
0/42 done(0%)

What is it? #

Authorization decides what an authenticated user is allowed to do. Authentication proves identity; authorization enforces limits.

The simplest model is roles: admin, editor, viewer. Each role carries a set of permissions, and each endpoint requires one.

Roles alone are rarely enough. Most real systems also need ownership checks — a user may edit their own order, not everyone's — and often resource-level rules.

The most damaging bug in this area is checking the role but forgetting the ownership check, which lets any logged-in user read other people's data by changing an ID in the URL.

Think of it like this #

A hotel keycard. Every guest has one, which proves they are a guest. It opens their own room and the gym, but not other rooms and not the staff office.

A card that opened every room would be worse than no card at all — everyone would assume they were safe when they were not.

Simple example #

An invoices endpoint. An admin may read any invoice. A regular user may read only their own. The role check alone is not enough, because the request includes an invoice ID the user controls.

Code #

PYTHON
from enum import Enum
from functools import wraps


class Permission(str, Enum):
    INVOICE_READ_ANY = "invoice:read:any"
    INVOICE_READ_OWN = "invoice:read:own"
    INVOICE_DELETE = "invoice:delete"
    USER_MANAGE = "user:manage"


ROLE_PERMISSIONS = {
    "admin":  {Permission.INVOICE_READ_ANY, Permission.INVOICE_DELETE, Permission.USER_MANAGE},
    "staff":  {Permission.INVOICE_READ_ANY},
    "customer": {Permission.INVOICE_READ_OWN},
}


def permissions_for(user) -> set[Permission]:
    return ROLE_PERMISSIONS.get(user.role, set())


def requires(permission: Permission):
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if permission not in permissions_for(user):
                raise PermissionError("not allowed")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator


# Role check AND ownership check — both are needed
def get_invoice(user, invoice_id: int):
    invoice = invoices.find(invoice_id)
    if invoice is None:
        raise NotFound()

    perms = permissions_for(user)
    if Permission.INVOICE_READ_ANY in perms:
        return invoice
    if Permission.INVOICE_READ_OWN in perms and invoice.customer_id == user.id:
        return invoice

    # Return 404, not 403: do not confirm that this invoice exists
    raise NotFound()


@requires(Permission.INVOICE_DELETE)
def delete_invoice(user, invoice_id: int):
    invoices.delete(invoice_id)
SQL
-- Better still: scope the query itself, so the check cannot be forgotten
SELECT * FROM invoices
WHERE id = :invoice_id
  AND (:is_staff OR customer_id = :user_id);
TEXT
Models, from simple to flexible

role-based (RBAC)        user has roles, roles have permissions
attribute-based (ABAC)   rules over attributes: department, region, amount
ownership / relationship "is this user the owner, member or manager of it?"
policy engine            centralised rules, useful in large systems

Most applications need RBAC plus ownership. Start there.

How it works #

Permissions are named as strings rather than checked by role directly. Checking user.role == "admin" scatters role names through your code; checking a permission means adding a role later is a configuration change.

The requires decorator handles the simple case: does this user hold this permission at all.

get_invoice shows the case that actually matters. Staff hold read:any and get the invoice. Customers hold read:own and get it only when the invoice belongs to them. Without that second condition, any customer could read any invoice by changing the ID — the vulnerability known as insecure direct object reference.

Raising NotFound rather than PermissionError for an unauthorised read is deliberate. A 403 confirms the resource exists, which leaks information. For data the user should not know about, 404 is the safer answer.

The SQL version is the most robust approach. Putting the ownership condition into the query means a developer cannot forget the check, because the row is never fetched in the first place. Some databases go further with row-level security policies.

Authorization must be enforced on the server for every request. Hiding a button in the interface is a usability improvement, not a security control.

Real-world use #

Broken access control is consistently ranked as the most common serious web vulnerability, and it is almost always the missing ownership check rather than a missing login.

Multi-tenant systems make it sharper still. Every query must be scoped by tenant, and the standard defence is enforcing that at the data access layer rather than trusting each endpoint.

Admin interfaces are a frequent weak point, because they are built quickly and assumed to be behind a login. Role checks belong there as much as anywhere.

Tests are the practical safeguard. For each endpoint, write a test asserting that another user's ID returns 404, and that a lower-privileged role is refused. Those tests catch regressions that code review misses.

Common mistakes #

  • Checking the role but not whether the user owns the resource.
  • Enforcing permissions only in the user interface.
  • Returning 403 for resources the user should not know exist, leaking their existence.
  • Hardcoding role names throughout the codebase instead of named permissions.
  • Forgetting tenant scoping in one query in a multi-tenant system.

Practice #

Write an endpoint returning a document by ID with three rules: admins see all, members see documents in their team, everyone else gets 404. Then write three tests — admin, member with a foreign document, and a stranger — and confirm the responses.

Quick quiz

  1. 1. What is the difference between authentication and authorization?

  2. 2. What is the most common serious access control bug?

  3. 3. Why return 404 instead of 403 for some unauthorised reads?

  4. 4. Why scope the ownership condition inside the SQL query?

  5. 5. Is hiding a button in the UI a security control?

Summary

  • Authorization decides what an authenticated user may do.
  • Roles plus ownership checks cover most real requirements.
  • The missing ownership check is the most common serious vulnerability.
  • Scope queries by user or tenant so the check cannot be forgotten.
  • Enforce on the server and write tests for the refusal cases.