Skip to main content

OpenFGA

Fine-grained authorization service for RavenmaskOS using Google Zanzibar-style relation-based access control (ReBAC).


Overview

OpenFGA provides authorization as a separate concern from authentication, enabling complex permission models across services without embedding authorization logic in each application.

┌─────────────────────────────────────────────────────────────────┐
│ APPLICATION │
└─────────────────────────────────────────────────────────────────┘

Check(user, action, resource)

┌─────────────────────────────────────────────────────────────────┐
│ OPENFGA │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Authorization Model (Relations) │ │
│ │ • user can view project │ │
│ │ • user can edit project if owner │ │
│ │ • user can admin workspace if admin │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Tuples (Relationships) │ │
│ │ • (user:alice, owner, project:web-app) │ │
│ │ • (user:bob, viewer, project:web-app) │ │
│ │ • (user:alice, admin, workspace:engineering) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

true/false

Connection Details

PropertyValue
URLInternal only
API Endpointhttp://openfga:8080
gRPC Endpointhttp://openfga:8081
Store ID01KE1W1JS5HZWWJSZKG5HR9XA5
Model ID01KE1W3RJH1E13G84N3ERN5XDN
DatabasePostgreSQL (ravenmaskos)

Authorization Model

The current model supports workspace, project, and task permissions:

model
schema 1.1

type user

type workspace
relations
define admin: [user]
define member: [user]
define can_view: admin or member
define can_edit: admin
define can_admin: admin

type project
relations
define workspace: [workspace]
define owner: [user]
define editor: [user]
define viewer: [user]
define can_view: owner or editor or viewer or admin from workspace
define can_edit: owner or editor or admin from workspace
define can_delete: owner or admin from workspace
define can_admin: admin from workspace

type task
relations
define project: [project]
define assignee: [user]
define creator: [user]
define can_view: assignee or creator or viewer from project
define can_edit: assignee or creator or editor from project
define can_delete: creator or owner from project
define can_assign: editor from project

Permission Hierarchy

Workspace Admin

Full control over workspace, all projects, and all tasks

Project Owner

Can view, edit, delete project and its tasks

Project Editor

Can view, edit project and tasks

Project Viewer

Can view project and tasks (read-only)

Task Assignee/Creator

Can view and edit assigned tasks

Bifrost Integration

Bifrost API acts as the authorization enforcement point for Norns and other services.

Architecture

┌──────────────────┐         ┌──────────────────┐
│ Norns Admin │────────▶│ Bifrost API │
│ (Frontend) │ │ (Backend) │
└──────────────────┘ └──────────────────┘

┌───────┴────────┐
↓ ↓
┌────────────────┐ ┌────────────┐
│ auth/ │ │ services/ │
│ dependencies.py│ │ openfga.py │
└────────────────┘ └────────────┘

┌────────────────────┐
│ OpenFGA API │
│ (port 8080) │
└────────────────────┘

RBAC Service (services/openfga.py)

Provides a clean interface to OpenFGA:

class RBACService:
async def check_permission(
self,
user_id: str,
action: str,
resource_type: str,
resource_id: str
) -> bool:
"""Check if user has permission for action on resource"""

async def list_user_permissions(
self,
user_id: str,
resource_type: str
) -> list[dict]:
"""List all resources user can access with their permissions"""

Auth Dependency (auth/dependencies.py)

Enforces permissions in FastAPI endpoints:

async def require_permission(
action: str,
resource_type: str,
resource_id: str,
current_user: User = Depends(get_current_user),
rbac: RBACService = Depends(get_rbac_service)
) -> User:
"""Dependency to require specific permission"""
has_permission = await rbac.check_permission(
str(current_user.id), action, resource_type, resource_id
)
if not has_permission:
raise HTTPException(status_code=403, detail="Forbidden")
return current_user

Norns Admin CORS Workaround

Norns Admin frontend uses a local API proxy to avoid CORS issues:

// Instead of calling OpenFGA directly (CORS blocked):
// const response = await fetch('http://openfga:8080/check', {...})

// Call Bifrost's RBAC proxy endpoint:
const response = await fetch('/api/rbac/permissions', {
headers: { 'Authorization': `Bearer ${token}` }
})

Bifrost proxies the request to OpenFGA and returns permissions.


Tuples (Authorization Data)

Authorization tuples define the actual relationships. As of 2026-01-03, 23 tuples are configured:

Example Tuples

{
"user": "user:350644551056793609",
"relation": "admin",
"object": "workspace:default"
}

{
"user": "user:350644551056793609",
"relation": "owner",
"object": "project:01JG1VE8JYKTZN5ZBGJ1F0DD5D"
}

{
"user": "user:350644551056793609",
"relation": "creator",
"object": "task:01JG31PYJH1JMTA8BMJT87BJWD"
}

These tuples were migrated from the existing ravenmaskos database (users, projects, tasks).


Migration from Database

Initial tuples were seeded from existing data:

