Skip to main content

Permission Model (OpenFGA & Tuples)

Easy AppServer uses OpenFGA for fine-grained, relation-based authorization that scales from simple checks to complex hierarchies.

Overview

The permission model is based on relationship tuples that express who can do what to which resources. This approach, inspired by Google Zanzibar, enables flexible access control that adapts to your application's needs.

Core Concepts

Tuples

A tuple is a simple statement of relationship:

(user, relation, object)

Examples:
- (user:alice, viewer, app:todos)
- (role:admin#assignee, owner, route:/api/todos)
- (app:backend, trigger, hook:user.created)

Relations

Relations define the type of access or relationship:

Common Relations:

  • owner - Full control over the resource
  • editor - Can modify but not delete
  • viewer - Read-only access
  • accessible_by - Can access a route
  • granted_to - Has a specific permission
  • trigger - Can trigger a hook
  • execute - Can execute an activity

Objects

Objects are the resources being protected:

- app:de.easy-m.todos
- route:/api/todos
- hook:user.created
- activity:send-email
- setting:api-key

Tuple Structure

User-to-Role Assignment

Tuple: (user:alice, assignee, role:admin)

Meaning: Alice is assigned the admin role

Role-to-Resource Permission

Tuple: (role:admin#assignee, owner, app:todos)

Meaning: Users with the admin role are owners of the todos app

Note: The #assignee syntax means "users who have assignee relation to role:admin"

App-to-Permission Grant

Tuple: (app:backend, trigger, hook:user.created)

Meaning: The backend app can trigger the user.created hook

Permission Scopes

Different resource types use different tuple patterns:

Route Permissions

Tuple: (role:advertiser#assignee, accessible_by, route:/api/campaigns)

Check: Can user:bob access route:/api/campaigns?
Resolved via: bob → role:advertiser → accessible_by → route

Code Reference: pkg/v2/application/proxy/ - HTTP proxy verifies permissions before forwarding

Hook Permissions

Trigger Tuple: (app:backend, trigger, hook:user.created)
Listen Tuple: (app:notifier, listen, hook:user.created)

Actions:
- trigger: Can publish the hook
- listen: Can subscribe to hook events

Code Reference: pkg/v2/application/hooks/hooks_service.go:16

Activity Permissions

Tuple: (app:worker, execute, activity:generate-report)

Check: Can app:worker execute activity:generate-report?

Code Reference: pkg/v2/application/activity/activity_service.go:12

Settings Permissions

Tuple: (role:admin#assignee, write, setting:api-key)
Tuple: (role:viewer#assignee, read, setting:api-key)

Granular control over configuration access

Permission Checker Flow

The platform uses a multi-step process to check permissions:

Code Reference: pkg/v2/infrastructure/authz/permission_checker.go:10

1. Authentication

Identify the requester:

  • User Auth: Session cookie → Kratos → User ID
  • App Auth: Certificate → Signature verification → App name

2. Cache Lookup

Check multi-level cache (fastest → slowest):

1. In-memory cache (local to appserver instance)
2. Redis cache (shared across instances)
3. OpenFGA API (authoritative source)

Code Reference: pkg/v2/infrastructure/permission/cache/cache.go:12

3. OpenFGA Query

If not cached, query OpenFGA:

POST /stores/{store_id}/check
{
"tuple_key": {
"user": "user:alice",
"relation": "viewer",
"object": "app:todos"
}
}

Response: { "allowed": true }

4. Cache Update

Store result in all cache layers with TTL.

5. Decision

Return allow/deny decision to caller.

Tuple Population

Tuples are created automatically during app installation:

Code Reference: pkg/v2/application/marketplace/marketplace_service.go:43

Installation Flow

1. App manifest declares required permissions

2. Marketplace service parses permissions

3. For each permission, create tuple:
Tuple: (app:name, action, resource)

4. Write tuples to OpenFGA

Uninstallation Flow

1. Query all tuples for app:name

2. Delete tuples from OpenFGA

3. Publish a `permission.invalidate` event so caches drop the affected tuples

Caching Strategy

The permission system uses aggressive caching to minimize latency:

Cache Layers

L1: In-Memory Cache (per appserver instance)

- TTL: None (entries live until invalidated)
- Eviction: Manual only
- Latency: < 1ms

L2: Redis Cache (shared)

- TTL: Configurable (default 5 minutes)
- Latency: 2-5ms

L3: OpenFGA (source of truth)

- No TTL (always fresh)
- Unlimited size
- Latency: 10-50ms

Code Reference: pkg/v2/infrastructure/permission/cache/cache.go:12

Cache Invalidation

Caches are invalidated on:

  • Tuple creation/deletion
  • Role assignments changed
  • App installation/uninstallation
  • Explicit cache clear command

Invalidation Signals

Event Bus: permission.invalidate
Payload: { app_name, pattern }

Handler:
1. Remove from in-memory cache
2. Remove from Redis cache
3. Broadcast to other appserver instances

Permission Declaration in Manifest

Apps declare permissions in their manifest:

defineManifest()
.name('de.easy-m.backend')
.permission('hook', 'user.created', 'trigger')
.permission('hook', 'user.deleted', 'listen')
.permission('route', '/api/users', 'read')
.permission('activity', 'send-email', 'execute')
.build();

This generates tuples:

(app:de.easy-m.backend, trigger, hook:user.created)
(app:de.easy-m.backend, listen, hook:user.deleted)
(app:de.easy-m.backend, read, route:/api/users)
(app:de.easy-m.backend, execute, activity:send-email)

Role Hierarchy

The platform defines 4 base roles with inheritance:

easy (god mode)

admin

advertiser / publisher (parallel)

Role Relations

role:easy inherits from role:admin
role:admin inherits from role:advertiser
role:admin inherits from role:publisher

Example Checks

Query: Can user:alice (admin) view app:todos?

Resolution:
1. alice → assignee → role:admin
2. role:admin inherits from role:advertiser
3. role:advertiser#assignee → viewer → app:todos
4. Result: Yes (via inheritance)

Code Reference: docker/scripts/setup-openfga-roles.sh - Role initialization

OpenFGA Authorization Model

The authorization model defines valid relationships:

model
schema 1.1

type user

type role
relations
define assignee: [user]

type app
relations
define owner: [role#assignee]
define editor: [role#assignee] or owner
define viewer: [role#assignee] or editor

type route
relations
define accessible_by: [role#assignee, app]

type hook
relations
define trigger: [app]
define listen: [app]

type activity
relations
define execute: [app]

Code Reference: docker/openfga/authorization-model.json

Performance Optimization

Batch Checks

For multiple permission checks:

POST /stores/{store_id}/batch-check
{
"checks": [
{"user": "user:alice", "relation": "viewer", "object": "app:todos"},
{"user": "user:alice", "relation": "viewer", "object": "app:notes"}
]
}

Reduces round trips from N to 1.

Permission Hints

Pre-populate caches based on user roles:

On user login:
1. Query all permissions for user's roles
2. Warm up cache with likely checks
3. Subsequent checks hit cache

Stats Tracking

The cache tracks hit rates:

Cache Stats:
- L1 Hits: 95% (excellent)
- L2 Hits: 4%
- L3 Hits (OpenFGA): 1%

Total latency: ~1ms avg

Security Considerations

Tuple Immutability

Once created, tuples should be immutable. To change permissions:

  1. Delete old tuple
  2. Create new tuple
  3. Invalidate caches

Audit Logging

All permission checks and tuple changes are logged:

Log: permission.checked
Data: { user, action, resource, allowed, latency, cache_hit }

Log: tuple.created
Data: { tuple, created_by, timestamp }

Log: tuple.deleted
Data: { tuple, deleted_by, timestamp }

Least Privilege

Apps should request minimum necessary permissions:

❌ Permission: ("app", "owner", "route:/*")  // Too broad

✓ Permission: ("app", "read", "route:/api/todos") // Specific

Troubleshooting Permissions

Check Tuple Existence

# Via OpenFGA CLI
fga tuple list --store-id=${STORE_ID}

# Via API
curl http://localhost:8090/stores/${STORE_ID}/read

Verify Cache State

# Redis
redis-cli KEYS "permission:*"

# Get specific entry
redis-cli GET "permission:user:alice:viewer:app:todos"

Debug Permission Denial

1. Check authentication (is user/app identified?)
2. Query OpenFGA directly (bypass cache)
3. Verify tuple exists
4. Check role assignments
5. Review inheritance chain

Further Reading