Skip to content

title: Enterprise RBAC Documentation description: FraiseQL's Enterprise Role-Based Access Control (RBAC) system provides: keywords: [] tags: ["documentation", "reference"]


Enterprise RBAC Documentation

Status: ✅ Production Ready Topic: Role-Based Access Control Performance: <0.5ms cached, <100ms uncached


Overview

FraiseQL's Enterprise Role-Based Access Control (RBAC) system provides:

  • Hierarchical Role Inheritance: Up to 10 levels of role nesting
  • Two-Layer Permission Caching: Request-level + PostgreSQL UNLOGGED table
  • Automatic Cache Invalidation: Domain versioning prevents manual cache management
  • Field-Level Authorization: GraphQL directive-based field permissions
  • Row-Level Security: Automatic WHERE clause filtering based on roles
  • Multi-Tenant Support: Tenant-scoped roles and permissions
  • Production-Grade Performance: <0.5ms permission lookups (cached)

Quick Start

Basic Setup

from fraiseql.enterprise.rbac import setup_rbac_cache
from fraiseql.enterprise.rbac import PermissionResolver

# At application startup
async def app_startup(db_pool):
    # Initialize RBAC domain versioning and cascade rules
    await setup_rbac_cache(db_pool)

    # Create permission resolver
    resolver = PermissionResolver(db_pool)

Using in GraphQL

from fraiseql.enterprise.rbac.middleware import create_rbac_middleware

# Add RBAC middleware to GraphQL schema
schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    extensions=[create_rbac_middleware(permission_resolver=resolver)]
)

Field-Level Authorization

from fraiseql.strawberry_compat import strawberry
from fraiseql.enterprise.rbac.directives import requires_permission, requires_role

@strawberry.type
class User:
    id: strawberry.ID
    name: str

    @requires_permission("user", "read_email")
    def email(self) -> str:
        """Only users with user:read_email permission can access"""
        return self.email_value

    @requires_role("admin")
    def salary(self) -> float:
        """Only admins can see salary"""
        return self.salary_value

Architecture

Role Hierarchy

FraiseQL supports hierarchical role inheritance where roles can inherit from parent roles.

┌─────────────┐
│   System    │  (root role)
└──────┬──────┘
       │
   ┌───┴───┐
   │       │
┌──▼──┐ ┌──▼──┐
│Admin│ │User │
└──┬──┘ └─────┘
   │
┌──▼─────┐
│Manager  │
└─────────┘

Key Concepts:

  • Each role can have an optional parent_role_id
  • Child roles inherit all permissions from parents
  • Cycle detection prevents infinite loops (via PostgreSQL CTEs)
  • Depth limit: 10 levels maximum

Two-Layer Permission Cache

Layer 1: Request-Level Cache

  • In-memory dictionary, scoped to single request
  • Instant access: < 1 microsecond
  • Automatic cleanup at request end
  • Zero latency for repeated permission checks

Layer 2: PostgreSQL UNLOGGED Table

  • Persistent cache across requests
  • Domain versioning tracks cache validity
  • Automatic cascade invalidation on changes
  • Lookup performance: 0.1-0.3ms

Cache Invalidation:

User modifies permission
       ↓
domain_version incremented
       ↓
All cached permissions with old version invalidated
       ↓
Next request checks version, refreshes if needed

Domain Versioning

FraiseQL uses domain versioning for automatic cache invalidation:

-- Each domain has a version
SELECT version FROM domain_versions WHERE domain = 'role';

-- When a role changes:
UPDATE domain_versions
SET version = version + 1
WHERE domain = 'role';

-- On permission lookup:
SELECT * FROM permission_cache
WHERE user_id = ?
  AND version = (SELECT version FROM domain_versions WHERE domain = 'role');

Core Concepts

Roles

A role represents a set of permissions within your system.

@strawberry.type
class Role:
    id: strawberry.ID
    name: str
    description: str | None
    parent_role_id: strawberry.ID | None  # Optional inheritance
    is_system: bool  # Cannot be deleted
    tenant_id: strawberry.ID | None  # Multi-tenancy support
    created_at: datetime
    updated_at: datetime

System Roles (predefined):

  • admin - Full system access
  • user - Basic user access
  • guest - Limited access

