OpenFGA Fine Grained Authorization - Liturgical-Calendar/LiturgicalCalendarAPI GitHub Wiki

The API uses OpenFGA — an open-source, Zanzibar-style relationship-based authorization system — for fine-grained, per-resource permissions. OpenFGA complements Zitadel: Zitadel answers "does this user hold the calendar_editor role?", while OpenFGA answers "may this specific user edit this specific calendar?". The ecclesial rationale behind the model is explained in Calendar Governance Model.

Two-layer authorization

Protected write routes pass through both layers in order:

  1. AuthorizationMiddleware — verifies the required Zitadel role (calendar_editor, test_editor, etc.) from the OIDC token. Users with the admin role bypass all further checks.
  2. OpenFgaAuthorizationMiddleware — asks OpenFGA whether the user holds the required relation on the specific resource being modified. The check is fail-closed: if the resource scope cannot be resolved, the request is rejected with 403 Forbidden.

OpenFGA checks use the tuple format user:{zitadel_user_id} → relation → {object_type}:{object_id} (for example: is user:284735… an editor of national_calendar:IT?).

The authorization model

The model is defined in scripts/openfga-model.json and loaded into the OpenFGA store by scripts/setup-openfga.sh. It defines the following object types:

Object type Object IDs Represents
national_calendar Nation codes (IT, US, VA, ...) National calendar definitions
diocesan_calendar Diocese IDs (romamo_it, rotter_nl, ...) Diocesan calendar definitions
wider_region Region names (Americas, Europe, Asia) Shared regional calendar layer
general_roman_calendar temporale, EDITIO_TYPICA_1970/2002/2008, decrees Universal calendar source data
national_calendar_test Nation codes All tests scoped to a nation
diocesan_calendar_test Diocese IDs All tests scoped to a diocese
general_roman_calendar_test general_roman_calendar (single fixed ID) Tests for the universal calendar

Every type defines the relations admin, editor, and viewer as a superset chain: admin implies editor, and editor implies viewer. The wider_region type additionally defines a member_nation relation through which governance is inherited from member national calendars. In OpenFGA DSL notation:

type wider_region
  relations
    define member_nation: [national_calendar]
    define admin: [user] or admin from member_nation
    define editor: [user] or admin
    define viewer: [user] or editor or admin

Membership tuples (e.g. wider_region:Americas member_nation national_calendar:US) are seeded from each nation's metadata.wider_region field by scripts/seed-wider-region-membership.php, so an admin of any member national calendar automatically holds admin on the wider region.

Note: earlier versions of the model had a separate deleter relation and a generic test_definition type. These were replaced by the admin superset design and the calendar-scoped test types; migration scripts (scripts/migrate-deleter-tuples.php, scripts/migrate-test-tuples.php) handled the tuple conversion.

HTTP method to relation mapping

The middleware maps the HTTP method of a write request to the relation required on the target resource:

Method Calendar data routes Test routes Rationale
PUT admin editor Creating a calendar is a governance act; creating a test is an editorial act
PATCH editor editor Editing data is stewardship
DELETE admin admin Removing a resource is a governance act

Protected routes

Route Object checked
/data/nation/{id} national_calendar:{id}
/data/diocese/{id} diocesan_calendar:{id}
/data/widerregion/{id} wider_region:{id}
/temporale general_roman_calendar:temporale
/decrees general_roman_calendar:decrees
/missals/{id} general_roman_calendar:{id} for editiones typicae; national_calendar:{nation} for national missals
/tests/{test_name} Resolved from the test's scope (see below)

Test scope resolution

For /tests routes, TestScopeResolver reads the test definition's applies_to field to determine which calendar the test belongs to:

  • {"diocesan_calendar": "<id>"} → checks diocesan_calendar_test:<id>
  • {"national_calendar": "<code>"} → checks national_calendar_test:<code>
  • absent or empty → checks general_roman_calendar_test:general_roman_calendar

Permissions are therefore granted per calendar scope, not per individual test: an editor of national_calendar_test:NL may create and edit any test that applies to the Dutch national calendar.

Tuple lifecycle: the outbox pattern

