Skip to main content

FGA Guide — Fine-Grained Authorization

Overview

ZewstID FGA extends RBAC with resource-level permissions. While RBAC answers "does this user have the admin role?", FGA answers "can this user edit this specific document?"

FGA is built on the Zanzibar model: authorization is a graph of relationships (tuples). A check traverses the graph to find if a path exists between a user and a resource.

When to use FGA vs RBAC

NeedUse
Admin / user / viewer rolesRBAC (simpler, JWT-based)
User can edit document:123FGA
Team members can view project filesFGA
Folder permissions inherit to documentsFGA

Core Concepts

Tuples

A tuple is a relationship:

(user, relation, object)

user:alice editor document:budget-2026 team:finance viewer folder:q1-reports folder:q1 parent document:budget-2026

Authorization Model

Defines types, relations, and how relations compute:

{ "types": [ { "type": "document", "relations": { "owner": { "this": {} }, "editor": { "union": ["owner"] }, "viewer": { "union": ["editor"] } } } ] }

This means:

viewer
includes all
editors
, and
editor
includes all
owners
.

Check

"Can user:alice view document:budget-2026?"

The engine:

  1. Checks direct tuple: does
    (user:alice, viewer, document:budget-2026)
    exist?
  2. Checks union: is alice an
    editor
    ? Is she an
    owner
    ?
  3. Checks group membership: is alice in a team that has
    viewer
    on the document?
  4. Checks parent traversal: does the document's parent folder grant access?

API Reference

Write tuples

curl -X POST https://api.zewstid.com/api/v1/authz/apps/$APP_ID/tuples \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tuples": [ {"user":"user:alice","relation":"editor","object":"document:budget-2026"}, {"user":"team:finance","relation":"viewer","object":"folder:q1-reports"} ] }'

Up to 100 tuples per request.

Delete tuples

curl -X DELETE https://api.zewstid.com/api/v1/authz/apps/$APP_ID/tuples \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tuples": [ {"user":"user:alice","relation":"editor","object":"document:budget-2026"} ] }'

List tuples

curl -G https://api.zewstid.com/api/v1/authz/apps/$APP_ID/tuples \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "userType=user" \ --data-urlencode "userId=alice" \ --data-urlencode "relation=editor" \ --data-urlencode "objectType=document"

All filter params (

userType
,
userId
,
relation
,
objectType
,
objectId
) are optional. Supports
limit
(default 100) and
offset
for pagination.

Check

curl -X POST https://api.zewstid.com/api/v1/authz/check \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"user":"user:alice","relation":"viewer","object":"document:budget-2026"}'

Response:

{"allowed":true,"cached":false,"resolution_time_ms":5}

The

appId
is derived from the token's
azp
(authorized party) claim. If
user
is omitted, the engine uses the caller's
sub
claim.

List objects

"What documents can alice view?"

curl -X POST https://api.zewstid.com/api/v1/authz/apps/$APP_ID/list-objects \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"user":"user:alice","relation":"viewer","objectType":"document"}'

Response:

{"objects":["budget-2026","meeting-notes","project-plan"]}

List users

"Who has access to document:budget-2026?"

curl -X POST https://api.zewstid.com/api/v1/authz/apps/$APP_ID/list-users \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"relation":"viewer","object":"document:budget-2026"}'

Response:

{"users":["user:alice","user:bob","team:finance"]}

Expand (debug)

Shows the resolution tree for a check — useful for debugging why a check returned

allowed: true
or
false
:

curl -X POST https://api.zewstid.com/api/v1/authz/apps/$APP_ID/expand \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"user":"user:alice","relation":"viewer","object":"document:budget-2026"}'

Get / Update authorization model

# Read current model curl https://api.zewstid.com/api/v1/authz/apps/$APP_ID/model \ -H "Authorization: Bearer $TOKEN" # Update model (replaces existing) curl -X PUT https://api.zewstid.com/api/v1/authz/apps/$APP_ID/model \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "model": { "types": [ { "type": "document", "relations": { "owner": { "this": {} }, "editor": { "union": ["owner"] }, "viewer": { "union": ["editor"] } } } ] } }'

SDK Usage

Client-side

import { useAuthz } from '@zewstid/id-nextjs'; function DocumentView({ docId }: { docId: string }) { const { check } = useAuthz(); const [canEdit, setCanEdit] = useState(false); useEffect(() => { check('editor', `document:${docId}`).then(setCanEdit); }, [docId]); return canEdit ? <Editor /> : <ReadOnlyView />; }

Server-side

import { createServerAuthz } from '@zewstid/id-nextjs/server'; const authz = createServerAuthz(); export async function PUT(req, { params }) { const session = await getServerSession(authOptions); const canEdit = await authz.check(session.accessToken, 'editor', `document:${params.id}`); if (!canEdit) return Response.json({ error: 'Forbidden' }, { status: 403 }); // ... update document }

Write tuples when creating resources

// When a user creates a document, make them the owner await authz.writeTuples(session.accessToken, appId, [ { user: `user:${session.user.id}`, relation: 'owner', object: `document:${doc.id}` } ]);

Model Patterns

Team-based access

{ "types": [ { "type": "team", "relations": { "member": { "this": {} } } }, { "type": "project", "relations": { "team": { "this": {} }, "viewer": { "tupleToUserset": { "tupleset": "team", "computedUserset": "member" } } } } ] }

Tuple:

(team:engineering, team, project:alpha)
+
(user:alice, member, team:engineering)
Check:
can user:alice view project:alpha?
-> YES (via team membership)

Folder hierarchy

{ "types": [ { "type": "folder", "relations": { "viewer": { "this": {} } } }, { "type": "document", "relations": { "parent": { "this": {} }, "viewer": { "union": [], "tupleToUserset": { "tupleset": "parent", "computedUserset": "viewer" } } } } ] }

Performance & Caching

The FGA engine is designed for sub-20ms checks at scale.

Latency targets:

  • Simple checks (direct tuple): <5ms
  • Role-based checks (user -> role -> permission): <10ms
  • Model-based checks (1-2 hops): <20ms

3-layer cache:

  1. In-memory LRU — 10s TTL, per-process. Catches hot paths in tight loops.
  2. Redis tuple cache — 60s TTL. Shared across API Gateway instances.
  3. Redis role cache — 300s TTL. Materialized role-permission graph for RBAC checks.

Tuples and role assignments invalidate the relevant cache entries on write so changes propagate within a few seconds.

Engine guardrails:

  • Max depth: 10 hops. Prevents runaway recursion through deep tupleToUserset chains.
  • Resolution timeout: 50ms. Returns
    allowed: false
    and logs a warning if exceeded.
  • Cycle detection: Visited set prevents infinite loops in self-referential models (e.g.,
    manager
    of
    manager
    ).

Use the

expand
endpoint when debugging slow or unexpected results — it returns the resolution tree without applying the timeout.

Was this page helpful?

Let us know how we can improve our documentation