Custom Roles:

  • Department-specific (e.g., sales_manager, engineering_lead)
  • Feature-specific (e.g., analytics_viewer, report_editor)

Permissions

A permission is a pairing of resource and action.

@strawberry.type
class Permission:
    id: strawberry.ID
    resource: str  # e.g., "user", "product", "order"
    action: str    # e.g., "create", "read", "update", "delete"
    description: str | None
    constraints: dict | None  # Optional JSONB constraints

Standard Permissions:

user.create, user.read, user.update, user.delete
product.create, product.read, product.update, product.delete
order.create, order.read, order.update, order.delete

Constraints (optional JSONB):

{
  "own_data_only": true,
  "max_records": 1000,
  "time_restricted": "9-17",
  "department_only": "engineering"
}

User Roles

Assignment of roles to users, with optional expiration.

@strawberry.type
class UserRole:
    id: strawberry.ID
    user_id: strawberry.ID
    role_id: strawberry.ID
    tenant_id: strawberry.ID | None
    expires_at: datetime | None
    granted_by: strawberry.ID  # Who granted this role
    created_at: datetime

Permission Resolution

How Permissions Are Resolved

User Request
    ↓
Check request-level cache
    ├─ HIT → Return permissions (< 1 µs)
    └─ MISS → Check PostgreSQL cache
            ├─ HIT → Load to request cache (0.1-0.3ms)
            └─ MISS → Resolve from role hierarchy
                    ├─ Fetch user's roles (0.1ms)
                    ├─ Fetch all inherited roles (via CTE) (1-10ms)
                    ├─ Fetch all permissions (0.5-2ms)
                    ├─ Cache in request memory (< 1 µs)
                    └─ Cache in PostgreSQL (< 1ms)

Total uncached: 2-15ms (depends on hierarchy depth)
Total cached: < 0.5ms

API Methods

# Get all permissions for a user
permissions = await resolver.get_user_permissions(
    user_id="user-123",
    tenant_id="tenant-456",  # Optional, for multi-tenant
    use_cache=True           # Use caching (recommended)
)

# Check single permission
has_perm = await resolver.has_permission(
    user_id="user-123",
    resource="user",
    action="update"
)

# Check with exception
await resolver.check_permission(
    user_id="user-123",
    resource="order",
    action="delete",
    raise_on_deny=True  # Raises PermissionDenied if false
)

# Get user's direct roles
roles = await resolver.get_user_roles(
    user_id="user-123",
    tenant_id="tenant-456"  # Optional
)

# Get all permissions for a role (including inherited)
perms = await resolver.get_role_permissions(
    role_id="role-789",
    include_inherited=True
)

Role Hierarchy Management

Creating Role Hierarchies

# Define roles via GraphQL mutations
mutation CreateRoles {
  # Create system admin role
  createRole(name: "admin", isSystem: true) {
    id
    name
  }

  # Create department roles
  createRole(name: "sales_team", parentRoleId: "user") {
    id
  }

  createRole(name: "sales_manager", parentRoleId: "sales_team") {
    id
  }

  createRole(name: "sales_director", parentRoleId: "sales_manager") {
    id
  }
}

Inheritance Chain:

admin (system role)
  ↑
user (system role)
  ↑
sales_team
  ↑
sales_manager
  ↑
sales_director

A sales_director inherits all permissions from:

  • sales_director (direct)
  • sales_manager (parent)
  • sales_team (grandparent)
  • user (great-grandparent)
  • admin (great-great-grandparent)

Assigning Roles to Users

mutation AssignRole {
  assignRoleToUser(
    userId: "user-123"
    roleId: "sales_manager"
    tenantId: "tenant-456"
    expiresAt: "2025-12-31T23:59:59Z"
  ) {
    id
    user {
      id
      name
    }
    role {
      id
      name
    }
    expiresAt
  }
}

Expiration: Optional time-based role revocation.

Querying Role Hierarchy

query GetRoleHierarchy {
  role(id: "role-789") {
    id
    name
    parentRole {
      id
      name
    }
    childRoles {
      id
      name
    }
    permissions {
      id
      resource
      action
    }
    ancestors {
      id
      name
    }
    descendants {
      id
      name
    }
  }
}

Field-Level Authorization

Using Directives

FraiseQL provides GraphQL directives for field-level access control.

@requires_permission Directive

