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
-
Edit
deploy/openfga/skills.fga. -
Regenerate the JSON:
scripts/fga_convert.shIf the model body changed, the
versionis bumped by one andmodel_sha256/generated_atare updated. If nothing changed, the file is left untouched. -
Commit both files (
skills.fgaandmodels/skills.model.json). -
The
fga-verifypre-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 runscripts/fga_convert.shand 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_IDis verified (boot fails if it doesn’t exist, no name fallback); otherwise the store is resolved fromOPENFGA_STORE_NAME, creating it if no store has that name; -
compares the shipped JSON
versionand 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 |
|---|---|---|
|
(unset) |
OpenFGA HTTP API. Unset ⇒ Postgres owner/public subset, no provisioning. |
|
|
Store resolved by name; created if absent. Used when |
|
(unset) |
Pin an existing store id, verified at boot (startup fails if it doesn’t exist; no name fallback). |
|
(unset) |
Preshared bearer token; only when OpenFGA runs with |
|
|
|
|
|
Where the shipped model JSONs live. |
|
(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:
-
Permission checks (reads), the skill authz store. Every skills operation gates on a computed permission (
can_view/can_edit/can_share/can_admin) forskill:<id>. For app-public visibility the check carries a contextualaccessingtuple built from the caller’sapplication_id(user:<id> accessing application:<app>, never stored) so a skill’sin_shared_appresolves for the caller’s current app. -
Share grants (writes), the skill authz store.
POST/DELETE /share/skillpersists relationship tuples (viewer/editor, theorganization/tenantusersets, theuser:*public grants, andshared_in_app application:<app>forpublic_scope="app"). Writes useon_duplicate: ignore/on_missing: ignoreso a re-grant or partial/permission-less revoke is a server-side no-op. -
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 fromOPENFGA_SHERLOCK_STORE_NAME), using the bareuser:<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_viewgate (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 thanskill_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). Seeadapters/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
versionis 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.