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
| Property | Value |
|---|---|
| URL | Internal only |
| API Endpoint | http://openfga:8080 |
| gRPC Endpoint | http://openfga:8081 |
| Store ID | 01KE1W1JS5HZWWJSZKG5HR9XA5 |
| Model ID | 01KE1W3RJH1E13G84N3ERN5XDN |
| Database | PostgreSQL (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:
- Verify tuple exists for user-resource relationship
- Check authorization model allows the relation
- Ensure user_id matches
auth_provider_idfrom 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:
- Verify token hasn't expired
- Check ZITADEL_ISSUER matches exactly (no trailing slash)
- 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)