Free lesson · GenAI Platform Engineering

Design RBAC model with roles, permissions, and scopes

Define the permission model with platform-admin, team-admin, developer, and viewer roles. Map each role to specific API actions and resource scopes.

Course: AI Developer Platform Engineering · Chapter 7 · RBAC & Access Control

Free to read — no subscription required.

Introduction

Engineers often inherit access systems where every team member shares the same permissions—until a junior developer accidentally deletes a production model endpoint or a billing dashboard leaks to contractors who have no business seeing it. The problem is that ad-hoc permission grants accumulate faster than they are reviewed, and without a deliberate structure the blast radius of any single over-privileged account is the entire platform. This lesson teaches you to design a layered RBAC model built around four explicit roles—platform-admin, team-admin, developer, and viewer—each bound to a resource scope. You'll define permissions as typed verb-resource pairs, encode role inheritance in Python dataclasses, and assemble the result into enforceable policy objects your middleware can evaluate on every API request.

Key Terminology

  • Permission — a frozen, hashable pair of an Action verb (such as DEPLOY or MANAGE_BILLING) and a ResourceType (such as MODEL or BILLING), encoded as a @dataclass(frozen=True) so instances can live in Python set objects and duplicate definitions collapse at import time rather than silently diverging at runtime.
  • ResourceScope — a hierarchical boundary with four levels ("global", "organization", "team", "project") that constrains where a permission applies; its contains() method determines whether one scope subsumes another, so a global-scope policy automatically covers every organization, team, and project boundary beneath it.
  • Role — a named container holding a direct permissions set and an optional inherits_from parent reference; calling all_permissions() traverses the ancestor chain and returns the transitive union of every permission defined at or above that role in the hierarchy.
  • Role Inheritance — the mechanism by which a child role's effective permissions are the union of its own permissions set and all permissions from its ancestor chain, enforcing a strict superset relationship: platform-adminteam-admindeveloperviewer at every link.
  • RBACPolicy — the enforceable artifact your middleware evaluates on each API request, binding a principal_id, a Role, and a ResourceScope into the single declarative unit that answers "who may do what, and where."
  • Principal — any entity that can request access (a human user, service account, or CI/CD identity), identified in an RBACPolicy by its principal_id; because the RBAC model binds capabilities to roles rather than to principals directly, revoking access means removing the policy binding rather than hunting down individual permission grants scattered across the system.

Concepts

Permissions as Typed Verb-Resource Pairs

The atomic unit of access control in this model is always two-dimensional: what action applied to what kind of resource. A flat string like "models:deploy" captures the idea but is fragile — a typo silently creates a permission token that matches nothing, and nothing stops two developers from spelling the same capability two different ways. Encoding both dimensions as enums (Action and ResourceType) means the valid vocabulary is declared in one place and every reference is checked by the interpreter. Combining them into a @dataclass(frozen=True) makes each Permission instance hashable, so the full permission set for a role can be stored as a Python set: duplicate definitions collapse automatically, and set intersection and union operations across roles are native operations rather than manual loops (see Code Walkthrough).

Scope as a Containment Hierarchy

Every permission needs a "where" dimension. A developer who can create models inside Team A should not automatically be able to do so in Team B, even though both belong to the same organization. ResourceScope encodes four levels of hierarchy — "global", "organization", "team", and "project" — and its contains() method makes the containment relationship explicit: a broader scope returns True for any narrower scope nested beneath it. A platform-admin bound at "global" therefore needs exactly one policy to govern the entire platform, while a team-admin requires a separate policy per team. Crucially, scope comparison in middleware reduces to a single scope.contains(request_scope) call rather than a chain of ad-hoc conditional logic, which means the boundary rules are enforced consistently wherever policies are evaluated.

Role Inheritance as Transitive Set Union

The four-role hierarchy is structured so that every role's effective permission set is a strict superset of the role immediately below it — platform-admin holds everything team-admin holds, team-admin holds everything developer holds, and so on down to viewer. Role.all_permissions() implements this by recursively unioning the role's own permissions set with the complete permission set of its inherits_from ancestor. The practical consequence is a blast-radius guarantee: no lower-privilege role can silently acquire a capability its parent lacks. If a refactor adds Action.DEPLOY to developer but forgets to add it to team-admin, the hierarchy's superset invariant is broken and a team-admin unexpectedly lacks a permission a developer holds. Catching that break requires checking all_permissions() across the chain — which is exactly what the Code Walkthrough's verification exercise asks you to do with a developer pointing to a viewer parent (see Code Walkthrough).

Code Walkthrough

Now that you understand the core RBAC building blocks—roles, permissions, resource scopes, and policies—here is how they connect as a concrete four-role hierarchy and typed Python structures.

Four-Role Hierarchy

The diagram below shows how permissions cascade from platform-admin (global scope) through team-admin and developer down to viewer (read-only):

Loading diagram...

Each arrow is labeled to show whether the child inherits all of its parent's permissions or only read-level permissions. platform-admin connects exclusively to Billing & Quotas and Users & Roles, reflecting its global governance scope. viewer connects only to Models & Endpoints and Datasets & Artifacts—no write, deploy, pipeline, or administrative capabilities.

