Skip to main content

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:

  • Client interfaceCheck, ListObjects, and WriteTuples operations
  • RequireRelation middleware — per-handler authorization checks that return 403 or 500
  • Noop fallback — when FGA_STORE_ID is unset, all checks pass (for local development without OpenFGA)

Configuration

VariableDefaultRequiredDescription
FGA_API_URLhttp://localhost:5080NoOpenFGA HTTP API URL
FGA_STORE_IDNoOpenFGA 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 openfga PostgreSQL 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

FileResponsibility
internal/shared/authz/authz.goConfig, Client interface, OpenFGA wrapper, noop client
internal/shared/authz/middleware.goRequireRelation per-handler middleware
internal/shared/authz/model.fgaPlaceholder authorization model
internal/shared/authz/authz_test.goUnit tests for client, noop, and middleware