@strawberry.type
class User:
    id: strawberry.ID
    name: str

    @requires_permission("user", "read_email")
    def email(self) -> str:
        """Only accessible to users with user:read_email permission"""
        return self.email_value

    @requires_permission("user", "read_salary")
    def salary(self) -> float:
        """Only accessible to users with user:read_salary permission"""
        return self.salary_value

Behavior:

  • Field check: Permission required before field resolution
  • Missing permission: Returns GraphQL error, field returns null
  • No exception thrown: Graceful field hiding

@requires_role Directive

@strawberry.type
class Product:
    id: strawberry.ID
    name: str

    @requires_role("admin")
    def cost(self) -> float:
        """Only admins can see cost"""
        return self.cost_value

    @requires_role("sales_manager")
    def margin(self) -> float:
        """Only sales managers can see margin"""
        return (self.price - self.cost) / self.price

Behavior:

  • Role check: User must have specified role
  • Multiple roles (OR logic): @requires_role(roles=["admin", "manager"])
  • If denied: Graceful null return with error

Field Filtering Response

When a user doesn't have permission for a field:

query {
  user(id: "user-123") {
    id        # ✓ Always included
    name      # ✓ Always included
    email     # ✗ HIDDEN - user lacks "user:read_email"
    salary    # ✗ HIDDEN - user lacks "user:read_salary"
  }
}

Response:

{
  "data": {
    "user": {
      "id": "user-123",
      "name": "John Doe",
      "email": null,
      "salary": null
    }
  },
  "errors": [{
    "message": "Permission denied: user:read_email",
    "path": ["user", "email"]
  }]
}

Row-Level Security (RLS)

Automatic Row Filtering

Row-level security automatically filters query results based on user permissions.

# Install Rust row constraint resolver
from fraiseql.enterprise.rbac.rust_row_constraints import RustRowConstraintResolver

row_resolver = RustRowConstraintResolver(
    db_pool=db_pool,
    cache_capacity=10000  # LRU cache size
)

# Add to schema
schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    extensions=[create_rbac_middleware(row_constraint_resolver=row_resolver)]
)

Row Constraints

Define what rows each role can access:

# Example: Employees can only see their own data
constraint = RowConstraint(
    role_id="employee",
    table_name="users",
    where_clause="owner_id = current_user_id"  # Parameterized
)

# Example: Managers see their department
constraint = RowConstraint(
    role_id="department_manager",
    table_name="employees",
    where_clause="department_id = (SELECT department_id FROM users WHERE id = current_user_id)"
)

Performance

Row constraint checking with Rust FFI:

  • Cached lookup: < 0.1ms (LRU cache)
  • Uncached lookup: < 1ms (database query)
  • Supports 10,000+ rows: Transparent filtering

Example Query:

query {
  users {
    id
    name
    salary  # Only included if has permission
  }
}

Behind the scenes:

SELECT id, name, salary
FROM users
WHERE department_id = ? -- Automatically added by Rust resolver

Multi-Tenant RBAC

Tenant-Scoped Roles

Each role can be scoped to a tenant:

# Global role (NULL tenant_id)
role = {
    "id": "role-1",
    "name": "admin",
    "tenant_id": None,
    "permissions": [...]
}

# Tenant-specific role
role = {
    "id": "role-2",
    "name": "tenant_admin",
    "tenant_id": "tenant-123",
    "permissions": [...]
}

Permission Resolution with Tenants

# Get permissions scoped to tenant
permissions = await resolver.get_user_permissions(
    user_id="user-123",
    tenant_id="tenant-456"  # Filter to this tenant
)

# Query with tenant context
await resolver.check_permission(
    user_id="user-123",
    resource="product",
    action="read",
    tenant_id="tenant-456"  # Tenant isolation
)

Isolation:

  • User A (tenant-1) cannot inherit permissions from tenant-2 roles
  • Row-level filtering automatically includes tenant context
  • Cache keys include tenant_id

GraphQL Mutations

Creating Roles

mutation {
  createRole(
    name: "content_manager"
    description: "Can manage all content"
    parentRoleId: "user"
    tenantId: "tenant-123"
  ) {
    id
    name
    parentRole { id name }
  }
}

Assigning Permissions

mutation {
  grantPermissionToRole(
    roleId: "role-456"
    resourceId: "resource-789"
    action: "create"
  ) {
    id
    role { name }
    permission { resource action }
  }
}

