FGA Sync Client Guide
May 28, 2026 · View on GitHub
This guide explains how to use the FGA Sync service's NATS API to manage fine-grained authorization for your resources.
It is a caller-facing walkthrough. The authoritative generic subjects, envelope
shape, tuple format, cache behavior, and access-check semantics live in
docs/fga-sync-contract.md.
Request/Reply API
These subjects use NATS request/reply for synchronous queries.
Access Check
Subject: lfx.access_check.request
Checks one or more authorization relationships. Each line is a tuple-string in object#relation@user format.
Request (plain text, one check per line):
project:7cad5a8d-19d0-41a4-81a6-043453daf9ee#writer@user:456
project:7cad5a8d-19d0-41a4-81a6-043453daf9ee#viewer@user:456
Response (plain text, one line per check, tab-delimited {request}\t{true|false}). Order is not guaranteed —
cached results are returned first.
project:7cad5a8d-19d0-41a4-81a6-043453daf9ee#viewer@user:456\tfalse
project:7cad5a8d-19d0-41a4-81a6-043453daf9ee#writer@user:456\ttrue
Read Tuples
Subject: lfx.access_check.read_tuples
Returns all direct OpenFGA tuples for a given user and object type. Paginates internally so the caller receives the full result set.
Request (JSON):
{"user": "user:auth0|alice", "object_type": "project"}
Response (success) (JSON):
{"results": ["project:uuid1#writer@user:auth0|alice", "project:uuid2#auditor@user:auth0|alice"]}
If no tuples are found:
{"results": []}
Response (error) (JSON):
{"error": "failed to read tuples"}
Sync API — Generic Handlers
The FGA Sync service provides four universal NATS subjects that work with any
resource type defined in the OpenFGA model, such as projects, committees, and
v1 meetings. If a reply subject is provided, the service responds with OK
after processing, allowing callers to implement synchronous acknowledgement.
Benefits of Generic Handlers
- ✅ Resource Agnostic - Works with any model-defined object type
- ✅ Multiple Relations - Add/remove multiple relations atomically
- ✅ Mutually Exclusive Relations - Automatic cleanup of conflicting roles
- ✅ Future Proof - New model-defined resource types require no fga-sync service changes
- ✅ Consistent API - Same pattern for all operations
NATS Subjects
| Subject | Purpose |
|---|---|
lfx.fga-sync.update_access | Create/update access control for a resource |
lfx.fga-sync.delete_access | Delete all access control for a resource |
lfx.fga-sync.member_put | Add member(s) with one or more relations |
lfx.fga-sync.member_remove | Remove member relations |
Message Format
All messages use the GenericFGAMessage envelope:
{
"object_type": "your_resource_type",
"operation": "operation_name",
"data": {
// Operation-specific fields
}
}
Fields
object_type(required, string) - Your resource type, must be defined in the OpenFGA authorization model- Examples:
"committee","project","v1_meeting","v1_past_meeting"
- Examples:
operation(required, string) - Must match the NATS subject operation without thelfx.fga-sync.prefix- Example: If sending to
lfx.fga-sync.update_access, this must be"update_access"
- Example: If sending to
data(required, object) - Operation-specific payload (see below)
1. Update Access Control
Subject: lfx.fga-sync.update_access
Updates or creates access control for a resource. This is a full sync operation - any relations not included will be removed.
Data Fields
{
"object_type": "committee",
"operation": "update_access",
"data": {
"uid": "123",
"public": true,
"relations": {
"member": ["user1", "user2"],
"viewer": ["user3"]
},
"references": {
"parent": ["456"],
"project": ["789"]
},
"exclude_relations": ["participant"]
}
}
Data Object Fields
uid(required, string) - Unique identifier for your resource, typically a UUID (though not required to be)public(optional, boolean) - Iftrue, addsuser:*as viewer (public access)relations(optional, object) - Map of relation names to arrays of usernames- Key: Relation name (e.g.,
"member","viewer","editor") - Value: Array of usernames (e.g.,
["user1", "user2"])
- Key: Relation name (e.g.,
references(optional, object) - Map of relation names to arrays of object UIDs- Key: Relation name (e.g.,
"parent","project") - Value: Array of object UIDs in one of two formats:
- Just the ID:
["parent-123"](the handler will prepend the type) - Full type:ID format:
["committee:parent-123"](used as-is)
- Just the ID:
- The handler automatically detects which format you're using
- Key: Relation name (e.g.,
exclude_relations(optional, array) - Relations managed elsewhere (won't be synced)
Examples
Basic Access Control
{
"object_type": "project",
"operation": "update_access",
"data": {
"uid": "123",
"public": false,
"relations": {
"editor": ["alice", "bob"],
"viewer": ["charlie"]
}
}
}
With Parent Reference (ID Only)
{
"object_type": "committee",
"operation": "update_access",
"data": {
"uid": "456",
"public": true,
"relations": {
"member": ["user1", "user2"]
},
"references": {
"parent": ["123"]
}
}
}
Note: The parent reference uses just the ID
"123". The handler automatically prepends"committee:"to create the full reference"committee:123".
With Parent Reference (Full Type:ID Format)
{
"object_type": "committee",
"operation": "update_access",
"data": {
"uid": "456",
"public": true,
"relations": {
"member": ["user1", "user2"]
},
"references": {
"parent": ["committee:123"]
}
}
}
Note: This example uses the full
"committee:123"format. The handler detects the colon and uses the value as-is. Both formats produce the same result.
With Excluded Relations
Use exclude_relations when some relations are managed by separate member operations:
{
"object_type": "v1_meeting",
"operation": "update_access",
"data": {
"uid": "meeting-789",
"public": false,
"relations": {
"organizer": ["alice"]
},
"references": {
"project": ["123"]
},
"exclude_relations": ["participant", "host"]
}
}
Note: The
participantandhostrelations are managed by separatemember_put/member_removeoperations, so they're excluded from the sync.
Go Example
import (
"encoding/json"
"github.com/nats-io/nats.go"
)
type UpdateAccessData struct {
UID string `json:"uid"`
Public bool `json:"public"`
Relations map[string][]string `json:"relations"`
References map[string][]string `json:"references,omitempty"`
ExcludeRelations []string `json:"exclude_relations,omitempty"`
}
type GenericFGAMessage struct {
ObjectType string `json:"object_type"`
Operation string `json:"operation"`
Data interface{} `json:"data"`
}
// Send update access message
msg := GenericFGAMessage{
ObjectType: "committee",
Operation: "update_access",
Data: UpdateAccessData{
UID: "123",
Public: true,
Relations: map[string][]string{
"member": {"alice", "bob"},
"viewer": {"charlie"},
},
},
}
payload, _ := json.Marshal(msg)
nc.Request("lfx.fga-sync.update_access", payload, 5*time.Second)
2. Delete Access Control
Subject: lfx.fga-sync.delete_access
Deletes all access control tuples for a resource. Typically used when a resource is deleted.
Data Fields
{
"object_type": "committee",
"operation": "delete_access",
"data": {
"uid": "123"
}
}
Data Object Fields
uid(required, string) - Unique identifier for the resource to delete
Examples
Delete Committee Access
{
"object_type": "committee",
"operation": "delete_access",
"data": {
"uid": "123"
}
}
Delete Meeting Access
{
"object_type": "v1_meeting",
"operation": "delete_access",
"data": {
"uid": "meeting-456"
}
}
Go Example
type DeleteAccessData struct {
UID string `json:"uid"`
}
msg := GenericFGAMessage{
ObjectType: "project",
Operation: "delete_access",
Data: DeleteAccessData{
UID: "123",
},
}
payload, _ := json.Marshal(msg)
nc.Request("lfx.fga-sync.delete_access", payload, 5*time.Second)
3. Add Member(s)
Subject: lfx.fga-sync.member_put
Adds a user to a resource with one or more relations. Supports atomic multi-relation updates and mutually exclusive relation handling.
Data Fields
{
"object_type": "committee",
"operation": "member_put",
"data": {
"uid": "123",
"username": "alice",
"relations": ["member"],
"mutually_exclusive_with": ["guest"]
}
}
Data Object Fields
uid(required, string) - Unique identifier for the resourceusername(required, string) - Username (withoutuser:prefix)relations(required, array) - Array of relation names to addmutually_exclusive_with(optional, array) - Relations to auto-remove (for role transitions)
Examples
Add Single Relation
{
"object_type": "committee",
"operation": "member_put",
"data": {
"uid": "123",
"username": "alice",
"relations": ["member"]
}
}
Add Multiple Relations (Atomic)
Add both host and invitee relations in a single atomic operation:
{
"object_type": "v1_past_meeting",
"operation": "member_put",
"data": {
"uid": "past-meeting-456",
"username": "bob",
"relations": ["host", "invitee"]
}
}
Mutually Exclusive Relations
When promoting a participant to host, automatically remove the participant relation:
{
"object_type": "v1_meeting",
"operation": "member_put",
"data": {
"uid": "meeting-789",
"username": "charlie",
"relations": ["host"],
"mutually_exclusive_with": ["participant", "host"]
}
}
Behavior: The handler will:
- Check existing tuples for the user
- Remove any relations listed in
mutually_exclusive_with(if present)- Add the new relation(s) from
relationsarray- Skip writes if the user already has the correct relations (idempotent)
Go Example
type MemberData struct {
UID string `json:"uid"`
Username string `json:"username"`
Relations []string `json:"relations"`
MutuallyExclusiveWith []string `json:"mutually_exclusive_with,omitempty"`
}
// Single relation
msg := GenericFGAMessage{
ObjectType: "committee",
Operation: "member_put",
Data: MemberData{
UID: "123",
Username: "alice",
Relations: []string{"member"},
},
}
// Multiple relations
msg := GenericFGAMessage{
ObjectType: "v1_past_meeting",
Operation: "member_put",
Data: MemberData{
UID: "past-meeting-456",
Username: "bob",
Relations: []string{"host", "invitee", "attendee"},
},
}
// Mutually exclusive
msg := GenericFGAMessage{
ObjectType: "v1_meeting",
Operation: "member_put",
Data: MemberData{
UID: "meeting-789",
Username: "charlie",
Relations: []string{"host"},
MutuallyExclusiveWith: []string{"participant", "host"},
},
}
payload, _ := json.Marshal(msg)
nc.Request("lfx.fga-sync.member_put", payload, 5*time.Second)
4. Remove Member(s)
Subject: lfx.fga-sync.member_remove
Removes specific relations or all relations for a user from a resource.
Data Fields
{
"object_type": "committee",
"operation": "member_remove",
"data": {
"uid": "123",
"username": "alice",
"relations": ["member"]
}
}
Data Object Fields
uid(required, string) - Unique identifier for the resourceusername(required, string) - Username (withoutuser:prefix)relations(required, array) - Array of relation names to remove- Empty array
[]- Removes ALL relations for this user
- Empty array
Examples
Remove Specific Relations
Remove only the invitee relation, keeping other relations intact:
{
"object_type": "v1_past_meeting",
"operation": "member_remove",
"data": {
"uid": "past-meeting-456",
"username": "bob",
"relations": ["invitee"]
}
}
Remove Multiple Specific Relations
{
"object_type": "v1_past_meeting",
"operation": "member_remove",
"data": {
"uid": "past-meeting-456",
"username": "charlie",
"relations": ["host", "organizer"]
}
}
Remove ALL Relations
Use an empty array to remove all relations for the user:
{
"object_type": "committee",
"operation": "member_remove",
"data": {
"uid": "123",
"username": "alice",
"relations": []
}
}
Behavior: When
relationsis an empty array, the handler callsDeleteTuplesByUserAndObject()to remove all tuples for this user-object pair.
Go Example
type MemberData struct {
UID string `json:"uid"`
Username string `json:"username"`
Relations []string `json:"relations"`
}
// Remove specific relation
msg := GenericFGAMessage{
ObjectType: "committee",
Operation: "member_remove",
Data: MemberData{
UID: "123",
Username: "alice",
Relations: []string{"member"},
},
}
// Remove all relations
msg := GenericFGAMessage{
ObjectType: "committee",
Operation: "member_remove",
Data: MemberData{
UID: "123",
Username: "alice",
Relations: []string{}, // Empty array
},
}
payload, _ := json.Marshal(msg)
nc.Request("lfx.fga-sync.member_remove", payload, 5*time.Second)
Complete Use Case Examples
Use Case 1: Committee Lifecycle
Create Committee with Initial Members
{
"object_type": "committee",
"operation": "update_access",
"data": {
"uid": "001",
"public": false,
"relations": {
"admin": ["alice"],
"member": ["bob", "charlie"]
},
"references": {
"project": ["linux-foundation"]
}
}
}
Add New Member
{
"object_type": "committee",
"operation": "member_put",
"data": {
"uid": "001",
"username": "dave",
"relations": ["member"]
}
}
Promote Member to Admin
{
"object_type": "committee",
"operation": "member_put",
"data": {
"uid": "001",
"username": "bob",
"relations": ["admin", "member"]
}
}
Remove Member
{
"object_type": "committee",
"operation": "member_remove",
"data": {
"uid": "001",
"username": "charlie",
"relations": []
}
}
Delete Committee
{
"object_type": "committee",
"operation": "delete_access",
"data": {
"uid": "001"
}
}
Use Case 2: Meeting with Dynamic Participants
Create Meeting
{
"object_type": "v1_meeting",
"operation": "update_access",
"data": {
"uid": "meeting-2026-01-15",
"public": false,
"relations": {
"organizer": ["alice"]
},
"references": {
"project": ["123"],
"committee": ["001"]
},
"exclude_relations": ["participant", "host"]
}
}
Note: We exclude
participantandhostbecause they'll be managed via member operations.
Register Participant
{
"object_type": "v1_meeting",
"operation": "member_put",
"data": {
"uid": "meeting-2026-01-15",
"username": "bob",
"relations": ["participant"]
}
}
Promote Participant to Host
{
"object_type": "v1_meeting",
"operation": "member_put",
"data": {
"uid": "meeting-2026-01-15",
"username": "bob",
"relations": ["host"],
"mutually_exclusive_with": ["participant", "host"]
}
}
Result: Bob's
participantrelation is automatically removed and replaced withhost.
Cancel Participant Registration
{
"object_type": "v1_meeting",
"operation": "member_remove",
"data": {
"uid": "meeting-2026-01-15",
"username": "bob",
"relations": ["participant", "host"]
}
}
Use Case 3: Past Meeting with Multiple Participant Roles
Record Past Meeting Participant
A participant can have multiple roles (invitee, attendee, host):
{
"object_type": "v1_past_meeting",
"operation": "member_put",
"data": {
"uid": "past-meeting-123",
"username": "alice",
"relations": ["host", "invitee", "attendee"]
}
}
Update Participant Status
Remove attendee if they didn't actually attend:
{
"object_type": "v1_past_meeting",
"operation": "member_remove",
"data": {
"uid": "past-meeting-123",
"username": "alice",
"relations": ["attendee"]
}
}
Result: Alice keeps
hostandinviteerelations but losesattendee.
Response Format
Sync operations return a simple "OK" string on success when the request
includes a reply subject:
OK
Sync-operation failures are logged server-side by the subscription loop. They do
not currently have a standardized NATS error response body, so callers using
request/reply should treat a missing OK as failure and apply their normal
timeout/retry handling.
Best Practices
1. Use Idempotent Operations
The handlers are designed to be idempotent. Calling member_put multiple times with the same data will only write once:
// First call: Creates tuple
{"object_type": "committee", "operation": "member_put", "data": {"uid": "123", "username": "alice", "relations": ["member"]}}
// Second call: Skips write (logs "no changes needed")
{"object_type": "committee", "operation": "member_put", "data": {"uid": "123", "username": "alice", "relations": ["member"]}}
2. Atomic Multi-Relation Updates
When a user needs multiple relations, send them in one member_put operation:
// ✅ Good: Atomic operation
{
"object_type": "v1_past_meeting",
"operation": "member_put",
"data": {
"uid": "meeting-123",
"username": "alice",
"relations": ["host", "invitee", "attendee"]
}
}
// ❌ Bad: Three separate operations (race conditions possible)
// member_put with ["host"]
// member_put with ["invitee"]
// member_put with ["attendee"]
3. Exclude Dynamically Managed Relations
If some relations are managed separately, exclude them from update_access:
{
"operation": "update_access",
"data": {
"uid": "meeting-123",
"relations": {
"organizer": ["alice"]
},
"exclude_relations": ["participant", "host"]
}
}
4. Use Mutually Exclusive for Role Transitions
When users can only have one role at a time, use mutually_exclusive_with:
{
"operation": "member_put",
"data": {
"uid": "meeting-123",
"username": "bob",
"relations": ["host"],
"mutually_exclusive_with": ["participant", "host"]
}
}
5. Empty Array for Complete Removal
To remove all relations for a user, use an empty relations array:
{
"operation": "member_remove",
"data": {
"uid": "123",
"username": "alice",
"relations": []
}
}
Performance Characteristics
The repo does not publish canonical latency numbers. In general:
| Operation | Cost driver |
|---|---|
update_access | Reads current object tuples, computes diff, writes/deletes changed tuples |
delete_access | Reads current object tuples, then deletes all tuples for the object |
member_put (new) | Reads current object tuples, writes missing relations |
member_put (existing) | Reads current object tuples, skips writes when relations already exist |
member_put (mutually exclusive) | Reads current object tuples, deletes mutually exclusive relations, writes desired relations |
member_remove (specific) | Deletes specific tuples without a full-object sync |
member_remove (all) | Reads tuples for the user/object pair, then deletes them |
Error Handling
Common Errors
Missing Required Field:
// Error: object_type is required
{
"operation": "member_put",
"data": { ... }
}
Empty Username:
// Error: username is required
{
"object_type": "committee",
"operation": "member_put",
"data": {
"uid": "123",
"username": "",
"relations": ["member"]
}
}
Empty Relations Array in member_put:
// Error: relations array cannot be empty for member_put
{
"object_type": "committee",
"operation": "member_put",
"data": {
"uid": "123",
"username": "alice",
"relations": []
}
}
Invalid JSON:
Error: failed to parse generic message
FAQ
Q: What happens if I send the same member_put twice? A: The handler is idempotent - it checks existing tuples and skips the write if the relation already exists.
Q: Can I add multiple relations in one operation?
A: Yes! Use the relations array: "relations": ["host", "invitee", "attendee"]
Q: How do I remove all relations for a user?
A: Send member_remove with an empty relations array: "relations": []
Q: What's the difference between update_access and member_put?
A: update_access is a full sync (sets complete state), while member_put adds specific relations incrementally.
Q: Can I use any object_type value?
A: Use any object type that is defined in the OpenFGA model, such as
"committee", "project", "v1_meeting", or "v1_past_meeting". Unknown
or invalid tuple writes are logged and skipped by the OpenFGA validation retry
path; non-validation OpenFGA errors fail the operation.
Quick Reference
# Update Access
nats request lfx.fga-sync.update_access '{"object_type":"committee","operation":"update_access","data":{"uid":"123","public":true,"relations":{"member":["alice"]}}}'
# Delete Access
nats request lfx.fga-sync.delete_access '{"object_type":"committee","operation":"delete_access","data":{"uid":"123"}}'
# Add Member
nats request lfx.fga-sync.member_put '{"object_type":"committee","operation":"member_put","data":{"uid":"123","username":"alice","relations":["member"]}}'
# Remove Member
nats request lfx.fga-sync.member_remove '{"object_type":"committee","operation":"member_remove","data":{"uid":"123","username":"alice","relations":[]}}'