Permission tuples are never written to OpenFGA directly inside a request transaction. Instead, every tuple write or delete goes through a PostgreSQL outbox (openfga_outbox table), which makes the business operation and the authorization change atomic and survivable:

  1. The business write (e.g. approving an access request) and the outbox rows are committed in a single PostgreSQL transaction.
  2. A synchronous fast path immediately attempts the OpenFGA write; in the healthy case the tuple is live within milliseconds of the commit.
  3. If OpenFGA is unavailable, the row is rescheduled with exponential backoff (1s → 512s, up to 10 attempts) and picked up by a Redis Streams consumer (litcal:reconcile-stream, systemd service liturgical-calendar-reconciler).
  4. A cron backstop (every 5 minutes) sweeps for orphaned pending rows, covering crashes between the database commit and the stream notification.

An OutboxClassifier distinguishes benign conflicts (tuple already exists / already deleted — treated as success), transient errors (retried), and terminal errors such as validation failures (marked failed_terminal, never retried). The GET /health endpoint reports outbox counts per status so operators can detect a stuck queue. See docs/ops/openfga-outbox-runbook.md in the API repository for operational procedures.

Tuple cleanup rules

  • Resource deletion purges editor and viewer tuples for the deleted object but preserves admin tuples — governance survives data deletion (see Calendar Governance Model).
  • Role revocation cascades: when a user's Zitadel role is revoked, all of their tuples, including admin, are removed.
  • Reconciliation sweep: scripts/reconcile-resource-tuples.php (with --dry-run / --apply) periodically removes operational tuples that point at resources which no longer exist.

Management endpoints

Method Route Purpose
POST /auth/access-requests Submit an access request (role and/or permissions)
GET /auth/access-requests View own access requests
GET /auth/access-requests/status Check current access status
GET /admin/access-requests List access requests (admin)
POST /admin/access-requests/{id}/approve Approve — grants role and writes tuples via outbox
POST /admin/access-requests/{id}/reject Reject an access request
POST /admin/access-requests/{id}/revoke Revoke — removes role and cascades tuples
GET /admin/permissions List OpenFGA tuples directly (admin)

Deployment and configuration

OpenFGA runs as a Docker Compose service (openfga/openfga:v1.8.12) backed by its own PostgreSQL database, with a companion openfga-migrate service for schema migrations. The HTTP API listens on port 8083 (host-mapped, localhost only) and gRPC on 8084.

scripts/setup-openfga.sh creates the store, loads the authorization model, and (with --update-env) writes the resulting IDs into the environment files:

./scripts/setup-openfga.sh --update-env

The API is configured through these environment variables:

OPENFGA_API_URL=http://localhost:8083   # OpenFGA HTTP endpoint
OPENFGA_STORE_ID=<store-id>             # From setup-openfga.sh
OPENFGA_MODEL_ID=<model-id>             # Pinned authorization model version
OPENFGA_API_TOKEN=<preshared-key>       # Optional, when OpenFGA auth is enabled

# Outbox reconciliation
REDIS_OUTBOX_STREAM=litcal:reconcile-stream
REDIS_OUTBOX_GROUP=reconciler
REDIS_OUTBOX_CONSUMER_NAME=             # Defaults to hostname
OUTBOX_MAX_ATTEMPTS=10
OUTBOX_BACKSTOP_GRACE_SECONDS=60

The OpenFGA middleware is applied conditionally: when OpenFGA is not configured, routes fall back to role-based authorization only. See Zitadel Infrastructure Setup for the full local stack.

Key source files

File Purpose
scripts/openfga-model.json Authorization model definition
scripts/setup-openfga.sh Store creation and model loading
src/Services/OpenFgaClient.php PSR-18 client for the OpenFGA API
src/Http/Middleware/OpenFgaAuthorizationMiddleware.php Per-resource permission checks
src/Services/TestScopeResolver.php Maps a test to its calendar-scoped object
src/Repositories/OutboxRepository.php Outbox persistence and state transitions
src/Services/Outbox/OutboxProcessor.php Applies outbox rows to OpenFGA
src/Services/ResourceTuplePurgeService.php Tuple purge on resource deletion
scripts/seed-wider-region-membership.php Seeds member_nation tuples
scripts/reconcile-resource-tuples.php Orphaned-tuple reconciliation sweep

Authentication & RBAC: ← Calendar Governance Model | Home | Zitadel Infrastructure Setup

⚠️ **GitHub.com Fallback** ⚠️