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

SubjectPurpose
lfx.fga-sync.update_accessCreate/update access control for a resource
lfx.fga-sync.delete_accessDelete all access control for a resource
lfx.fga-sync.member_putAdd member(s) with one or more relations
lfx.fga-sync.member_removeRemove 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"
  • operation (required, string) - Must match the NATS subject operation without the lfx.fga-sync. prefix
    • Example: If sending to lfx.fga-sync.update_access, this must be "update_access"
  • 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) - If true, adds user:* 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"])
  • 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)
    • The handler automatically detects which format you're using
  • 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 participant and host relations are managed by separate member_put/member_remove operations, 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 resource
  • username (required, string) - Username (without user: prefix)
  • relations (required, array) - Array of relation names to add
  • mutually_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:

  1. Check existing tuples for the user
  2. Remove any relations listed in mutually_exclusive_with (if present)
  3. Add the new relation(s) from relations array
  4. 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 resource
  • username (required, string) - Username (without user: prefix)
  • relations (required, array) - Array of relation names to remove
    • Empty array [] - Removes ALL relations for this user

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 relations is an empty array, the handler calls DeleteTuplesByUserAndObject() 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 participant and host because 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 participant relation is automatically removed and replaced with host.

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 host and invitee relations but loses attendee.


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:

OperationCost driver
update_accessReads current object tuples, computes diff, writes/deletes changed tuples
delete_accessReads 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":[]}}'