Modeling the Structures in Python

The following code encodes the hierarchy as Python dataclasses and enums. Action enumerates every verb the platform supports. ResourceType enumerates the object categories those verbs apply to. A Permission is a frozen pairing of one action and one resource type—frozen=True makes it hashable so permissions can be stored in sets and duplicate definitions are caught at import time rather than at runtime. ResourceScope encodes the hierarchical boundary and its contains method determines whether one scope subsumes another. Role holds a permission set and an optional parent reference; all_permissions() walks the inheritance chain and unions every permission transitively. RBACPolicy is the enforceable artifact: it binds a principal, a role, and a scope into the single unit your middleware evaluates on each request.

Code snippetpython
1from dataclasses import dataclass, field 2from enum import Enum 3from typing import Optional 4 5class Action(Enum): 6 CREATE = "create" 7 READ = "read" 8 UPDATE = "update" 9 DELETE = "delete" 10 DEPLOY = "deploy" 11 MANAGE_MEMBERS = "manage_members" 12 MANAGE_BILLING = "manage_billing" 13 VIEW_AUDIT_LOG = "view_audit_log" 14 15class ResourceType(Enum): 16 MODEL = "model" 17 DATASET = "dataset" 18 PIPELINE = "pipeline" 19 DEPLOYMENT = "deployment" 20 TEAM = "team" 21 BILLING = "billing" 22 AUDIT_LOG = "audit_log" 23 24@dataclass(frozen=True) 25class Permission: 26 action: Action 27 resource_type: ResourceType 28 description: str = "" 29 30@dataclass(frozen=True) 31class ResourceScope: 32 level: str # "global", "organization", "team", "project" 33 org_id: Optional[str] = None 34 team_id: Optional[str] = None 35 project_id: Optional[str] = None 36 37 def contains(self, other: "ResourceScope") -> bool: 38 if self.level == "global": 39 return True 40 if self.level == "organization": 41 return other.org_id == self.org_id 42 if self.level == "team": 43 return other.org_id == self.org_id and other.team_id == self.team_id 44 if self.level == "project": 45 return (other.org_id == self.org_id 46 and other.team_id == self.team_id 47 and other.project_id == self.project_id) 48 return False 49 50@dataclass 51class Role: 52 name: str 53 permissions: set[Permission] = field(default_factory=set) 54 inherits_from: Optional["Role"] = None 55 56 def all_permissions(self) -> set[Permission]: 57 perms = set(self.permissions) 58 if self.inherits_from is not None: 59 perms |= self.inherits_from.all_permissions() 60 return perms 61 62@dataclass 63class RBACPolicy: 64 principal_id: str 65 role: Role 66 scope: ResourceScope

The hierarchy enforces a strict superset relationship: every lower role's permissions are a subset of its parent's, so a child role can never hold a capability its parent lacks.

Check that you can instantiate a developer role pointing to a viewer parent, call all_permissions() on the developer role, and confirm the returned set includes both the developer's own permissions and every permission defined on viewer.

Do's and Don'ts

Having just walked through the dataclasses, scope containment, and inheritance chain, the rules below distill those mechanics into the handful of choices that most often decide whether an RBAC model holds up under review.

Do's

  1. Do mark Permission as frozen=True — the frozen=True flag makes Permission instances hashable, which lets you store them in sets and catches duplicate permission definitions at import time rather than silently at runtime when an enforcement check may already be in progress.
  2. Do implement ResourceScope.contains() to gate every policy evaluation — a role's permission set alone is not enough; the RBACPolicy must confirm the request's resource scope falls within the principal's assigned scope before granting access, so platform-admin at global scope never bleeds into a narrower org or team boundary accidentally.
  3. Do resolve the full inherited permission set via Role.all_permissions() — because developer inherits from viewer and team-admin inherits from developer, reading only role.permissions silently drops every transitively inherited capability; call all_permissions() to walk the chain and union every parent's set before evaluating any middleware check.

Don'ts

  1. Don't store permissions in a list instead of a set[Permission] — lists allow duplicate Permission entries and make membership checks O(n) rather than O(1); because Permission is frozen=True and therefore hashable, a set is the correct structure and eliminates redundant grants that would otherwise accumulate as roles evolve.
  2. Don't conflate role inheritance with scope widening — a developer inheriting from viewer means it gains viewer's permission verbs, not a broader ResourceScope; assigning a developer role at team scope does not grant access to other teams' models or datasets, and the scope.contains() check is what enforces that boundary.
  3. Don't assign MANAGE_BILLING or MANAGE_MEMBERS permissions below platform-admin without an explicit scope check — these Action variants govern global governance resources (ResourceType.BILLING, ResourceType.AUDIT_LOG), and granting them at team or project scope means a team-admin could inadvertently read or mutate billing data outside their organizational boundary if ResourceScope.contains() is bypassed.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Platform Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in AI Developer Platform Engineering

All free lessons in GenAI Platform Engineering