-- Workspace admins (all users are workspace admins)
SELECT DISTINCT user_id FROM tasks
→ workspace:default admin tuples

-- Project ownership (project creator = owner)
SELECT DISTINCT user_id, project_id FROM tasks
→ project owner tuples

-- Task relationships (creator/assignee)
SELECT id, user_id, assigned_to FROM tasks
→ task creator and assignee tuples

API Examples

Check Permission

curl -X POST http://openfga:8080/stores/01KE1W1JS5HZWWJSZKG5HR9XA5/check \
-H "Content-Type: application/json" \
-d '{
"tuple_key": {
"user": "user:350644551056793609",
"relation": "can_view",
"object": "project:01JG1VE8JYKTZN5ZBGJ1F0DD5D"
},
"authorization_model_id": "01KE1W3RJH1E13G84N3ERN5XDN"
}'

Response:

{
"allowed": true
}

List User Permissions

curl -X POST http://openfga:8080/stores/01KE1W1JS5HZWWJSZKG5HR9XA5/list-objects \
-H "Content-Type: application/json" \
-d '{
"authorization_model_id": "01KE1W3RJH1E13G84N3ERN5XDN",
"type": "project",
"relation": "can_view",
"user": "user:350644551056793609"
}'

Response:

{
"objects": [
"project:01JG1VE8JYKTZN5ZBGJ1F0DD5D",
"project:01JG1VE9ABCDEFGHIJK1234567"
]
}

Zitadel Token Handling

CRITICAL: Zitadel issues opaque tokens by default, not JWTs. Bifrost's auth/dependencies.py handles both:

Token Type Detection

def is_jwt(token: str) -> bool:
"""Check if token is JWT format (3 parts separated by dots)"""
return token.count('.') == 2

if is_jwt(token):
# Decode JWT and extract user info
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get('sub')
else:
# Opaque token - call Zitadel userinfo endpoint
user_info = await validate_opaque_token(token)
user_id = user_info.get('sub')

Opaque Token Validation

async def validate_opaque_token(token: str) -> dict:
"""Validate opaque token with Zitadel's userinfo endpoint"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{ZITADEL_ISSUER}/oidc/v1/userinfo",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code != 200:
raise HTTPException(status_code=401, detail="Invalid token")
return response.json()

User Lookup

Users are looked up by auth_provider_id (Zitadel's sub claim), NOT by UUID:

user = await db.fetch_one(
"SELECT * FROM users WHERE auth_provider_id = :sub",
{"sub": zitadel_sub}
)

Quick Commands

# Check OpenFGA container
ssh ravenhelm@100.115.101.81 "docker ps | grep openfga"

# View OpenFGA logs
ssh ravenhelm@100.115.101.81 "docker logs openfga | tail -50"

# Count authorization tuples
ssh ravenhelm@100.115.101.81 "docker exec -i postgres psql -U ravenhelm -d openfga -c 'SELECT COUNT(*) FROM tuple;'"

# Test permission check via Bifrost
TOKEN=$(curl -s -X POST https://auth.ravenhelm.dev/oauth/v2/token \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_SECRET" | jq -r .access_token)

curl -H "Authorization: Bearer $TOKEN" \
https://bifrost-api.ravenhelm.dev/api/rbac/permissions

Troubleshooting

Permission Denied Unexpectedly

Symptoms: User gets 403 Forbidden but should have access

Diagnosis:

# Check if tuple exists
ssh ravenhelm@100.115.101.81 "docker exec -i postgres psql -U ravenhelm -d openfga -c \"
SELECT * FROM tuple
WHERE user_object_id = 'user:USERID'
AND object_type = 'project'
AND object_id = 'PROJECTID';
\""

# Check authorization model
curl http://openfga:8080/stores/01KE1W1JS5HZWWJSZKG5HR9XA5/authorization-models/01KE1W3RJH1E13G84N3ERN5XDN

Solutions:

  1. Verify tuple exists for user-resource relationship
  2. Check authorization model allows the relation
  3. Ensure user_id matches auth_provider_id from Zitadel

Opaque Token Validation Fails

Symptoms: 401 Unauthorized when using Zitadel token

Diagnosis:

# Test token with Zitadel userinfo
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://auth.ravenhelm.dev/oidc/v1/userinfo

Solutions:

  1. Verify token hasn't expired
  2. Check ZITADEL_ISSUER matches exactly (no trailing slash)
  3. Ensure token is from correct Zitadel project

CORS Errors in Frontend

Symptoms: Browser blocks OpenFGA API calls

Solution:
Use Bifrost's RBAC proxy endpoint (/api/rbac/permissions) instead of calling OpenFGA directly. OpenFGA is internal-only and not exposed via Traefik.


Future Enhancements

  • Dynamic role assignment UI in Norns Admin
  • Audit logging for permission changes
  • Integration with SPIRE for service-to-service authz
  • Granular field-level permissions
  • Time-based access control (temporal tuples)

References