OpenFGA Authorization Model

Skill sharing is enforced with OpenFGA (a Zanzibar-style relationship engine). The authorization model (the types and relations that say who may view/edit/share/delete a skill) is the source of truth for those decisions. This page describes how that model is authored, versioned, shipped, and applied to a running OpenFGA instance.

For the model’s semantics (types, relations, the tenant/org/owner ladder) see the design RFC docs/planning/openfga-skills-authz.md. This page is about the provisioning pipeline (docs/planning/openfga-model-provisioning.md).

Lifecycle at a glance

 deploy/openfga/skills.fga            ── the DSL you edit (human-readable)
        │
        │  scripts/fga_convert.sh      (fga model transform + version bump)
        ▼
 deploy/openfga/models/skills.model.json   ── generated JSON, committed, versioned
        │                                      carries __stateful_responses_annotations__
        │  CI (fga-model.yml) verifies + bumps
        │  Dockerfile COPY  → baked into the image
        ▼
 container start ── OpenFgaModelProvisioner
        1. read the version from the shipped JSON
        2. read the applied version from Postgres (openfga_model_versions)
        3. resolve/create the OpenFGA store (by name)
        4. write the model only if the shipped version is newer (or the store has none)
        5. record the new version in Postgres

The DSL is the thing humans edit; the JSON is a generated build artifact; the version number is what makes restarts cheap and rollouts deterministic.

The stateful_responses_annotations block

Every generated model JSON carries one extra top-level key we own:

"__stateful_responses_annotations__": {
  "version": 4,
  "source": "skills.fga",
  "model_sha256": "9f2c…",
  "generated_at": "2026-09-24T00:00:00Z"
}
  • version: a monotonic integer, the authority for "is this newer than what’s deployed". Bumped automatically when the model body actually changes.

  • model_sha256: hash of the canonical model body (annotations excluded); used to decide whether the version needs bumping and to detect drift.

  • source / generated_at: provenance only.

OpenFGA rejects unknown top-level fields, so the provisioner strips this block before sending the model to the API. It is metadata about the model, carried in the same file for convenience, never part of the wire model.

Changing the model

  1. Edit deploy/openfga/skills.fga.

  2. Regenerate the JSON:

    scripts/fga_convert.sh

    If the model body changed, the version is bumped by one and model_sha256/generated_at are updated. If nothing changed, the file is left untouched.

  3. Commit both files (skills.fga and models/skills.model.json).

  4. The fga-verify pre-commit hook and the OpenFGA Model CI workflow verify the committed JSON matches the DSL. CI is verify-only: it never commits; if you changed the DSL without regenerating (so the version was not bumped), the workflow fails and asks you to run scripts/fga_convert.sh and commit.

Verify locally without writing:

scripts/fga_verify.sh

Both scripts run the OpenFGA CLI in a container, so no host install is needed (jq is the only host dependency; override the image with OPENFGA_CLI_IMAGE).

Runtime behaviour (startup)

On every (re)start, when OpenFGA is configured (OPENFGA_API_URL set), the app:

  • resolves the store: a pinned OPENFGA_STORE_ID is verified (boot fails if it doesn’t exist, no name fallback); otherwise the store is resolved from OPENFGA_STORE_NAME, creating it if no store has that name;

  • compares the shipped JSON version and the full document against what Postgres recorded (the applied version and the model JSON, stored as JSONB with the annotation block):

    • equal version and matching JSON → no write (the common restart case);

    • equal version but the JSON drifted → re-writes and refreshes the record (the version alone is not trusted, the stored content must match too);

    • shipped newer → writes the model, records the new version + JSON;

    • shipped older (rollback to an earlier image) → no write, logs a warning (the model is never downgraded);

    • store has no model (e.g. the OpenFGA datastore was wiped) → re-writes even if the version matches.

Provisioning is guarded by a Postgres advisory lock, so concurrently restarting replicas write the model at most once. Any failure fails startup rather than serving with a stale or missing model (fail-closed).

Configuration

Env var Default Meaning

OPENFGA_API_URL

(unset)

OpenFGA HTTP API. Unset ⇒ Postgres owner/public subset, no provisioning.

OPENFGA_STORE_NAME

stateful-responses (compose)

Store resolved by name; created if absent. Used when OPENFGA_STORE_ID is not pinned.

