Authorization, Access Control, and Multi-Tenancy
7.1 Access-control models
RBAC: permissions grouped by role.
ABAC: decisions based on subject, resource, action, and environmental attributes.
ReBAC: access derived from relationships, such as organization membership or document sharing.
ACL: per-resource list of principals and permissions.
Capability-based security: possession of a narrowly scoped capability grants defined authority; capability leakage must be considered.
A real system may combine models. Keep the policy understandable and testable.
7.2 Deny by default and check every resource
The exact HTTP response is a product/security decision; consistency and avoiding unnecessary resource-existence disclosure matter. The important control is the server-side authorization decision.
The decision as a flow Text equivalent: an unauthenticated request stops first; the resource is loaded within the caller's tenant so cross-tenant objects are simply not found; the policy then decides the specific action; and the response includes only the fields that principal may see. Any path that cannot reach a "yes" ends in denial.
A small policy with its test matrix Output (CPython 3.13.1):
Every row is a test: the owner may read but not update, another student is denied, and an administrator from a different tenant is denied everything — that is the cross-tenant case teams most often forget. delete is denied for everyone because no rule grants it.
principal = authenticate(request)
resource = load_resource(request.resource_id)
if principal is missing:
deny(401)
if not policy.allows(principal, "read", resource):
deny(403) # Or a deliberate non-disclosing 404 policy
return only_fields_allowed_for(principal, resource)flowchart TD
Q[Request for resource R, action A] --> AUTH{Authenticated principal?}
AUTH -->|no| D401[Deny: 401]
AUTH -->|yes| LOAD[Load R, scoped to the principal's tenant]
LOAD --> FOUND{Found in tenant?}
FOUND -->|no| D404[Deny: 404]
FOUND -->|yes| POL{Policy allows A on R?}
POL -->|no| D403[Deny: 403 or 404 by policy]
POL -->|yes| OUT[Return only permitted fields]from dataclasses import dataclass
@dataclass(frozen=True)
class Principal:
user_id: str
tenant: str
role: str # student | instructor | course_admin
@dataclass(frozen=True)
class Grade:
student_id: str
course_instructor: str
tenant: str
def allows(p: Principal, action: str, g: Grade) -> bool:
if p.tenant != g.tenant:
return False # tenant isolation first
if action == "read":
return p.user_id == g.student_id or p.user_id == g.course_instructor or p.role == "course_admin"
if action == "update":
return p.user_id == g.course_instructor or p.role == "course_admin"
return False # deny by default
grade = Grade(student_id="s1", course_instructor="i1", tenant="uni-a")
people = {
"owner student": Principal("s1", "uni-a", "student"),
"other student": Principal("s2", "uni-a", "student"),
"instructor": Principal("i1", "uni-a", "instructor"),
"admin, other tenant": Principal("a9", "uni-b", "course_admin"),
}
for name, p in people.items():
row = " ".join(f"{a}={'allow' if allows(p, a, grade) else 'deny '}" for a in ("read", "
…owner student read=allow update=deny delete=deny
other student read=deny update=deny delete=deny
instructor read=allow update=allow delete=deny
admin, other tenant read=deny update=deny delete=deny7.3 Broken object-level authorization (BOLA/IDOR)
An unpredictable identifier is not authorization. Every read, update, delete, export, and action must check permission for the specific object. Test cross-user and cross-tenant access.
7.4 Multi-tenant isolation
Derive tenant context from a trusted authenticated identity or verified membership.
Do not trust a client-supplied tenant ID without checking membership and permission.
Include tenant scoping in every relevant data-access path.
Consider database row-level security where suitable.
Isolate object storage, caches, queues, search indexes, and background jobs.
Test cross-tenant leakage in exports, analytics, logs, and error paths.
Ensure caches include tenant and authorization context in keys when necessary.
7.5 Privilege escalation and mass assignment
Avoid automatically binding arbitrary client fields to internal models. Explicitly select fields allowed for the operation. Never let a profile-update endpoint silently accept role, isAdmin, tenantId, or billing/security flags unless a separately authorized operation is designed for them.
Explicit field allowlists Output (CPython 3.13.1): Rejecting unknown fields (rather than silently dropping them) also surfaces client bugs early. Role changes belong to a separate, separately authorized operation.
ALLOWED_PROFILE_FIELDS = {"display_name", "bio", "timezone"}
def apply_profile_update(user: dict, body: dict) -> dict:
unknown = set(body) - ALLOWED_PROFILE_FIELDS
if unknown:
raise ValueError(f"fields not allowed: {sorted(unknown)}")
return {**user, **body}
user = {"id": 7, "display_name": "Ana", "bio": "", "timezone": "UTC", "role": "student"}
print(apply_profile_update(user, {"bio": "Learning AppSec"}))
try:
apply_profile_update(user, {"bio": "hi", "role": "admin"})
except ValueError as error:
print("rejected:", error){'id': 7, 'display_name': 'Ana', 'bio': 'Learning AppSec', 'timezone': 'UTC', 'role': 'student'}
rejected: fields not allowed: ['role']7.6 Authorization testing matrix
Anonymous vs authenticated Owner vs non-owner Same tenant vs different tenant Read vs write vs delete vs export Ordinary vs privileged roles Active vs disabled accounts Revoked membership and changed roles Background job and administrative paths
Exercise
Create an access-control matrix for Student, Instructor, Course Admin, and Platform Admin. Include courses, lessons, grades, enrollment, and account management.
Quiz
? Document IDs are random UUIDs. Is object-level authorization still needed?
+ Yes — IDs leak through URLs, logs, shares and referrers; unpredictability is not permission. :: Every access must still be checked.
- No — nobody can guess a UUID. :: Guessing is only one way to obtain an ID.
- Only for write operations. :: Reads can disclose data just as harmfully.
- Only for administrators. :: Every principal's access needs checking.
? Where should the tenant for a request come from?
- A `tenantId` query parameter :: Client-supplied and forgeable.
+ The authenticated identity or a verified membership lookup :: The server derives it from trusted state.
- The subdomain alone, without checking membership :: The user may not belong to that tenant.
- The `Referer` header :: Client-controlled and optional.
? A profile update endpoint copies every JSON field onto the user record. What is the risk? (select all that apply)
+ A user can set `role` or `isAdmin`. :: Mass assignment escalates privilege.
+ A user can change `tenantId`. :: That breaks tenant isolation.
- It makes the endpoint slower. :: Performance is not the concern.
+ Future fields added to the model become writable automatically
…