Authorization (OpenFGA)
How the Go API uses OpenFGA for fine-grained authorization checks.
Overview
The API uses OpenFGA for relationship-based access control (ReBAC). OpenFGA stores authorization data as relationship tuples (user → relation → object) and evaluates access checks against an authorization model.
The internal/shared/authz package wraps the OpenFGA Go SDK and provides:
Clientinterface —Check,ListObjects, andWriteTuplesoperationsRequireRelationmiddleware — per-handler authorization checks that return 403 or 500- Noop fallback — when
FGA_STORE_IDis unset, all checks pass (for local development without OpenFGA)
Configuration
| Variable | Default | Required | Description |
|---|---|---|---|
FGA_API_URL | http://localhost:5080 | No | OpenFGA HTTP API URL |
FGA_STORE_ID | — | No | OpenFGA store ID (ULID format) |
When FGA_STORE_ID is empty, the authz client logs a warning and uses a noop client that allows all operations. This is intentional for local development but must be configured in production.
Local Setup
OpenFGA runs as part of the Docker Compose stack:
docker compose up
This starts:
- openfga-migrate — runs database migrations against the
openfgaPostgreSQL database - openfga — the API server on port 5080, with the playground on port 5082
Creating a store
After the first docker compose up, create a store and authorization model:
# Create a store
curl -X POST http://localhost:5080/stores \
-H 'Content-Type: application/json' \
-d '{"name": "chompardo"}'
# Returns: {"id": "<STORE_ID>", ...}
# Set the store ID in your .env
echo "FGA_STORE_ID=<STORE_ID>" >> apps/api/.env
Writing an authorization model
The placeholder model is in internal/shared/authz/model.fga:
model
schema 1.1
type user
type recipe
relations
define owner: [user]
define editor: [user] or owner
define viewer: [user] or editor
Upload it via the OpenFGA API or use the playground UI to create and test models interactively.
Usage
Checking permissions in handlers
Use the authz.Client interface directly:
allowed, err := authzClient.Check(ctx, "user:alice", "viewer", "recipe:123")
if err != nil {
// OpenFGA error — return 500
}
if !allowed {
// User doesn't have access — return 403
}
Using the middleware
RequireRelation returns chi-compatible middleware for per-route authorization:
r.With(authz.RequireRelation(
authzClient,
"viewer",
func(r *http.Request) string { return "user:" + getUserID(r) },
func(r *http.Request) string { return "recipe:" + chi.URLParam(r, "id") },
)).Get("/recipes/{id}", handler.GetRecipe)
The middleware returns:
- 403 when the user is denied access
- 500 when the OpenFGA check itself fails (network error, misconfiguration)
Writing tuples
When creating resources, write ownership tuples:
err := authzClient.WriteTuples(ctx, []authz.Tuple{
{User: "user:alice", Relation: "owner", Object: "recipe:123"},
})
Listing accessible objects
Find all objects a user can access:
objects, err := authzClient.ListObjects(ctx, "user:alice", "viewer", "recipe")
// objects: ["recipe:123", "recipe:456"]
Architecture
┌─────────────┐ ┌──────────────┐ ┌──────────┐
│ Go API │────▶│ OpenFGA │────▶│ Postgres │
│ (authz pkg)│ │ (port 5080) │ │ (openfga │
│ │ │ │ │ database)│
└─────────────┘ └──────────────┘ └──────────┘
The authz package is injected into modules via the Register() function, following the same dependency injection pattern as analytics and feature flags.
File Map
| File | Responsibility |
|---|---|
internal/shared/authz/authz.go | Config, Client interface, OpenFGA wrapper, noop client |
internal/shared/authz/middleware.go | RequireRelation per-handler middleware |
internal/shared/authz/model.fga | Placeholder authorization model |
internal/shared/authz/authz_test.go | Unit tests for client, noop, and middleware |