Hierarchy & Scoping¶
The core idea: permissions flow upward through a resource tree. A role assigned at an organization grants those permissions on every project and application under it.
Defining the Hierarchy¶
Pass a dict mapping each child type to its parent type:
PermissionsConfig(
hierarchy={
"application": "project",
"project": "organization",
}
)
This creates the chain: application -> project -> organization.
When checking if a user can application:deploy on a specific application, the resolver:
Checks role assignments scoped to that application
Walks up to the parent project and checks there
Walks up to the parent organization and checks there
Checks global (unscoped) role assignments
A match at any level grants the permission.
Resource Resolver¶
The hierarchy traversal needs to know how to find a resource’s parent. Provide a resource_resolver callback:
def resolve_resource(resource_type: str, resource_id: str, db: Session):
"""Look up a resource and return an object with a .parent attribute."""
if resource_type == "application":
return db.get(Application, resource_id)
elif resource_type == "project":
return db.get(Project, resource_id)
return None
config = PermissionsConfig(
hierarchy={"application": "project", "project": "organization"},
resource_resolver=resolve_resource,
)
The returned object must satisfy ResourceProtocol:
class ResourceProtocol(Protocol):
id: UUID | str | int
resource_type: str
parent: ResourceProtocol | None # None = top of the tree
Without a resource_resolver, hierarchy traversal stops at the direct scope. Global assignments still apply.
Scoped vs Global Assignments¶
A UserRoleAssignment with resource_type=None and resource_id=None is global. The role applies everywhere.
A scoped assignment ties the role to a specific resource:
# Global admin
UserRoleAssignment(user_id=user.id, role_id=admin_role.id)
# Admin only for organization X
UserRoleAssignment(
user_id=user.id,
role_id=admin_role.id,
resource_type="organization",
resource_id=org_x.id,
)
Caching¶
The resolver caches permission check results with an LRU strategy. Defaults:
cache_ttl: 300 secondscache_max_size: 10,000 entries
Invalidate after role changes:
resolver = app.state["permissions_resolver"]
resolver.invalidate_user(user.id) # clear one user
resolver.invalidate_all() # clear everything
Set cache_ttl=0 to disable caching entirely.