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.
Protected write routes pass through both layers in order:
-
AuthorizationMiddleware— verifies the required Zitadel role (calendar_editor,test_editor, etc.) from the OIDC token. Users with theadminrole bypass all further checks. -
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 with403 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 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 adminMembership 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
deleterrelation and a generictest_definitiontype. These were replaced by theadminsuperset design and the calendar-scoped test types; migration scripts (scripts/migrate-deleter-tuples.php,scripts/migrate-test-tuples.php) handled the tuple conversion.
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 |
| 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) |
For /tests routes, TestScopeResolver reads the test definition's applies_to field to determine
which calendar the test belongs to:
-
{"diocesan_calendar": "<id>"}→ checksdiocesan_calendar_test:<id> -
{"national_calendar": "<code>"}→ checksnational_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.
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:
- The business write (e.g. approving an access request) and the outbox rows are committed in a single PostgreSQL transaction.
- A synchronous fast path immediately attempts the OpenFGA write; in the healthy case the tuple is live within milliseconds of the commit.
- 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 serviceliturgical-calendar-reconciler). - 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.
-
Resource deletion purges
editorandviewertuples for the deleted object but preservesadmintuples — 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.
| 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) |
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-envThe 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=60The 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.
| 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 →