Managing User Roles

mutation {
  assignRoleToUser(
    userId: "user-123"
    roleId: "content_manager"
    tenantId: "tenant-456"
    expiresAt: "2025-12-31"
  ) {
    id
    user { name }
    role { name }
    expiresAt
  }
}

mutation {
  revokeRoleFromUser(
    userId: "user-123"
    roleId: "content_manager"
    tenantId: "tenant-456"
  ) {
    success
  }
}

Performance Characteristics

Permission Lookup Performance

Scenario Latency Notes
Request cache hit < 1 µs Already computed this request
PostgreSQL cache hit 0.1-0.3ms UNLOGGED table lookup
Role hierarchy computation 2-15ms CTE with up to 10 levels
Total (cached) < 0.5ms 99.9% of requests
Total (uncached) 2-15ms Cold start or cache expired

Row Constraint Performance

Operation Latency Notes
Cached constraint lookup < 0.1ms LRU cache (10,000 entries)
Uncached constraint lookup < 1ms Database query
WHERE clause generation < 0.1ms Template substitution
Query execution with constraints < actual query time Transparent to client

Scalability

  • Users: 10,000+ concurrent
  • Roles per user: Up to 100 (practical limit)
  • Role depth: Up to 10 levels (architectural limit)
  • Permissions: Unlimited (scaled by hardware)

Best Practices

1. Cache at Request Level

Always reuse resolved permissions within a request:

# GOOD - Single resolution
user_permissions = await resolver.get_user_permissions(user_id)
has_create = "resource:create" in user_permissions
has_read = "resource:read" in user_permissions
has_update = "resource:update" in user_permissions

# BAD - Multiple database calls
has_create = await resolver.has_permission(user_id, "resource", "create")
has_read = await resolver.has_permission(user_id, "resource", "read")
has_update = await resolver.has_permission(user_id, "resource", "update")

2. Use Domain Versioning

Domain versioning automatically handles cache invalidation - don't manually clear caches:

# GOOD - Let domain versioning handle invalidation
mutation {
  createRole(name: "new_role") {
    id
  }
}
# Automatically increments domain_version for 'role' domain

# BAD - Manual cache management
cache.invalidate_all()  # Throws away valid data

3. Prefer Inheritance Over Duplication

Build role hierarchies rather than copying permissions:

# GOOD - Inheritance
user → team_lead → team_manager → director

# BAD - Duplication
user (has all permissions copied)
team_lead (has all same permissions again)

4. Set Expiration Dates

Use role expiration for temporary assignments:

mutation {
  assignRoleToUser(
    userId: "contractor-123"
    roleId: "developer"
    expiresAt: "2025-03-31"  # Auto-revoke after contract
  ) {
    id
  }
}

5. Audit Role Changes

Log who made what changes:

# Automatically captured in audit logging
granted_by: "admin-user-456"  # Who granted the role
created_at: "2025-01-11T10:30:00Z"

Troubleshooting

Permission Not Working

  1. Check role inheritance:
query {
  user(id: "user-123") {
    roles {
      name
      parentRole { name }
      permissions { resource action }
    }
  }
}
  1. Verify permission assignment:
query {
  role(id: "role-456") {
    permissions { resource action }
  }
}
  1. Check cache version:
SELECT * FROM domain_versions WHERE domain = 'role';

High Latency on Permission Checks

  1. Check cache hit ratio:
SELECT * FROM permission_cache_stats;
  1. Verify domain versioning is working:
SELECT version FROM domain_versions WHERE domain = 'role';
-- Should be same across requests unless roles changed
  1. Monitor role hierarchy depth:
SELECT role_id, max_depth FROM role_hierarchy_depths;
-- Limit to <10 for optimal performance

Summary

FraiseQL RBAC provides:

✅ Hierarchical roles with up to 10 levels ✅ Two-layer caching for sub-millisecond lookups ✅ Automatic cache invalidation via domain versioning ✅ Field-level authorization with GraphQL directives ✅ Row-level security for transparent filtering ✅ Multi-tenant support with tenant-scoped roles ✅ Production-grade performance (<0.5ms cached) ✅ Full audit trail of role changes

Start with role hierarchy design, leverage automatic caching, and use expiration dates for temporary assignments.