OPENFGA_STORE_ID

(unset)

Pin an existing store id, verified at boot (startup fails if it doesn’t exist; no name fallback).

OPENFGA_API_TOKEN

(unset)

Preshared bearer token; only when OpenFGA runs with authn-method=preshared.

OPENFGA_MANAGE_MODEL

true

false ⇒ another service owns the model; only bind the store, never write.

OPENFGA_MODELS_DIR

/app/deploy/openfga/models

Where the shipped model JSONs live.

OPENFGA_SHERLOCK_STORE_NAME

(unset)

Name of Sherlock’s own OpenFGA store (same instance, different store). When set, skills callers are enrolled into Sherlock’s public / per-app skills groups so public / app-public skills are byte-readable; unset ⇒ no enrolment (public byte-sharing effectively disabled). See the request-time flow below.

Request-time authorization data flow

Startup provisions the model; at request time the app both reads and writes OpenFGA, and against two different stores on the same OpenFGA instance:

  1. Permission checks (reads), the skill authz store. Every skills operation gates on a computed permission (can_view / can_edit / can_share / can_admin) for skill:<id>. For app-public visibility the check carries a contextual accessing tuple built from the caller’s application_id (user:<id> accessing application:<app>, never stored) so a skill’s in_shared_app resolves for the caller’s current app.

  2. Share grants (writes), the skill authz store. POST/DELETE /share/skill persists relationship tuples (viewer / editor, the organization / tenant usersets, the user:* public grants, and shared_in_app application:<app> for public_scope="app"). Writes use on_duplicate: ignore / on_missing: ignore so a re-grant or partial/permission-less revoke is a server-side no-op.

  3. Group-membership enrolment (writes), Sherlock’s OWN OpenFGA store. This is a request-time write that is easy to miss: on skills-related requests (POST /v1/responses, /v1/skills*, /share*) the app best-effort enrols the caller into Sherlock’s skills groups by writing membership tuples to Sherlock’s OpenFGA store (a different store, resolved from OPENFGA_SHERLOCK_STORE_NAME), using the bare user:<id> subject in both cases:

    • user:<id> member group:skill_public: the everyone/public group;

    • user:<id> member group:skill_app_<app>_public: the per-app group (one per app the caller connects through).

    A skill shared public / app-public grants the matching group store-viewer access, so this enrolment is what lets the skill’s bytes be read. It does not re-enforce per-app scoping: that is already done upstream by the can_view gate (step 1), which fronts every skill byte read, so by the time bytes are fetched the app scope has been decided. The per-app group simply bounds the Sherlock-layer population to callers who have used that app (tighter than skill_public), and the bare subject keeps it consistent with the public group. The write is fire-and-forget (off the request’s critical path), idempotent (on_duplicate: ignore, with an "already exists" fallback for older servers), and monotonic (members are never removed). See adapters/outbound/authorization/sherlock_group_mgmt.py.

The C4 request dynamic view (docs/architecture/structurizr/) models (1) as "Check permissions" and (3) as "Enrol caller into the public / per-app skills group (membership write to Sherlock’s store)".

Operator runbook

  • Which version is live?

    -- One row per (source, store_id): the applied version is per target store, so a
    -- changed store id/name is provisioned rather than masked by an old record.
    SELECT source, store_id, version, model_id, applied_at FROM openfga_model_versions;
    -- The exact applied document (annotations included) is kept in the JSONB column:
    SELECT model_json FROM openfga_model_versions
     WHERE source = 'skills.fga' AND store_id = '<id>';
  • Force a re-apply: deploy an image whose JSON version is higher (the normal path is: edit the DSL, fga_convert.sh, merge, deploy).

  • Rollback: deploying an older image is safe: the provisioner detects the lower version and leaves the newer model in place. If you genuinely need to revert the model, roll the DSL back, regenerate (version bumps forward), and deploy.

  • Store/model out of sync: if the OpenFGA datastore was reset, restarting the app re-writes the model automatically (the store-has-no-model check).

Local development

docker-compose.yml runs OpenFGA against the shared Postgres and lets the app provision the model on startup, there is no manual openfga/cli model write step. Enable it by setting OPENFGA_API_URL=http://openfga:8080 in your .env; leave it unset to use the Postgres owner/public subset.