Roles¶
A role is a named collection of capabilities with optional inheritance. Roles are the bridge between raw capabilities and user assignments — instead of granting individual capabilities to users, you assign roles.
The Role model¶
Role is a Beanie document stored in the roles collection:
from craft_easy.models.role import Role
role = Role(
name="operator",
display_name="Operator",
description="Day-to-day operations: manage bookings, view reports",
capabilities=[
"bookings:list",
"bookings:read",
"bookings:create",
"bookings:update",
"parking_areas:list",
"parking_areas:read",
"reports.view",
],
inherits_from=[],
is_system=False,
tenant_id=None,
)
await role.insert()
Fields¶
| Field | Type | Description |
|---|---|---|
name |
str (unique, indexed) |
Machine-readable identifier, e.g. "operator" |
display_name |
str |
Human-readable label |
description |
str (optional) |
Free-form description |
capabilities |
list[str] |
Capability names granted by this role |
inherits_from |
list[str] |
Role names this role inherits from |
is_system |
bool |
Whether this is a built-in system role |
tenant_id |
ObjectId (optional) |
Tenant that owns this role (None = system-wide) |
Indexes¶
(name, 1) — unique lookup
(tenant_id, 1, name, 1) — tenant-scoped lookup
(is_system, 1) — filter system roles
Inheritance¶
Roles can inherit capabilities from other roles via inherits_from. This builds a hierarchy:
viewer = Role(
name="viewer",
display_name="Viewer",
capabilities=["bookings:list", "bookings:read", "parking_areas:list", "parking_areas:read"],
)
operator = Role(
name="operator",
display_name="Operator",
capabilities=["bookings:create", "bookings:update", "reports.view"],
inherits_from=["viewer"], # inherits all viewer capabilities
)
admin = Role(
name="admin",
display_name="Administrator",
capabilities=["bookings:delete", "users:list", "users:read", "users:create", "users:update"],
inherits_from=["operator"], # inherits operator + viewer capabilities
)
In this example, an admin user gets all capabilities from admin + operator + viewer.
Expansion rules¶
The AccessResolver.expand_role() method recursively resolves the full capability set:
- Start with the role's own
capabilities - For each name in
inherits_from, recursively expand that role - Union all capabilities
- Maximum depth: 5 levels (prevents runaway chains)
- Circular inheritance is detected via a visited set and silently stops
from craft_easy.core.access.resolver import get_access_resolver
resolver = get_access_resolver()
all_caps = await resolver.expand_role("admin")
# Returns: {"bookings:list", "bookings:read", "bookings:create", "bookings:update",
# "bookings:delete", "parking_areas:list", "parking_areas:read",
# "reports.view", "users:list", "users:read", "users:create", "users:update"}
Public role¶
The public role is a special system role used for unauthenticated access when PUBLIC_USER_ENABLED=True. It has restrictions enforced by a Beanie lifecycle hook:
- Cannot contain mutating capabilities (create, update, delete, cancel, refund, etc.)
- Can only reference models that are safe for public read access
The validation runs automatically on insert, replace, and update:
public_role = Role(
name="public",
display_name="Public",
capabilities=["parking_areas:list", "parking_areas:read"],
is_system=True,
)
await public_role.insert() # OK — read-only capabilities
public_role.capabilities.append("parking_areas:create")
await public_role.replace() # Raises validation error — mutating capability
Admin endpoints¶
Roles are managed via standard CRUD endpoints:
# List all roles
curl http://localhost:5001/roles
# Get a specific role
curl http://localhost:5001/roles/{id}
# Create a role
curl -X POST http://localhost:5001/roles \
-H "Content-Type: application/json" \
-d '{
"name": "support",
"display_name": "Support Agent",
"description": "Can view and update tickets",
"capabilities": ["tickets:list", "tickets:read", "tickets:update"],
"inherits_from": ["viewer"]
}'
# Update a role (ETag required)
curl -X PATCH http://localhost:5001/roles/{id} \
-H "Content-Type: application/json" \
-H "If-Match: \"etag-value\"" \
-d '{"capabilities": ["tickets:list", "tickets:read", "tickets:update", "tickets:delete"]}'
# Delete a role (ETag required)
curl -X DELETE http://localhost:5001/roles/{id} \
-H "If-Match: \"etag-value\""
Seeding roles¶
Most applications define a base set of roles at startup. A typical seed creates three roles that cover the majority of tenants:
async def seed_roles():
from craft_easy.models.role import Role
from craft_easy.core.access.capabilities import get_capability_registry
registry = get_capability_registry()
all_cap_names = [c.name for c in registry.all()]
# Admin — full access
admin = Role(
name="admin",
display_name="Administrator",
capabilities=all_cap_names,
is_system=True,
)
# Operator — day-to-day, no destructive actions
operator = Role(
name="operator",
display_name="Operator",
capabilities=[c for c in all_cap_names if ":delete" not in c],
is_system=True,
)
# Viewer — read-only
viewer = Role(
name="viewer",
display_name="Viewer",
capabilities=[c for c in all_cap_names if c.endswith(":list") or c.endswith(":read")],
is_system=True,
)
for role in [admin, operator, viewer]:
existing = await Role.find_one(Role.name == role.name)
if not existing:
await role.insert()
Tenant-specific roles¶
Roles with tenant_id=None are system-wide and available to all tenants. Roles with a tenant_id are only visible within that tenant, allowing tenants to define custom roles beyond the system defaults: