Skip to content

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:

  1. Start with the role's own capabilities
  2. For each name in inherits_from, recursively expand that role
  3. Union all capabilities
  4. Maximum depth: 5 levels (prevents runaway chains)
  5. 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:

custom_role = Role(
    name="parking-supervisor",
    display_name="Parking Supervisor",
    capabilities=["parking_areas:list", "parking_areas:read", "parking_areas:update", "bookings:list", "bookings:read"],
    tenant_id=tenant.id,
)
await custom_role.insert()