diff --git a/auth/handoffs/liturgicalcalendar.md b/auth/handoffs/liturgicalcalendar.md index 9cc19a2..978aa1d 100644 --- a/auth/handoffs/liturgicalcalendar.md +++ b/auth/handoffs/liturgicalcalendar.md @@ -30,13 +30,31 @@ Generated by `setup-zitadel.sh --provision-litcal` + `setup-openfga.sh --create- - `calendar_editor` — Calendar Editor - `test_editor` — Test Definition Author +Everything in this Zitadel section was re-verified against production on 2026-08-04 — Org/Project IDs, both app IDs and client IDs, both auth methods, all four role keys, and the frontend's registered redirect and post-logout URIs all match exactly as recorded. + ## OpenFGA - **API URL**: `https://authz.catholicdigitalcommons.org` - **Store name**: `LiturgicalCalendar` - **Store ID**: `01KRSCF4GVX0X4ZNXXJQEC4XXJ` -- **Authorization model ID**: `01KRSCF4K9W2EWZ1X2PP1QVH3B` -- **Model source**: `cdcf-infra/auth/models/LiturgicalCalendar.json` — lifted verbatim from `LiturgicalCalendarAPI/scripts/openfga-model.json` (schema 1.1; types: `user`, `wider_region`, `national_calendar`, `diocesan_calendar`, `test_definition`; each with `admin`/`viewer`/`editor`/`deleter` relations). +- **Authorization model ID**: `01KW4FW2ZCT1E693PY8D9TJEFM` — the latest model in the store, uploaded 2026-06-27T12:11Z. Supersedes `01KW40P7AM87W4Y864D2RZDR0B` (same day, 07:46Z) and `01KRSCF4K9W2EWZ1X2PP1QVH3B` (the original 2026-05-16 upload this handoff used to record). +- **Model source**: **`LiturgicalCalendarAPI/scripts/openfga-model.json` — that repo, not this one.** Schema 1.1. Deployed types: `user`, `wider_region`, `national_calendar`, `diocesan_calendar`, `general_roman_calendar`, `national_calendar_test`, `diocesan_calendar_test`, `general_roman_calendar_test`; relations are `admin`/`editor`/`viewer` throughout, plus `member_nation` on `wider_region`. Verified against the live store on 2026-08-04. + + Both `test_definition` and the `deleter` relation are **gone** — dropped by the LitCal team in `ea6fdd6c` ("drop test_definition type") and `76033bfb` ("admin-superset model … drop deleter"), with the calendar-scoped test types added in `2060b19a`. Any consumer still checking `deleter` or `test_definition` is checking against a relation that no longer exists **in the current/latest model** — consumers pinned to an earlier model ID (e.g. `01KRSCF4K9W2EWZ1X2PP1QVH3B`) still see it, since a pin names a specific model ID. + +### ⚠ The copy in this repo is stale — do not re-run `--create-litcal-store` + +`cdcf-infra/auth/models/LiturgicalCalendar.json` has not been touched since 2026-05-16 (`dd343e5`), so it still describes the original model. The LitCal team has since evolved the model twice in their own repo and uploaded it directly to the shared store. + +`upload_model_if_changed` in `setup-openfga.sh` compares the store's **latest** model against that file and uploads a new version when they differ. They differ. So running: + +```bash +./setup-openfga.sh --target production --create-litcal-store # ⚠ NOT safe right now +``` + +would push the May model back on top as the new latest — silently reverting `general_roman_calendar`, the three `*_test` types and `member_nation`, and resurrecting `test_definition`/`deleter`. Existing pinned consumers would be unaffected (a pin names a specific model ID), but anything resolving "latest" would regress. + +Until `auth/models/LiturgicalCalendar.json` is re-synced from `LiturgicalCalendarAPI/scripts/openfga-model.json`, treat the LitCal store's model as **owned by the LitCal repo** and leave it alone from here. Store creation and tuple seeding are unaffected; it is only the model upload that is unsafe. ## Out-of-band (delivered separately, not in this repo) @@ -55,7 +73,7 @@ ZITADEL_CLIENT_ID=373246751403933699 # ← the API Backend client_id (aud OPENFGA_API_URL=https://authz.catholicdigitalcommons.org OPENFGA_STORE_ID=01KRSCF4GVX0X4ZNXXJQEC4XXJ -OPENFGA_MODEL_ID=01KRSCF4K9W2EWZ1X2PP1QVH3B +OPENFGA_MODEL_ID=01KW4FW2ZCT1E693PY8D9TJEFM # ← latest as of 2026-08-04; was 01KRSCF4K9W2EWZ1X2PP1QVH3B # OPENFGA_PRESHARED_KEY= ``` diff --git a/docs/SYSADMIN.md b/docs/SYSADMIN.md index 6857c8f..aec81e5 100644 --- a/docs/SYSADMIN.md +++ b/docs/SYSADMIN.md @@ -56,7 +56,7 @@ Discussion: . Summary | Internal nginx proxy | `nginx:alpine` (config in `auth/nginx/zitadel.conf`) | same | | OpenFGA | `openfga/openfga:v1.15.1` | same | | OpenFGA migrate (one-shot) | `openfga/openfga:v1.15.1` | same | -| Authz model (LitCal) | `auth/models/LiturgicalCalendar.json` | lifted verbatim from `LiturgicalCalendarAPI/scripts/openfga-model.json` | +| Authz model (LitCal) | `auth/models/LiturgicalCalendar.json` | originally lifted verbatim from `LiturgicalCalendarAPI/scripts/openfga-model.json`, but **frozen at the 2026-05-16 version and two revisions behind the deployed model** — that repo now owns it, see the §4.8 warning | | Authz model (Martyrology) | `auth/models/Martyrology.json` + `auth/models/Martyrology.tuples.json` | this repo — the `.tuples.json` carries the structural `edition → governed_by → governance_body` wiring seeded at store creation | | Setup script — Zitadel | `auth/setup-zitadel.sh` | this repo | | Setup script — OpenFGA | `auth/setup-openfga.sh` | this repo | @@ -240,10 +240,12 @@ After successful run, you can sign in to the admin console at `https://auth.cath ### 4.8 OpenFGA stores + models [📜 CLI script] ```bash -./setup-openfga.sh --target production --create-litcal-store +./setup-openfga.sh --target production --create-litcal-store # ⚠ see warning below ./setup-openfga.sh --target production --create-martyrology-store ``` +⚠ **`--create-litcal-store` is not safe to re-run as of 2026-08-04.** The LitCal model is now evolved in `LiturgicalCalendarAPI/scripts/openfga-model.json` and uploaded to the shared store from there; `auth/models/LiturgicalCalendar.json` here has been frozen since 2026-05-16 and is two revisions behind. Because `upload_model_if_changed` uploads whenever the file differs from the store's latest, re-running it would push the stale May model on top as the new latest. See [`handoffs/liturgicalcalendar.md`](../auth/handoffs/liturgicalcalendar.md) → "The copy in this repo is stale". The command is safe again once the file is re-synced from the LitCal repo. + Each action creates the store (idempotent) and uploads `auth/models/.json` as the authorization model (idempotent — re-uploads only if the file diverges from what's already in the store). Prints the store ID + model ID. Where an `auth/models/.tuples.json` file exists it is also seeded — currently only Martyrology has one, carrying the structural `edition → governed_by → governance_body` tuples the model is useless without. Only tuples missing from the store are written; tuples present in the store but absent from the file are reported as drift and left alone (the script never deletes). Human role grants (`user:` → reader/editor/admin) are **not** seeded — they are per-person operator actions, see [`auth/handoffs/martyrology.md`](../auth/handoffs/martyrology.md). @@ -311,7 +313,7 @@ A snapshot, not a source of truth: the per-property handoffs in [`auth/handoffs/ | Org | Zitadel Project | Project roles | OpenFGA store | |---|---|---|---| | `CDCF` | `CDCF Website` (`376050623310725125`) | `subscriber`, `contributor`, `author`, `editor`, `administrator` | none — by design, see [`handoffs/cdcf-website.md`](../auth/handoffs/cdcf-website.md) | -| `LiturgicalCalendar` | `LiturgicalCalendarAPI` (`373246750732845059`) | `admin`, `developer`, `calendar_editor`, `test_editor` | `LiturgicalCalendar` (`01KRSCF4GVX0X4ZNXXJQEC4XXJ`) | +| `LiturgicalCalendar` | `LiturgicalCalendarAPI` (`373246750732845059`) | `admin`, `developer`, `calendar_editor`, `test_editor` | `LiturgicalCalendar` (`01KRSCF4GVX0X4ZNXXJQEC4XXJ`) — model owned by the LitCal repo, see §4.8 warning | | `Martyrology` | `MartyrologyAPI` (`384518610174869507`) | `admin`, `martyrology_editor`, `developer` | `Martyrology` (`01KZ1M9NJR1JHTMTV091X5DMYZ`) | | `BibleGet` | none — pre-provisioned Org stub | — | none | | `OntoKit` | none — pre-provisioned Org stub | — | none | diff --git a/docs/superpowers/plans/2026-08-03-martyrology-oidc-login-client.md b/docs/superpowers/plans/2026-08-03-martyrology-oidc-login-client.md index 7fe7ce1..82a4811 100644 --- a/docs/superpowers/plans/2026-08-03-martyrology-oidc-login-client.md +++ b/docs/superpowers/plans/2026-08-03-martyrology-oidc-login-client.md @@ -4,7 +4,7 @@ **Goal:** Let a human sign in to the Martyrology API as themselves, so a licensed reader receives unredacted restricted texts instead of the anonymous redaction fallback. -**Architecture:** Two confidential OIDC Web apps are provisioned in the existing `MartyrologyAPI` Zitadel project by `cdcf-infra/auth/setup-zitadel.sh`. `martyrology-frontend` gains Auth.js v5, which keeps the access token in an encrypted httpOnly cookie; its existing server-side proxy at `app/api/mr/[...path]/route.ts` attaches that token as a bearer header on the upstream call. The browser never holds a token and `martyrology-api` is not modified. +**Architecture:** A confidential OIDC Web app is provisioned in the existing `MartyrologyAPI` Zitadel project by `cdcf-infra/auth/setup-zitadel.sh`, one per instance (production; local separately). `martyrology-frontend` gains Auth.js v5, which keeps the access token in an encrypted httpOnly cookie; its existing server-side proxy at `app/api/mr/[...path]/route.ts` attaches that token as a bearer header on the upstream call. The browser never holds a token and `martyrology-api` is not modified. **Tech Stack:** Bash + Zitadel Management API v2 (`cdcf-infra`); Next.js 16 App Router, React 19, TypeScript, Auth.js v5 (`next-auth@5.0.0-beta.31`), Vitest + Testing Library (`martyrology-frontend`). @@ -395,32 +395,38 @@ shred -u /tmp/martyrology-token.json 2>/dev/null || rm -f /tmp/martyrology-token --- -> ## Tasks 3-7 are stale pending the local-stack design +> ## Tasks 3, 5-7: local verification now runs against the local stack > -> **Added 2026-08-03.** These tasks were written assuming a localhost Zitadel -> client existed, so several of their verification steps cannot be performed as -> written. Do not execute them until they have been revised. +> **Added 2026-08-03, resolved 2026-08-04.** These tasks were written assuming a +> localhost Zitadel client existed. It does — but in the *local* Zitadel, not the +> production one, which is the spec's D3 rather than a departure from it. > -> Specifically affected: +> Two things landed since: > -> - **Task 3 Step 9** — the `.env.local` block uses the Dev app's credentials and -> runs a live sign-in against `localhost:3000`. No such client exists. -> - **Task 5 Step 8** — the browser acceptance sequence runs against -> `localhost:3000` while signed in. -> - **Task 6 Step 2** — instructs generating an `AUTH_SECRET` distinct from "the -> dev value". -> - **Task 7 Step 1** — the handoff table lists both apps. -> - **Task 7 Step 3** — the README section documents local sign-in with the Dev -> client. -> - **Task 7 Step 5** — the issue-close comment claims two apps exist and that -> "both apps live in the MartyrologyAPI project". Do not post it as written. +> - **`--provision-martyrology-frontend` is target-aware** (PR #23). `--target local` +> registers `http://localhost:3000/api/auth/callback/zitadel` with `devMode=true` +> against a local Zitadel; `--target production` is byte-identical to before. +> - **The local stack exists**, built on unmerged `feat/local-dev-stack` branches +> in `martyrology-api` and `martyrology-frontend` — see `martyrology-api`'s +> `docs/superpowers/specs/2026-08-04-local-development-stack-design.md`, and the +> bring-up in `martyrology-frontend`'s README. It is not yet on either repo's +> `main`. > -> The code and unit tests in Tasks 3, 4 and 5 are unaffected — they mock the -> session and never contact Zitadel. Only the live verification steps are. +> So the affected steps are performed as written, against +> `http://localhost:3000` with the local stack running, taking +> `AUTH_ZITADEL_ID` / `AUTH_ZITADEL_SECRET` from the `.env` that +> `./scripts/setup-stack.sh --update-env` writes: > -> Two ways forward, to be settled when the local-stack design lands: verify -> against the local stack once it exists, or verify against production after -> deploying and drop local sign-in from the plan entirely. +> - **Task 3 Step 9**, **Task 5 Step 8** — run against the local stack. +> - **Task 6 Step 2** — "the dev value" means the local stack's `AUTH_SECRET`, +> which `setup-stack.sh` generates. Production's must differ. +> - **Task 7 Steps 1, 3, 5** — there is **one app per instance**, not two apps in +> one instance. The handoff table lists the production app; local sign-in is +> documented as a property of the local stack. Do not claim "both apps live in +> the MartyrologyAPI project" of a single Zitadel. +> +> The code and unit tests in Tasks 3, 4 and 5 were never affected — they mock the +> session and never contact Zitadel. ### Task 3: Auth.js configuration and token refresh @@ -766,9 +772,11 @@ npx vitest run lib/__tests__/auth-callbacks.test.ts ``` Expected: PASS, 2 tests. If importing `@/auth` fails for want of environment -variables, set the four `AUTH_*` values from `.env.local` in the test run — do -not weaken the test to avoid the import, since importing the real module is what -makes it a guard rather than a restatement. +variables, set the three `AUTH_ZITADEL_ISSUER`, `AUTH_ZITADEL_ID` and +`AUTH_ZITADEL_SECRET` values — the ones `auth.ts`'s module-level `Zitadel({...})` +call reads directly — from the local stack's `.env` (see Task 3 Step 9) in the +test run — do not weaken the test to avoid the import, since importing the +real module is what makes it a guard rather than a restatement. - [ ] **Step 7: Add the type augmentation** @@ -822,20 +830,20 @@ npm run lint Expected: both clean. -Create `.env.local` (already gitignored) using the **Dev** app values from Task 2: - -```bash -AUTH_ZITADEL_ISSUER=https://auth.catholicdigitalcommons.org -AUTH_ZITADEL_ID= -AUTH_ZITADEL_SECRET= -AUTH_SECRET= -AUTH_URL=http://localhost:3000 -API_BASE=https://api.romanmartyrology.com -``` - -Then: +Run this against the local stack (`martyrology-api`'s +`docs/superpowers/specs/2026-08-04-local-development-stack-design.md`, brought +up per this repo's own README). `API_BASE` is already in the stack's `.env` +from `cp .env.example .env`, and `./scripts/setup-stack.sh --update-env` has +since added `AUTH_ZITADEL_ISSUER`, `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET`, +`AUTH_SECRET` and `AUTH_URL` to it — Next.js loads `.env` automatically, so no +separate `.env.local` is needed. Stop the containerized frontend first so port +3000 is free for the dev server (same origin, same registered callback). Run +this from the `martyrology-frontend` checkout root, where its +`docker-compose.yml` defines the `martyrology-frontend` service — running it +from `martyrology-api` instead fails with "no such service": ```bash +docker compose stop martyrology-frontend npm run dev ``` @@ -1319,7 +1327,7 @@ npx tsc --noEmit npm run dev ``` -With `.env.local` from Task 3 Step 9 still in place: +With the local stack running and its `.env` (from Task 3 Step 9) still in place: 1. Open `http://localhost:3000` — the header shows **Sign in**. 2. Open `http://localhost:3000/compare`, select `martyrologium_romanum_2004`, and confirm `EulogyView` shows the redaction fallback. @@ -1507,18 +1515,22 @@ Append to `cdcf-infra/auth/handoffs/martyrology.md`: ## Human login — the frontend OIDC clients Provisioned by `./setup-zitadel.sh --target production --provision-martyrology-frontend`. -Two confidential Web apps in the **existing `MartyrologyAPI` project** — not a -project of their own, because same-project membership is what puts +One confidential Web app in the **existing `MartyrologyAPI` project** — not a +project of its own, because project membership is what puts `urn:zitadel:iam:org:project:384518610174869507:roles` in the token without requesting an `:aud` scope. | App | Origin | devMode | Auth method | |---|---|---|---| | `Martyrology Frontend` | `https://romanmartyrology.com` | false | `client_secret_post` | -| `Martyrology Frontend (Dev)` | `http://localhost:3000` | true | `client_secret_post` | -Callback path on both: `/api/auth/callback/zitadel` — fixed by the Auth.js -provider id, so it cannot be changed on one side alone. +Local development provisions the same app name into a **separate, local-only +Zitadel instance** (`--target local`) — one app per instance, not a second app +in this project. That client's ID and secret live in the local stack's own +`.env`, not here; see `martyrology-frontend`'s README. + +Callback path: `/api/auth/callback/zitadel` — fixed by the Auth.js provider +id, so it cannot be changed without changing the provider id too. ### Secrets are not in git and not in the deploy @@ -1527,8 +1539,8 @@ romanmartyrology.com → Node.js → Custom environment variables. The deploy writes `.next/standalone/.env` with non-secret values only; a real environment variable takes precedence over that file. -Both client secrets were emitted once at creation and are unrecoverable. To -rotate: Martyrology Org → Projects → MartyrologyAPI → Apps → *app name* → +The client secret was emitted once at creation and is unrecoverable. To +rotate: Martyrology Org → Projects → MartyrologyAPI → Apps → `Martyrology Frontend` → Regenerate Client Secret, then update the Plesk environment variable. ### Verified end to end @@ -1556,19 +1568,14 @@ Add to `martyrology-frontend/README.md`: ```markdown ## Signing in locally -Local sign-in uses the `Martyrology Frontend (Dev)` Zitadel app, which is the -only one that accepts an `http://localhost:3000` redirect. Create `.env.local` -(gitignored): - - AUTH_ZITADEL_ISSUER=https://auth.catholicdigitalcommons.org - AUTH_ZITADEL_ID= - AUTH_ZITADEL_SECRET= - AUTH_SECRET= - AUTH_URL=http://localhost:3000 - API_BASE=https://api.romanmartyrology.com - -Credentials come from `cdcf-infra/auth/handoffs/martyrology.md`. Never use the -production client secret locally. +Local sign-in runs against the local stack's own Zitadel instance (see "Local +development stack" above for bring-up) — a `Martyrology Frontend` app +provisioned there by `--target local`, separate from the production app in +production's Zitadel, not a second app in production's project. +`./scripts/setup-stack.sh --update-env` writes its `AUTH_ZITADEL_ISSUER`, +`AUTH_ZITADEL_ID` and `AUTH_ZITADEL_SECRET` into this stack's `.env`, which +Next.js loads automatically — no `.env.local` needed, and never reuse the +production client secret here. Signing in is additive: anonymous browsing works unchanged, and restricted editions render redacted. Signing in as a user who holds the OpenFGA @@ -1580,11 +1587,11 @@ editions render redacted. Signing in as a user who holds the OpenFGA ```bash cd /home/johnrdorazio/development/CatholicOS_org/cdcf-infra git add auth/handoffs/martyrology.md -git commit -S -m "Record the Martyrology frontend OIDC clients in the handoff" +git commit -S -m "Record the Martyrology frontend OIDC client in the handoff" cd /home/johnrdorazio/development/CatholicOS_org/martyrology-frontend git add README.md -git commit -S -m "Document local sign-in against the dev Zitadel client" +git commit -S -m "Document local sign-in against the local stack's Zitadel instance" ``` - [ ] **Step 5: Close the issue** @@ -1592,13 +1599,13 @@ git commit -S -m "Document local sign-in against the dev Zitadel client" ```bash gh issue close 26 --repo CatholicOS/martyrology-api --comment "Closed by the OIDC login client work. -Two confidential Web apps (\`Martyrology Frontend\`, \`Martyrology Frontend (Dev)\`) now exist in the MartyrologyAPI project, and martyrology-frontend signs in against them with Auth.js v5. The access token stays server-side and rides the existing /api/mr proxy, so martyrology-api needed no changes — no CORS, no new endpoints. +A confidential Web app (\`Martyrology Frontend\`) now exists in the production MartyrologyAPI project, and martyrology-frontend signs in against it with Auth.js v5. The access token stays server-side and rides the existing /api/mr proxy, so martyrology-api needed no changes — no CORS, no new endpoints. Local development provisions the same app name into a separate, local-only Zitadel instance — one app per instance, not a second app in this project. Answers to the open questions in this issue: 1. **Who is the client?** martyrology-frontend, the existing Next.js curation app at romanmartyrology.com. It already ran a server-side proxy, so a confidential Web app fit the architecture that was there. 2. **Does curation get a UI?** Yes, but reading only for now. Phase B authoring is still unbuilt and out of scope here. -3. **Audience.** Moot — both apps live in the MartyrologyAPI project, so the roles claim appears with no \`:aud\` scope requested. Verified against a live token. +3. **Audience.** Moot — the app lives in the MartyrologyAPI project, so the roles claim appears with no \`:aud\` scope requested. Verified against a live token. 4. **Service accounts.** Unchanged; a machine user with a PAT still works and needs no new client. Verified end to end: signed in as a user holding \`can_read_texts\`, a 2004-edition elogium returns real text; signed out, the same request returns \`text: null\` with \`metadata.access = \"restricted-texts\"\`." @@ -1631,6 +1638,6 @@ Verified end to end: signed in as a user holding \`can_read_texts\`, a 2004-edit **Deviation from the spec, deliberate:** §5 says the *proxy route* gets tests for all three branches including refresh. Refresh is tested at the `lib/zitadel-token.ts` layer instead (Task 3 Step 2), because refresh happens in the Auth.js `jwt` callback, not in the route — the route only ever sees an already-refreshed session. Both branches the route can actually observe are tested there, plus two the spec did not name: a throwing session lookup and query-string preservation. -**Placeholder scan:** no TBD/TODO; every code step carries the actual code; ``-style angle brackets appear only where a runtime secret must be pasted by the operator, and each is accompanied by where to get it. +**Placeholder scan:** no TBD/TODO; every code step carries the actual code; ``-style angle brackets appear only where a runtime value must be pasted by the operator — `AUTH_ZITADEL_SECRET` and `AUTH_SECRET` are secrets, `AUTH_ZITADEL_ID` is a client ID, not a secret — and each is accompanied by where to get it. **Type consistency:** `ZitadelToken` fields (`access_token`, `refresh_token`, `expires_at`, `error`) are used identically in `lib/zitadel-token.ts`, `auth.ts`, and the tests. `expires_at` is seconds everywhere, `nowMs` is milliseconds and only appears as an `isExpired` parameter. `access_token` is declared on the `JWT` interface in `types/next-auth.d.ts` and consumed in Task 4's route via `getToken` and in Task 4's test mock; it never appears on the `Session` interface. `buildUpstreamHeaders` has one signature across its definition, test, and call site. diff --git a/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md b/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md new file mode 100644 index 0000000..50a24df --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md @@ -0,0 +1,450 @@ +# OpenFGA 1.15.1 → 1.18.2 Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move every OpenFGA deployment — the production umbrella instance and three local dev stacks — to `openfga/openfga:v1.18.2`, having first verified the image assumptions the composes depend on. + +**Architecture:** Image pin bumps only. No schema migration is involved on Postgres, so the `openfga-migrate` one-shot no-ops and rollback is a pin revert. Local stacks move first so any surprise surfaces off production; production moves last and is verified with health, model-listing and Check probes. + +**Tech Stack:** Docker / Docker Compose, Postgres, `curl`, `jq`, `gh`. + +## Global Constraints + +- Source design: `docs/superpowers/specs/2026-08-04-openfga-model-ownership-and-upgrade-design.md` §5. +- Target version is exactly `openfga/openfga:v1.18.2` everywhere. Both services in each compose file (`openfga-migrate` and `openfga`) must move together — a mismatched pair runs migrations from one version against a server of another. +- **The v1.18.0 MySQL warning does not apply here.** It concerns MySQL schema migration `008_collate_identifiers.sql` (CVE-2026-55170, CVE-2026-55689). Every deployment in scope sets `OPENFGA_DATASTORE_ENGINE: postgres`, and the Postgres migration set is identical (001–006) at v1.15.1 and v1.18.2. If any deployment is later moved to MySQL, this plan's reasoning does not carry over. +- Production is `auth/docker-compose.prod.yml` on the VPS at `/opt/cdcf-auth`. `sync-to-vps.yml` fast-forwards that checkout on merge to `main` but never runs `docker compose up` — bringing the stack up is an operator step. +- LitCal PRs target `development`. +- Do not change any other pinned image (notably `ghcr.io/zitadel/zitadel:v4.15.0`) in these PRs. + +## File Structure + +| File | Change | +| --- | --- | +| `cdcf-infra/auth/docker-compose.prod.yml:155,164` | `v1.15.1` → `v1.18.2` (both services) | +| `cdcf-infra/docs/SYSADMIN.md` | Component inventory rows for OpenFGA + migrate | +| `martyrology-api/docker-compose.yml:117,130` | `v1.15.1` → `v1.18.2` | +| `martyrology-api/docker-compose.yml:141-145` | Playground comment, if Task 1 finds it obsolete | +| `LiturgicalCalendarAPI/docker-compose.yml:235,248` | `v1.8.12` → `v1.18.2` | +| `LiturgicalCalendarFrontend/docker-compose.yml:203,216` | `v1.8.12` → `v1.18.2` | + +--- + +### Task 1: Verify the 1.18.2 image assumptions + +**Files:** none — this task produces findings the later tasks depend on. + +**Interfaces:** +- Produces three answers: (a) does `/usr/local/bin/grpc_health_probe` exist in the image, (b) does OpenFGA still refuse to start with the Playground enabled alongside preshared auth, (c) are `OPENFGA_AUTHN_METHOD` / `OPENFGA_AUTHN_PRESHARED_KEYS` still honoured. Tasks 2-5 assume (a) and (c) hold; Task 3 acts on (b). + +- [ ] **Step 1: Pull the image** + +```bash +docker pull openfga/openfga:v1.18.2 +``` + +- [ ] **Step 2: (a) Healthcheck binary** + +Both `cdcf-infra/auth/docker-compose.prod.yml` and `martyrology-api/docker-compose.yml` healthcheck with `/usr/local/bin/grpc_health_probe`. Confirm it is still there: + +```bash +docker run --rm --entrypoint ls openfga/openfga:v1.18.2 -l /usr/local/bin/grpc_health_probe +``` + +Expected: the file listed. **If it is missing**, stop and revise every affected healthcheck to `["CMD", "/usr/local/bin/openfga", "health"]` or an HTTP probe against `/healthz` before continuing — and record the change in this plan. + +- [ ] **Step 3: (c) Preshared auth still honoured** + +```bash +docker run --rm -d --name fga-1182 -p 127.0.0.1:18081:8080 \ + -e OPENFGA_AUTHN_METHOD=preshared \ + -e OPENFGA_AUTHN_PRESHARED_KEYS=test-key \ + openfga/openfga:v1.18.2 run +sleep 5 +echo -n "no token: "; curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18081/stores +echo -n "with token: "; curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer test-key' http://127.0.0.1:18081/stores +``` + +Expected: `401` without the token, `200` with it. + +- [ ] **Step 4: (b) Playground + preshared behaviour** + +`martyrology-api/docker-compose.yml:141-145` documents that v1.15.1 panics at startup when the Playground is enabled alongside preshared auth. Re-test at 1.18.2: + +```bash +docker rm -f fga-1182 fga-pg 2>/dev/null +docker run -d --name fga-pg -p 127.0.0.1:18082:8080 \ + -e OPENFGA_AUTHN_METHOD=preshared \ + -e OPENFGA_AUTHN_PRESHARED_KEYS=test-key \ + -e OPENFGA_PLAYGROUND_ENABLED=true \ + openfga/openfga:v1.18.2 run +sleep 5 +docker logs fga-pg 2>&1 | head -20 +docker rm -f fga-pg 2>/dev/null +``` + +Detached, without `--rm`, so this terminates either way and the crash output +survives long enough to inspect: if the server panics on startup (the +expected 1.15.1 behaviour) the container sits exited, and `docker logs` still +reads its output; if it starts successfully instead (behaviour changed), the +`sleep 5` bounds how long it runs before `docker logs` reads it. The trailing +`docker rm -f` removes the container either way. + +Expected (if unchanged): a startup error mentioning that the playground only supports authn method 'none'. Record which it is — Task 3 updates martyrology's comment only if the behaviour changed. + +- [ ] **Step 5: Confirm the migrate command no-ops against an up-to-date schema** + +```bash +docker network create fga-upgrade-test 2>/dev/null || true +docker run --rm -d --name fga-pg-db --network fga-upgrade-test \ + -e POSTGRES_PASSWORD=pw -e POSTGRES_USER=openfga -e POSTGRES_DB=openfga postgres:16 +sleep 8 +docker run --rm --network fga-upgrade-test openfga/openfga:v1.15.1 migrate \ + --datastore-engine postgres --datastore-uri 'postgres://openfga:pw@fga-pg-db:5432/openfga?sslmode=disable' +docker run --rm --network fga-upgrade-test openfga/openfga:v1.18.2 migrate \ + --datastore-engine postgres --datastore-uri 'postgres://openfga:pw@fga-pg-db:5432/openfga?sslmode=disable' +``` + +Expected: the second run reports the database is already at the target version and applies nothing. This is the empirical confirmation of the "migration-free" claim that the rest of the plan rests on. + +- [ ] **Step 6: Clean up** + +```bash +docker rm -f fga-pg-db fga-1182 fga-pg 2>/dev/null; docker network rm fga-upgrade-test 2>/dev/null; true +``` + +- [ ] **Step 7: Record findings** + +Append a short findings block to this plan file under Task 1 (binary present yes/no, playground behaviour, migrate no-op confirmed) and commit: + +```bash +git add docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md +git commit -m "Record OpenFGA 1.18.2 image verification findings" +``` + +--- + +### Task 2: Bump `martyrology-api` local stack + +**Files:** +- Modify: `/home/johnrdorazio/development/CatholicOS_org/martyrology-api/docker-compose.yml:117,130` (and `:141-145` only if Task 1 Step 4 found the playground behaviour changed) + +**Interfaces:** +- Consumes: Task 1's findings (a) and (c). + +- [ ] **Step 1: Branch** + +```bash +cd /home/johnrdorazio/development/CatholicOS_org/martyrology-api +git checkout main && git pull --ff-only +git checkout -b chore/openfga-1.18.2 +``` + +- [ ] **Step 2: Bump both pins** + +```bash +sed -i 's|openfga/openfga:v1\.15\.1|openfga/openfga:v1.18.2|g' docker-compose.yml +grep -n "openfga/openfga:" docker-compose.yml +``` + +Expected: two lines, both `v1.18.2`. + +- [ ] **Step 3: Rebuild the stack from scratch** + +```bash +docker compose down -v +docker compose up -d +docker compose ps +``` + +Expected: `openfga-migrate` exits 0, `openfga` reaches healthy (the healthcheck is the real test of Task 1's finding (a)). + +- [ ] **Step 4: Verify the seeded store still works** + +```bash +docker compose up authz-seed +docker compose logs authz-seed | tail -20 +``` + +Expected: store created, model uploaded, 11 structural tuples written. + +- [ ] **Step 5: Run the API's own test suite** + +```bash +if [ -n "$(docker compose ps -q api)" ]; then + docker compose exec api pytest -q +else + echo "api container not running; falling back to host pytest" >&2 + pytest -q +fi +``` + +Expected: pass. The fallback triggers only when the `api` container isn't +available — a real test failure inside the container must propagate, not be +masked by a host-side rerun. If the suite needs the stack's env, follow this +repo's README for the canonical invocation rather than inventing one. + +- [ ] **Step 6: Commit and PR** + +```bash +git add docker-compose.yml +git commit -m "Bump OpenFGA to v1.18.2 + +Migration-free on Postgres: the Postgres migration set is identical (001-006) +at v1.15.1 and v1.18.2, and the v1.18.0 lock warning applies only to MySQL +(schema migration 008, CVE-2026-55170 / CVE-2026-55689)." +git push -u origin chore/openfga-1.18.2 +gh pr create --base main --title "Bump OpenFGA to v1.18.2" --body "Local dev stack only. Migration-free on Postgres; the v1.18.0 MySQL warning does not apply. Verified: migrate no-ops against an up-to-date schema, healthcheck binary still present, preshared auth unchanged." +``` + +--- + +### Task 3: Bump the LitCal local stacks + +**Files:** +- Modify: `LiturgicalCalendarAPI/docker-compose.yml:235,248` +- Modify: `LiturgicalCalendarFrontend/docker-compose.yml:203,216` + +**Interfaces:** +- Consumes: Task 1's findings. Independent of the ownership plan — but if that plan's Task 6 has landed, the API stack also has an `authz-seed` service, which this task exercises. + +- [ ] **Step 1: API repo — branch from `development` and bump** + +```bash +cd /home/johnrdorazio/development/LiturgicalCalendar/LiturgicalCalendarAPI +git checkout development && git pull --ff-only +git checkout -b chore/openfga-1.18.2 +sed -i 's|openfga/openfga:v1\.8\.12|openfga/openfga:v1.18.2|g' docker-compose.yml +grep -n "openfga/openfga:" docker-compose.yml +``` + +Expected: two lines, both `v1.18.2`. + +- [ ] **Step 2: Rebuild and confirm the migration applies cleanly** + +This stack is coming from v1.8.12, so it genuinely applies `006_add_collate_index` — on a throwaway dev database: + +```bash +docker compose down -v +docker compose up -d db openfga-migrate +# -a: a fast one-shot may already have exited before this line runs, and +# `docker compose ps -q` without -a only lists running containers — it would +# return empty here and `docker wait ""` would fail. +MIGRATE_EXIT=$(docker wait "$(docker compose ps -a -q openfga-migrate)") +docker compose logs openfga-migrate | tail -20 +[ "$MIGRATE_EXIT" = "0" ] || { echo "openfga-migrate exited $MIGRATE_EXIT" >&2; exit 1; } +``` + +Expected: migration runs, exits 0 — asserted above, not just eyeballed in the logs. Do not proceed to Step 3 unless the assertion passed. + +- [ ] **Step 3: Bring the rest up, now that migration is confirmed clean** + +```bash +docker compose up -d +docker compose ps +``` + +Expected: `openfga` healthy. Note this compose defaults `OPENFGA_AUTHN_METHOD` to `none`, so no token is needed locally: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8083/healthz +``` + +Expected: `200`. + +- [ ] **Step 4: Commit and PR against `development`** + +```bash +git add docker-compose.yml +git commit -m "Bump OpenFGA to v1.18.2 + +Local dev stack. From v1.8.12 this applies Postgres migration 006 to the dev +database; the v1.18.0 warning is MySQL-only and does not apply here." +git push -u origin chore/openfga-1.18.2 +gh pr create --base development --title "Bump OpenFGA to v1.18.2" --body "Local dev stack only. Applies Postgres migration 006 on first up; the v1.18.0 lock warning is MySQL-specific and does not apply to this Postgres stack." +``` + +- [ ] **Step 5: Repeat for the Frontend repo** + +```bash +cd /home/johnrdorazio/development/LiturgicalCalendar/LiturgicalCalendarFrontend +git checkout development && git pull --ff-only +git checkout -b chore/openfga-1.18.2 +sed -i 's|openfga/openfga:v1\.8\.12|openfga/openfga:v1.18.2|g' docker-compose.yml +grep -n "openfga/openfga:" docker-compose.yml +docker compose down -v && docker compose up -d && docker compose ps +git add docker-compose.yml +git commit -m "Bump OpenFGA to v1.18.2 + +Local dev stack, matching the API repo's pin." +git push -u origin chore/openfga-1.18.2 +gh pr create --base development --title "Bump OpenFGA to v1.18.2" --body "Local dev stack only; keeps this repo's pin in step with LiturgicalCalendarAPI." +``` + +--- + +### Task 4: Bump the production compose in cdcf-infra + +**Files:** +- Modify: `auth/docker-compose.prod.yml:155,164` +- Modify: `docs/SYSADMIN.md` component inventory (OpenFGA and OpenFGA-migrate rows) + +**Interfaces:** +- Consumes: Task 1's findings; Tasks 2-3 having run clean on local stacks. +- Produces: the merged `main` that `sync-to-vps.yml` fast-forwards onto the VPS. Task 5 restarts the stack. + +- [ ] **Step 1: Branch and bump** + +```bash +cd /home/johnrdorazio/development/CatholicOS_org/cdcf-infra +git checkout main && git pull --ff-only +git checkout -b chore/openfga-1.18.2 +sed -i 's|openfga/openfga:v1\.15\.1|openfga/openfga:v1.18.2|g' auth/docker-compose.prod.yml +grep -n "openfga/openfga:" auth/docker-compose.prod.yml +``` + +Expected: two lines, both `v1.18.2`. Confirm nothing else moved: + +```bash +git diff --stat +grep -n "zitadel:v" auth/docker-compose.prod.yml +``` + +Expected: one file changed, two insertions/deletions; Zitadel still `v4.15.0`. + +- [ ] **Step 2: Update the component inventory** + +In `docs/SYSADMIN.md`, change both OpenFGA rows from `openfga/openfga:v1.15.1` to `openfga/openfga:v1.18.2`. + +- [ ] **Step 3: Commit, PR, merge** + +```bash +git add auth/docker-compose.prod.yml docs/SYSADMIN.md +git commit -m "Bump production OpenFGA to v1.18.2 + +Migration-free on Postgres: assets 001-006 are identical at v1.15.1 and +v1.18.2, confirmed empirically by running v1.18.2 migrate against a +v1.15.1-migrated database (no-op). The v1.18.0 operational warning covers +MySQL schema migration 008 only. Rollback is a pin revert with no schema to +undo." +git push -u origin chore/openfga-1.18.2 +gh pr create --base main --title "Bump production OpenFGA to v1.18.2" --body "Image pin only; Zitadel untouched. Migration-free on Postgres (assets 001-006 identical at both tags, verified by a no-op migrate run). The v1.18.0 MySQL lock warning does not apply. Merging only syncs the file to the VPS — an operator still has to bring the stack up (Task 5 of the plan)." +gh pr merge --merge --delete-branch +``` + +- [ ] **Step 4: Confirm the VPS received the file** + +```bash +ssh ubuntu@catholicdigitalcommons.org 'git -C /opt/cdcf-auth log --oneline -1; grep -n "openfga/openfga:" /opt/cdcf-auth/auth/docker-compose.prod.yml' +``` + +Expected: the merge commit, and both lines showing `v1.18.2`. The running containers are still 1.15.1 at this point — that is expected. + +--- + +### Task 5: Operator — restart production and verify + +**Files:** none (operator action on the VPS). + +- [ ] **Step 1: Record the pre-upgrade state** + +```bash +ssh ubuntu@catholicdigitalcommons.org 'bash -s' <<'EOS' +set -euo pipefail +cd /opt/cdcf-auth/auth +KEY=$(grep -m1 '^OPENFGA_PRESHARED_KEY=' .env.production | cut -d= -f2- | tr -d '"') +docker compose -f docker-compose.prod.yml ps --format '{{.Name}}\t{{.Image}}\t{{.Status}}' +for s in 01KRSCF4GVX0X4ZNXXJQEC4XXJ 01KZ1M9NJR1JHTMTV091X5DMYZ; do + echo -n "$s latest model: " + curl -sS "http://127.0.0.1:8081/stores/$s/authorization-models?page_size=1" -H "Authorization: Bearer $KEY" | jq -r '.authorization_models[0].id' +done +EOS +``` + +Keep this output — it is the comparison baseline for Step 4 and the rollback trigger. + +- [ ] **Step 2: Pull and recreate only the OpenFGA services** + +```bash +ssh ubuntu@catholicdigitalcommons.org +cd /opt/cdcf-auth/auth +docker compose -f docker-compose.prod.yml pull openfga openfga-migrate +docker compose -f docker-compose.prod.yml up -d openfga-migrate +# -a: a fast one-shot may already have exited before this line runs, and +# `docker compose ps -q` without -a only lists running containers — it would +# return empty here and `docker wait ""` would fail. +MIGRATE_EXIT=$(docker wait "$(docker compose -f docker-compose.prod.yml ps -a -q openfga-migrate)") +docker compose -f docker-compose.prod.yml logs --tail=30 openfga-migrate | tee /tmp/openfga-migrate.log +[ "$MIGRATE_EXIT" = "0" ] || { echo "openfga-migrate exited $MIGRATE_EXIT" >&2; exit 1; } +# Exit 0 only proves `migrate` ran without error, not that it left the store +# untouched — for an upgrade against a live store that's the weaker claim. +# `migrate` logs the pre-migration schema version as `"current version": N` +# before it applies anything (verified against the v1.15.1 image: a fresh run +# logs `"current version": 0`, a repeat run against an already-migrated +# database logs `"current version": 6` — both then print the same +# "running all migrations" / "migration done" lines, so those two lines are +# NOT a usable no-op signal on their own). The Postgres migration set is fixed +# at 001-006 (see Global Constraints), so a pre-migration version of 6 proves +# there was nothing left to apply. +grep -q '"current version": 6' /tmp/openfga-migrate.log || { echo "openfga-migrate started from a schema version other than 6 — it may have applied changes; investigate before continuing" >&2; exit 1; } +``` + +Expected: migrate exits 0 having applied nothing — asserted above before continuing. + +```bash +docker compose -f docker-compose.prod.yml up -d openfga +docker compose -f docker-compose.prod.yml ps +``` + +Expected: `cdcf-auth-openfga-1` running `openfga/openfga:v1.18.2` and healthy. Only run this once the assertion above passed. Zitadel containers are untouched. + +- [ ] **Step 3: Health probes** + +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://authz.catholicdigitalcommons.org/healthz +curl -s -o /dev/null -w '%{http_code}\n' https://auth.catholicdigitalcommons.org/debug/healthz +``` + +Expected: `200` for the OpenFGA endpoint; the Zitadel probe is there to confirm nothing else was disturbed. + +- [ ] **Step 4: Data probes — models and a real Check** + +```bash +cd /opt/cdcf-auth/auth +KEY=$(grep -m1 '^OPENFGA_PRESHARED_KEY=' .env.production | cut -d= -f2- | tr -d '"') +for s in 01KRSCF4GVX0X4ZNXXJQEC4XXJ 01KZ1M9NJR1JHTMTV091X5DMYZ; do + echo -n "$s latest model: " + curl -sS "http://127.0.0.1:8081/stores/$s/authorization-models?page_size=1" -H "Authorization: Bearer $KEY" | jq -r '.authorization_models[0].id' +done +curl -sS -X POST "http://127.0.0.1:8081/stores/01KZ1M9NJR1JHTMTV091X5DMYZ/check" \ + -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ + -d '{"authorization_model_id":"01KZ3VZC7RAAX7TEMMVAYEBPW8","tuple_key":{"user":"user:384646678734438403","relation":"can_read_texts","object":"edition:martyrologium_romanum_2004"}}' | jq . +curl -sS -X POST "http://127.0.0.1:8081/stores/01KRSCF4GVX0X4ZNXXJQEC4XXJ/check" \ + -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ + -d '{"authorization_model_id":"","tuple_key":{"user":"","relation":"viewer","object":"general_roman_calendar:general_roman_calendar"}}' | jq . +``` + +Expected: both model IDs identical to Step 1, and both Checks return +`{"allowed": true}` — the Martyrology probe exercises the superuser +inheritance path, the LiturgicalCalendar probe a known `viewer` grant on +`general_roman_calendar`, so together they exercise model evaluation end to +end in both stores rather than just liveness. Fill in the LiturgicalCalendar +`user` from a known grant (e.g. via the Zitadel console → `LiturgicalCalendar` +Org → Users, cross-referenced with `auth/handoffs/liturgicalcalendar.md`'s +role list) before running this probe for the first time, then reuse the same +tuple on future upgrades. + +- [ ] **Step 5: Rollback procedure (only if a probe fails)** + +```bash +ssh ubuntu@catholicdigitalcommons.org +cd /opt/cdcf-auth/auth +sed -i 's|openfga/openfga:v1\.18\.2|openfga/openfga:v1.15.1|g' docker-compose.prod.yml +docker compose -f docker-compose.prod.yml up -d openfga +``` + +There is no schema change to undo, so this is complete on its own. Then revert the pin in the repo (`git revert` the Task 4 merge) so the VPS checkout and `main` do not diverge — the local edit above will otherwise be clobbered by the next CI pull. + +- [ ] **Step 6: Record the outcome** + +Add a line to `docs/SYSADMIN.md` §5.4 noting the production OpenFGA version and the date it was verified, then commit and push. diff --git a/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md b/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md new file mode 100644 index 0000000..5751b53 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md @@ -0,0 +1,900 @@ +# OpenFGA Model Ownership Centralization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make cdcf-infra the single owner of every OpenFGA authorization model on the shared instance, and make it impossible for a stale model file to silently regress a store. + +**Architecture:** cdcf-infra keeps `auth/models/.json` (+ optional `.tuples.json`) and gains `auth/models/.lock.json`, recording the model ID this repo last uploaded. `upload_model_if_changed` refuses to upload when the store's latest model ID differs from the lock, unless `--force-model-upload` is passed. LiturgicalCalendarAPI stops owning its model and consumes cdcf-infra's, exactly as `martyrology-api` already does. + +**Tech Stack:** Bash 4+, `curl`, `jq`, Docker (for a throwaway OpenFGA used in verification), GitHub CLI (`gh`). + +## Global Constraints + +- Source design: `docs/superpowers/specs/2026-08-04-openfga-model-ownership-and-upgrade-design.md`. +- The lock file has exactly three fields: `store_name`, `store_id`, `model_id`. No timestamps, no commit hashes — provenance comes from the lock file's own git history. +- Production model IDs as of 2026-08-04: LiturgicalCalendar store `01KRSCF4GVX0X4ZNXXJQEC4XXJ` → model `01KW4FW2ZCT1E693PY8D9TJEFM`; Martyrology store `01KZ1M9NJR1JHTMTV091X5DMYZ` → model `01KZ3VZC7RAAX7TEMMVAYEBPW8`. +- `setup-openfga.sh` must stay idempotent and must never delete tuples. +- No secrets in any committed file. `OPENFGA_PRESHARED_KEY` stays out-of-band. +- LitCal PRs target the `development` branch (`Liturgical-Calendar` org default), not `main`. +- Production is never modified by this plan. Every production interaction is read-only verification. Applying the synced model to production is an operator step recorded in Task 8, run by hand. +- Do not run `./setup-openfga.sh --target production --create-litcal-store` at any point before Task 3 is merged — that is the regression this plan exists to prevent. + +## File Structure + +| File | Responsibility | Change | +| --- | --- | --- | +| `auth/models/LiturgicalCalendar.json` | LitCal authorization model, authoritative copy | Replaced with the deployed version | +| `auth/models/LiturgicalCalendar.lock.json` | Records the model ID cdcf-infra last uploaded to the LitCal store | Created | +| `auth/models/Martyrology.lock.json` | Same, for Martyrology | Created | +| `auth/setup-openfga.sh` | Store/model/tuple provisioning | `upload_model_if_changed` gains the lock guard; new `--force-model-upload` flag | +| `auth/README.md` | Infra reference | Documents the ownership rule + lock file | +| `docs/SYSADMIN.md` | Operator handbook | §4.8 interim warning removed, lock-file behaviour documented | +| `auth/handoffs/liturgicalcalendar.md` | LitCal handoff | Interim warning removed; model source now this repo | +| **LitCal repo** `scripts/openfga-model.json` | — | Deleted | +| **LitCal repo** `scripts/setup-openfga.sh` | Local env wiring | Reduced to reading IDs back + `--update-env`; no model upload | +| **LitCal repo** `docker-compose.yml` | Local stack | Gains `authz-seed` service | + +--- + +### Task 1: Sync the LitCal model into cdcf-infra + +**Files:** +- Modify: `auth/models/LiturgicalCalendar.json` (full replacement) +- Read-only reference: `/home/johnrdorazio/development/LiturgicalCalendar/LiturgicalCalendarAPI/scripts/openfga-model.json` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: an `auth/models/LiturgicalCalendar.json` whose normalized `type_definitions` equal the deployed model `01KW4FW2ZCT1E693PY8D9TJEFM`. Tasks 3 and 8 depend on this equality. + +- [ ] **Step 1: Write the failing check** + +Create `/tmp/model-parity-check.sh` (scratch, not committed). It compares the repo file against the LitCal source using the same normalization `upload_model_if_changed` uses: + +```bash +#!/usr/bin/env bash +set -euo pipefail +INFRA=auth/models/LiturgicalCalendar.json +SRC=/home/johnrdorazio/development/LiturgicalCalendar/LiturgicalCalendarAPI/scripts/openfga-model.json +N='walk(if type == "object" then with_entries(select(.value != null and .value != "" and (.value != {} or .key == "this"))) else . end)' +a=$(jq -cS ".type_definitions | $N" "$INFRA") +b=$(jq -cS ".type_definitions | $N" "$SRC") +[[ "$a" == "$b" ]] && echo "PARITY: yes" || echo "PARITY: no" +``` + +- [ ] **Step 2: Run it to confirm it currently fails** + +```bash +bash /tmp/model-parity-check.sh +``` + +Expected: `PARITY: no` — cdcf-infra's copy is two revisions behind. + +- [ ] **Step 3: Copy the model in** + +```bash +cp /home/johnrdorazio/development/LiturgicalCalendar/LiturgicalCalendarAPI/scripts/openfga-model.json \ + auth/models/LiturgicalCalendar.json +``` + +- [ ] **Step 4: Re-run the check** + +```bash +bash /tmp/model-parity-check.sh +``` + +Expected: `PARITY: yes`. + +- [ ] **Step 5: Confirm the deployed types are present** + +```bash +jq -r '.type_definitions[].type' auth/models/LiturgicalCalendar.json | sort | tr '\n' ' ' +``` + +Expected exactly: `diocesan_calendar diocesan_calendar_test general_roman_calendar general_roman_calendar_test national_calendar national_calendar_test user wider_region` + +There must be **no** `test_definition`, and no `deleter` relation: + +```bash +jq -r '[.type_definitions[].relations // {} | keys[]] | unique | join(" ")' auth/models/LiturgicalCalendar.json +``` + +Expected: `admin editor member_nation viewer` + +- [ ] **Step 6: Commit** + +```bash +git add auth/models/LiturgicalCalendar.json +git commit -m "Sync LiturgicalCalendar model from the LitCal repo + +The deployed model (01KW4FW2ZCT1E693PY8D9TJEFM) has been evolved twice in +LiturgicalCalendarAPI since this copy was written on 2026-05-16. Adopt it +verbatim so cdcf-infra describes what is actually running: adds +general_roman_calendar and the three *_test types, adds member_nation on +wider_region, drops test_definition and the deleter relation." +``` + +--- + +### Task 2: Add the lock-file guard to `setup-openfga.sh` + +**Files:** +- Modify: `auth/setup-openfga.sh` — `upload_model_if_changed`, `do_create_store`, the argument parser, the usage block, and the header comment. + +**Interfaces:** +- Consumes: nothing from Task 1 (independent, but commit order in this plan is 1 → 2). +- Produces: + - `lock_file_for NAME` → echoes `${MODELS_DIR}/NAME.lock.json` + - `read_lock_model_id NAME` → echoes the recorded model ID, or empty string when the lock is absent/unreadable + - `write_lock NAME STORE_ID MODEL_ID` → writes the three-field lock file + - `upload_model_if_changed STORE_ID MODEL_FILE NAME` → **note the new third parameter**; still echoes the model ID on stdout + - Global `FORCE_MODEL_UPLOAD` (`true`/`false`), set by `--force-model-upload` + +- [ ] **Step 1: Start a throwaway OpenFGA for verification** + +In-memory datastore, so nothing persists and no database is needed: + +```bash +docker run --rm -d --name fga-test -p 127.0.0.1:18081:8080 \ + -e OPENFGA_AUTHN_METHOD=preshared \ + -e OPENFGA_AUTHN_PRESHARED_KEYS=test-key \ + openfga/openfga:v1.15.1 run +sleep 5 +curl -sS -H "Authorization: Bearer test-key" http://127.0.0.1:18081/stores | jq . +``` + +Expected: `{"stores":[],...}` or an empty-ish store list — proves it is up and authenticating. + +- [ ] **Step 2: Create the local env file the script needs** + +```bash +cat > auth/.env.local <<'EOF' +OPENFGA_API_URL=http://127.0.0.1:18081 +OPENFGA_INTERNAL_URL=http://127.0.0.1:18081 +OPENFGA_PRESHARED_KEY=test-key +EOF +``` + +`auth/.env.local` is git-ignored; confirm with `git check-ignore -v auth/.env.local` before proceeding. If it is NOT ignored, stop and add it to `.gitignore` in this task. + +- [ ] **Step 3: Reproduce the regression the guard must prevent** + +Seed the throwaway store with the current model, then simulate the out-of-band upload by uploading a modified model directly, then run the script with the repo file: + +```bash +cd auth +./setup-openfga.sh --target local --create-store LiturgicalCalendar # creates store + model A +STORE=$(curl -sS -H "Authorization: Bearer test-key" http://127.0.0.1:18081/stores | jq -r '.stores[] | select(.name=="LiturgicalCalendar") | .id') +# out-of-band upload: same model minus one type, standing in for "someone else changed it" +jq '{schema_version, type_definitions: [.type_definitions[] | select(.type != "general_roman_calendar_test")]}' \ + models/LiturgicalCalendar.json > /tmp/oob-model.json +curl -sS -X POST "http://127.0.0.1:18081/stores/$STORE/authorization-models" \ + -H "Authorization: Bearer test-key" -H 'Content-Type: application/json' -d @/tmp/oob-model.json | jq -r .authorization_model_id +./setup-openfga.sh --target local --create-store LiturgicalCalendar # ← the dangerous run +cd .. +``` + +Expected **before the fix**: the last run prints `⚠ Model differs from file — uploading new version` and `✓ Uploaded model: ` — it overwrote the out-of-band model. That is the bug. + +- [ ] **Step 4: Add the lock helpers** + +In `auth/setup-openfga.sh`, immediately **above** `upload_model_if_changed()`, add: + +```bash +# --- model lock ------------------------------------------------------------ +# +# auth/models/.lock.json records the model ID THIS repo last uploaded to +# the store. Under centralized ownership (see docs/superpowers/specs/ +# 2026-08-04-openfga-model-ownership-and-upgrade-design.md) cdcf-infra is the +# only writer of models, so a store whose latest model ID is not the recorded +# one means someone uploaded out-of-band. Uploading over that would silently +# revert their work — which is exactly what nearly happened to the +# LiturgicalCalendar store on 2026-08-04 — so we refuse instead. +# +# Three fields only. No timestamp or commit hash: the lock file is committed, +# so `git log auth/models/.lock.json` is the provenance record. + +lock_file_for() { + echo "${MODELS_DIR}/${1}.lock.json" +} + +read_lock_model_id() { + local lock; lock=$(lock_file_for "$1") + [[ -f "$lock" ]] || { echo ""; return 0; } + jq -r '.model_id // empty' "$lock" 2>/dev/null || echo "" +} + +write_lock() { + local name="$1" store_id="$2" model_id="$3" + local lock; lock=$(lock_file_for "$name") + jq -n --arg n "$name" --arg s "$store_id" --arg m "$model_id" \ + '{store_name: $n, store_id: $s, model_id: $m}' > "$lock" + ok "Lock updated: $(basename "$lock") → $model_id" +} +``` + +- [ ] **Step 5: Add the guard inside `upload_model_if_changed`** + +Change the function signature line from: + +```bash +upload_model_if_changed() { + local store_id="$1" model_file="$2" +``` + +to: + +```bash +upload_model_if_changed() { + local store_id="$1" model_file="$2" name="$3" +``` + +Then, inside the `if [[ -n "$existing_model_id" ]]; then` branch, **before** the normalization comparison, insert: + +```bash + local locked_model_id; locked_model_id=$(read_lock_model_id "$name") + if [[ -n "$locked_model_id" && "$locked_model_id" != "$existing_model_id" ]]; then + if [[ "$FORCE_MODEL_UPLOAD" == "true" ]]; then + warn "Store's latest model ($existing_model_id) is not the locked one ($locked_model_id) — proceeding anyway (--force-model-upload)" + else + err "Refusing to touch the model for store '$name'." + err " store's latest: $existing_model_id" + err " lock file says: $locked_model_id" + err "Someone uploaded a model outside this repo. Uploading now would revert it." + err "Resolve by syncing $(basename "$model_file") from the source of truth and updating" + err "$(basename "$(lock_file_for "$name")"), or re-run with --force-model-upload if you" + err "really mean to replace the deployed model." + exit 7 + fi + fi +``` + +- [ ] **Step 6: Record the lock on both exit paths** + +In the "unchanged" branch, replace: + +```bash + if [[ "$server_model" == "$file_model" ]]; then + ok "Model unchanged ($existing_model_id) — no upload needed" + echo "$existing_model_id" + return 0 + fi +``` + +with: + +```bash + if [[ "$server_model" == "$file_model" ]]; then + ok "Model unchanged ($existing_model_id) — no upload needed" + [[ "$(read_lock_model_id "$name")" == "$existing_model_id" ]] \ + || write_lock "$name" "$store_id" "$existing_model_id" + echo "$existing_model_id" + return 0 + fi +``` + +And after the successful upload, replace: + +```bash + ok "Uploaded model: $model_id" + echo "$model_id" +``` + +with: + +```bash + ok "Uploaded model: $model_id" + write_lock "$name" "$store_id" "$model_id" + echo "$model_id" +``` + +- [ ] **Step 7: Handle the adoption case (lock absent, file differs)** + +Still inside the `if [[ -n "$existing_model_id" ]]` branch, replace the line: + +```bash + warn "Model differs from file — uploading new version" +``` + +with: + +```bash + if [[ -z "$locked_model_id" && "$FORCE_MODEL_UPLOAD" != "true" ]]; then + err "No lock file for store '$name' and the model file differs from the store's latest ($existing_model_id)." + err "This repo has no record of uploading that model, so it cannot tell an intended" + err "update from a stale file. Sync the file and re-run (an identical file adopts the" + err "lock silently), or pass --force-model-upload to upload this file as the new model." + exit 7 + fi + warn "Model differs from file — uploading new version" +``` + +- [ ] **Step 8: Update the single call site** + +In `do_create_store`, change: + +```bash + model_id=$(upload_model_if_changed "$store_id" "$model_file") +``` + +to: + +```bash + model_id=$(upload_model_if_changed "$store_id" "$model_file" "$name") +``` + +- [ ] **Step 9: Add the flag** + +Next to `TARGET=""` and `ACTIONS=()` near the top, add: + +```bash +FORCE_MODEL_UPLOAD="false" +``` + +In the argument `while` loop, add before `-h|--help)`: + +```bash + --force-model-upload) FORCE_MODEL_UPLOAD="true"; shift ;; +``` + +In `usage()`, under `Actions:`, add: + +```text + --force-model-upload Upload the model file even when the store's latest model + is not the one recorded in auth/models/NAME.lock.json + (i.e. someone uploaded out-of-band). Off by default. +``` + +And in the header comment block, under the `--seed-tuples` entry, add: + +```bash +# --force-model-upload Override the lock-file guard (see auth/models/*.lock.json). +# Without it, a store whose latest model was not uploaded by +# this repo is left alone and the run exits non-zero. +``` + +- [ ] **Step 10: Verify the guard blocks the regression** + +Step 3's own "dangerous run" already re-uploaded a copy matching the repo +file, so by now the store's latest model is back in sync with +`models/LiturgicalCalendar.json` and no lock file was ever written — the +"unchanged" branch would fire, not the no-lock refusal branch this step is +supposed to exercise. Recreate the out-of-band mismatch first, by re-uploading +the same modified model Step 3 used: + +```bash +STORE=$(curl -sS -H "Authorization: Bearer test-key" http://127.0.0.1:18081/stores | jq -r '.stores[] | select(.name=="LiturgicalCalendar") | .id') +OOB_MODEL=$(curl -sS -f -X POST "http://127.0.0.1:18081/stores/$STORE/authorization-models" \ + -H "Authorization: Bearer test-key" -H 'Content-Type: application/json' -d @/tmp/oob-model.json | jq -r '.authorization_model_id // empty') +[[ -n "$OOB_MODEL" && "$OOB_MODEL" != "null" ]] || { echo "out-of-band upload failed — aborting before it produces a false pass below" >&2; exit 1; } +echo "out-of-band model: $OOB_MODEL" +``` + +No lock file exists yet, and the model just uploaded no longer matches +`models/LiturgicalCalendar.json`, so the **no-lock refusal branch** must fire +— not adoption. (Adoption is the silent lock-write in the "unchanged" branch, +Step 6 above, and only applies when the file and the store's model already +match.) + +```bash +cd auth && ./setup-openfga.sh --target local --create-store LiturgicalCalendar; echo "exit=$?"; cd .. +``` + +Expected: `✗ No lock file for store 'LiturgicalCalendar' …`, `exit=7`, and **no** new model uploaded. Confirm the store's latest model is unchanged: + +```bash +curl -sS -H "Authorization: Bearer test-key" "http://127.0.0.1:18081/stores/$STORE/authorization-models?page_size=1" | jq -r '.authorization_models[0].id' +``` + +Expected: still `$OOB_MODEL`, the out-of-band model just re-uploaded above. + +- [ ] **Step 11: Verify `--force-model-upload` overrides** + +```bash +cd auth && ./setup-openfga.sh --target local --create-store LiturgicalCalendar --force-model-upload; echo "exit=$?"; cd .. +``` + +Expected: `⚠ Model differs from file — uploading new version`, `✓ Uploaded model: `, `✓ Lock updated: LiturgicalCalendar.lock.json → `, `exit=0`. A lock file now exists locally — **delete it**, because Task 3 writes the real one: + +```bash +rm -f auth/models/LiturgicalCalendar.lock.json +``` + +- [ ] **Step 12: Verify the happy path still works** + +```bash +cd auth && ./setup-openfga.sh --target local --create-store LiturgicalCalendar; echo "exit=$?"; cd .. +``` + +Expected: `✓ Model unchanged () — no upload needed`, a lock write (adoption, since the file matches), `exit=0`. Run it once more; expected: `Model unchanged`, no lock write (already correct), `exit=0`. Then clean up again: + +```bash +rm -f auth/models/LiturgicalCalendar.lock.json +docker rm -f fga-test +rm -f auth/.env.local /tmp/oob-model.json +``` + +- [ ] **Step 13: Shellcheck** + +```bash +shellcheck auth/setup-openfga.sh +``` + +Expected: no new warnings versus `git stash`-ed baseline. If `shellcheck` is unavailable, run `bash -n auth/setup-openfga.sh` and note that shellcheck was skipped. + +- [ ] **Step 14: Commit** + +```bash +git add auth/setup-openfga.sh +git commit -m "Refuse to upload a model over an out-of-band change + +setup-openfga.sh uploaded whenever the model file differed from the store's +latest, so a stale file silently replaced a newer deployed model. Record the +model ID this repo uploads in auth/models/.lock.json and refuse when the +store's latest is something else, with --force-model-upload as the deliberate +override. Verified against a throwaway in-memory OpenFGA: the run that +previously overwrote an out-of-band model now exits 7 and leaves it alone." +``` + +--- + +### Task 3: Generate the production lock files + +**Files:** +- Create: `auth/models/LiturgicalCalendar.lock.json` +- Create: `auth/models/Martyrology.lock.json` + +**Interfaces:** +- Consumes: `write_lock`'s three-field shape from Task 2. +- Produces: lock files matching production, so the first real run of either store action adopts silently instead of refusing. + +- [ ] **Step 1: Read the deployed model IDs back from production (read-only)** + +```bash +ssh ubuntu@catholicdigitalcommons.org 'bash -s' <<'EOS' +set -euo pipefail +KEY=$(grep -m1 '^OPENFGA_PRESHARED_KEY=' /opt/cdcf-auth/auth/.env.production | cut -d= -f2- | tr -d '"') +for s in 01KRSCF4GVX0X4ZNXXJQEC4XXJ 01KZ1M9NJR1JHTMTV091X5DMYZ; do + printf '%s -> ' "$s" + curl -sS "http://127.0.0.1:8081/stores/$s/authorization-models?page_size=1" \ + -H "Authorization: Bearer $KEY" | jq -r '.authorization_models[0].id' +done +EOS +``` + +Expected: `01KRSCF4GVX0X4ZNXXJQEC4XXJ -> 01KW4FW2ZCT1E693PY8D9TJEFM` and `01KZ1M9NJR1JHTMTV091X5DMYZ -> 01KZ3VZC7RAAX7TEMMVAYEBPW8`. **If either differs, stop** — something changed since 2026-08-04; use the values you just read and note the discrepancy in the commit message. + +- [ ] **Step 2: Confirm deployed model CONTENT matches the committed file, not just the ID** + +A matching ID is not enough — locking a model whose committed file doesn't +actually match what's deployed would make Step 3's lock a false record. Fetch +each deployed model's full `type_definitions` and compare against the repo +file using the exact same normalization `upload_model_if_changed` uses (see +`auth/setup-openfga.sh`'s `normalize` variable), so this check and the guard +it feeds agree on what "matches" means: + +```bash +KEY=$(ssh ubuntu@catholicdigitalcommons.org \ + "grep -m1 '^OPENFGA_PRESHARED_KEY=' /opt/cdcf-auth/auth/.env.production | cut -d= -f2- | tr -d '\"'") +NORMALIZE='walk(if type == "object" then with_entries(select(.value != null and .value != "" and (.value != {} or .key == "this"))) else . end)' + +for pair in "LiturgicalCalendar:01KRSCF4GVX0X4ZNXXJQEC4XXJ:01KW4FW2ZCT1E693PY8D9TJEFM" "Martyrology:01KZ1M9NJR1JHTMTV091X5DMYZ:01KZ3VZC7RAAX7TEMMVAYEBPW8"; do + name=${pair%%:*}; rest=${pair#*:}; store=${rest%%:*}; expected_id=${rest##*:} + ssh ubuntu@catholicdigitalcommons.org \ + "curl -sS 'http://127.0.0.1:8081/stores/$store/authorization-models' -H 'Authorization: Bearer $KEY'" \ + > "/tmp/deployed-$name.json" + # Extract BOTH the id and the content from this one response — comparing + # content alone and discarding the id it came with is how a coincidentally + # identical model at a different id would slip past this check. + deployed_id=$(jq -r '.authorization_models[0].id // empty' "/tmp/deployed-$name.json") + deployed=$(jq -cS ".authorization_models[0].type_definitions | $NORMALIZE" "/tmp/deployed-$name.json") + file=$(jq -cS ".type_definitions | $NORMALIZE" "auth/models/$name.json") + if [[ "$deployed_id" == "$expected_id" && "$deployed" == "$file" ]]; then + echo "$name: id ($deployed_id) and content match — safe to lock" + else + echo "$name: MISMATCH (deployed id=$deployed_id expected id=$expected_id) — do NOT write a lock for this store; sync auth/models/$name.json from the deployed model (or investigate) before Step 3" >&2 + fi +done +``` + +Expected: `content matches — safe to lock` for both stores. **If either reports a mismatch, stop** — do not write a lock file for that store in Step 3; resolve the file/deployed divergence first and note it in the commit message instead. + +- [ ] **Step 3: Write the lock files** + +Only for stores Step 2 confirmed match: + +```bash +jq -n '{store_name:"LiturgicalCalendar", store_id:"01KRSCF4GVX0X4ZNXXJQEC4XXJ", model_id:"01KW4FW2ZCT1E693PY8D9TJEFM"}' \ + > auth/models/LiturgicalCalendar.lock.json +jq -n '{store_name:"Martyrology", store_id:"01KZ1M9NJR1JHTMTV091X5DMYZ", model_id:"01KZ3VZC7RAAX7TEMMVAYEBPW8"}' \ + > auth/models/Martyrology.lock.json +``` + +- [ ] **Step 4: Verify shape** + +```bash +for f in auth/models/*.lock.json; do echo "$f"; jq -S . "$f"; done +``` + +Expected: each file has exactly the keys `store_name`, `store_id`, `model_id`, and nothing else. Confirm mechanically: + +```bash +jq -r 'keys | join(",")' auth/models/*.lock.json +``` + +Expected for each: `model_id,store_id,store_name`. + +- [ ] **Step 5: Commit** + +```bash +git add auth/models/LiturgicalCalendar.lock.json auth/models/Martyrology.lock.json +git commit -m "Record deployed model IDs in per-store lock files + +Read back from production on the day of the change. With these in place the +first --create-*-store run adopts silently instead of refusing, and the lock +becomes the committed record of each store's current model ID — the value the +handoff docs kept getting wrong." +``` + +--- + +### Task 4: Documentation — ownership rule, lock file, warning removal + +**Files:** +- Modify: `auth/README.md` (new subsection under the architecture pin) +- Modify: `docs/SYSADMIN.md` §4.8 (remove interim warning, document the lock), §5.4 (LitCal row), component inventory row for the LitCal model +- Modify: `auth/handoffs/liturgicalcalendar.md` (remove the "⚠ The copy in this repo is stale" section; restate model source) + +**Interfaces:** +- Consumes: behaviour defined in Task 2, lock files from Task 3. +- Produces: no code interfaces. + +- [ ] **Step 1: Add the ownership rule to `auth/README.md`** + +Under the "Architecture pin" bullet list, add: + +```markdown +- **cdcf-infra owns every OpenFGA authorization model** on the shared instance — + `auth/models/.json`, its optional `.tuples.json`, and the + `--create-{project}-store` shorthand. Consumer repos keep no model file; their + local stacks obtain the model by cloning this repo and running + `setup-openfga.sh --target local` (see `martyrology-api`'s `authz-seed` + service for the reference implementation). `auth/models/.lock.json` + records the model ID this repo last uploaded; the provisioner refuses to + upload when a store's latest model is something else, so an out-of-band + change is reported rather than silently reverted. +``` + +- [ ] **Step 2: Replace the §4.8 warning in `docs/SYSADMIN.md`** + +Delete the paragraph beginning `⚠ **`--create-litcal-store` is not safe to re-run as of 2026-08-04.**` and the `# ⚠ see warning below` comment on the command, then add after the tuples paragraph: + +```markdown +Each store also has `auth/models/.lock.json`, recording the model ID this repo last uploaded. If a store's latest model is not the recorded one, someone uploaded outside this repo and the run **refuses** (exit 7) rather than replacing their model — pass `--force-model-upload` only when replacing the deployed model is what you actually intend. A store whose file already matches its latest model adopts the lock silently. +``` + +- [ ] **Step 3: Fix the §5.4 LitCal row and the component inventory** + +In §5.4, change the LitCal store cell back to `` `LiturgicalCalendar` (`01KRSCF4GVX0X4ZNXXJQEC4XXJ`)`` with no ownership caveat. In the component inventory, restore the LitCal model row to: + +```markdown +| Authz model (LitCal) | `auth/models/LiturgicalCalendar.json` + `.lock.json` | owned here; synced from `LiturgicalCalendarAPI/scripts/openfga-model.json` on 2026-08-04 when ownership was centralized | +``` + +- [ ] **Step 4: Update the LitCal handoff** + +In `auth/handoffs/liturgicalcalendar.md`, delete the whole `### ⚠ The copy in this repo is stale — do not re-run --create-litcal-store` section, and change the **Model source** bullet to: + +```markdown +- **Model source**: `cdcf-infra/auth/models/LiturgicalCalendar.json` — this repo owns it as of 2026-08-04; the copy in `LiturgicalCalendarAPI` was removed. Schema 1.1. Deployed types: `user`, `wider_region`, `national_calendar`, `diocesan_calendar`, `general_roman_calendar`, `national_calendar_test`, `diocesan_calendar_test`, `general_roman_calendar_test`; relations `admin`/`editor`/`viewer` throughout, plus `member_nation` on `wider_region`. The current model ID is recorded in `auth/models/LiturgicalCalendar.lock.json`. +``` + +- [ ] **Step 5: Check for leftover references** + +```bash +grep -rn "not safe to re-run\|copy in this repo is stale\|owned by the LitCal repo\|model owned by the LitCal repo" docs/ auth/ || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 6: Commit** + +```bash +git add auth/README.md docs/SYSADMIN.md auth/handoffs/liturgicalcalendar.md +git commit -m "Document centralized model ownership; drop the interim warning + +The stale-copy hazard is resolved by the sync and the lock guard, so the +do-not-run warning added on 2026-08-04 comes back out." +``` + +--- + +### Task 5: Open the cdcf-infra PR + +**Files:** none (git/gh only). + +- [ ] **Step 1: Branch and push** + +If Tasks 1-4 were committed on `main`, move them onto a branch first: + +```bash +git log --oneline -4 # confirm the four commits +git branch feat/centralize-openfga-models +git reset --hard origin/main # only if the commits were made on main +git checkout feat/centralize-openfga-models +git push -u origin feat/centralize-openfga-models +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --base main --title "Centralize OpenFGA model ownership in cdcf-infra" --body "$(cat <<'BODY' +Implements `docs/superpowers/specs/2026-08-04-openfga-model-ownership-and-upgrade-design.md`. + +- Syncs `auth/models/LiturgicalCalendar.json` from `LiturgicalCalendarAPI`, which had evolved the deployed model twice since this copy was written. +- Adds `auth/models/.lock.json` and makes `setup-openfga.sh` refuse to upload when a store's latest model is not the recorded one (`--force-model-upload` overrides). Verified against a throwaway in-memory OpenFGA: the run that previously overwrote an out-of-band model now exits 7. +- Documents the ownership rule and removes the interim "do not re-run" warning. + +Follow-up in `LiturgicalCalendarAPI` removes that repo's model copy and adds an `authz-seed` service; it must land after this. +BODY +)" +``` + +- [ ] **Step 3: Merge after review** + +```bash +gh pr merge --merge --delete-branch +``` + +The `sync-to-vps.yml` workflow fast-forwards `/opt/cdcf-auth` on merge. No compose restart happens, and no provisioning runs — those stay manual. + +--- + +### Task 6: LitCal repo — replace model ownership with `authz-seed` + +**Files (in `/home/johnrdorazio/development/LiturgicalCalendar/LiturgicalCalendarAPI`):** +- Delete: `scripts/openfga-model.json` +- Modify: `docker-compose.yml` (add `authz-seed` after the `openfga` service) +- Modify: `scripts/setup-openfga.sh` (drop model upload; keep store/model ID discovery and `--update-env`) +- Modify: `docs/ops/test-scope-migration-runbook.md`, `docs/enhancements/AUTHENTICATION_ROADMAP.md`, `infrastructure/README.md` + +**Interfaces:** +- Consumes: cdcf-infra `main` containing Tasks 1-4 (the seed clones it). +- Produces: a local stack whose store and model come from cdcf-infra, with `OPENFGA_STORE_ID`/`OPENFGA_MODEL_ID` still written into env files by `scripts/setup-openfga.sh --update-env`. + +- [ ] **Step 1: Confirm the prerequisite landed** + +```bash +git -C /home/johnrdorazio/development/CatholicOS_org/cdcf-infra fetch origin main +git -C /home/johnrdorazio/development/CatholicOS_org/cdcf-infra log origin/main --oneline -1 +``` + +Expected: the merge commit from Task 5. Do not proceed otherwise — the seed would clone a `main` without the synced model. + +- [ ] **Step 2: Branch from `development`** + +```bash +cd /home/johnrdorazio/development/LiturgicalCalendar/LiturgicalCalendarAPI +git checkout development && git pull --ff-only +git checkout -b feat/consume-cdcf-infra-openfga-model +``` + +- [ ] **Step 3: Inventory every consumer of the model file** + +```bash +grep -rn "openfga-model.json" --include='*' . | grep -v '^./.git' +``` + +Expected hits: `scripts/setup-openfga.sh:28`, `docs/ops/test-scope-migration-runbook.md`, `docs/enhancements/AUTHENTICATION_ROADMAP.md`, three `docs/superpowers/specs/*` files. Historical spec/plan documents under `docs/superpowers/` describe past work and are **left untouched**; only the operational docs are updated. + +- [ ] **Step 4: Add the `authz-seed` service** + +In `docker-compose.yml`, immediately after the `openfga` service block, add (ported from `martyrology-api/docker-compose.yml:172-197`, adapted to this stack's `zitadel` network and store name): + +```yaml + # Seeds the OpenFGA store + authorization model from cdcf-infra, which owns + # every model on the shared umbrella instance. This repo intentionally keeps + # no model file: a second copy is what let a stale model silently revert the + # deployed one in August 2026. + authz-seed: + image: alpine:3.21 + restart: "no" + environment: + CDCF_INFRA_REF: "${CDCF_INFRA_REF:-main}" + OPENFGA_PRESHARED_KEY: "${OPENFGA_AUTHN_PRESHARED_KEYS:-}" + entrypoint: + - /bin/sh + - -c + - | + set -eu + apk add --no-cache bash curl jq git >/dev/null + rm -rf /tmp/cdcf-infra + git clone --depth 1 --branch "$$CDCF_INFRA_REF" \ + https://github.com/CatholicOS/cdcf-infra.git /tmp/cdcf-infra + cd /tmp/cdcf-infra/auth + cat > .env.local <&2 + exit 1 + } + local model_id + model_id=$(echo "$body" | jq -r '.authorization_models[0].id // empty') + if [[ -z "$model_id" ]]; then + echo -e "${RED}Store ${store_id} has no authorization model.${NC}" >&2 + echo -e "${YELLOW}Run 'docker compose up authz-seed' to seed it from cdcf-infra.${NC}" >&2 + exit 1 + fi + echo "$model_id" +} +``` + +Update the header usage comment to state that the model comes from cdcf-infra via `authz-seed`, and that this script only discovers IDs and updates env files. + +- [ ] **Step 6: Delete the model file** + +```bash +git rm scripts/openfga-model.json +``` + +- [ ] **Step 7: Update the operational docs** + +In `docs/ops/test-scope-migration-runbook.md`, replace the step that compares/applies `scripts/openfga-model.json` (around lines 47 and 215-216) with: change the model in `cdcf-infra/auth/models/LiturgicalCalendar.json`, get it merged, have the operator run `./setup-openfga.sh --target production --create-litcal-store` on the VPS, then re-pin `OPENFGA_MODEL_ID` from the new model ID. Apply the same substitution in `docs/enhancements/AUTHENTICATION_ROADMAP.md` and `infrastructure/README.md`. + +- [ ] **Step 8: Verify the local stack end-to-end** + +```bash +docker compose down -v +docker compose up -d db openfga-migrate openfga +docker compose up authz-seed +``` + +Expected: the seed clones cdcf-infra, prints `✓ Created store: LiturgicalCalendar` (or finds it), `✓ Uploaded model: `, `✓ Lock updated: …` (inside the container only — the clone is disposable), and exits 0. + +```bash +./scripts/setup-openfga.sh --update-env +grep -E '^OPENFGA_(STORE|MODEL)_ID=' .env.local +``` + +Expected: both IDs populated, the model ID matching what the seed uploaded. + +- [ ] **Step 9: Confirm the deleted file has no remaining operational consumer** + +```bash +grep -rn "openfga-model.json" --include='*' . | grep -v '^./.git' | grep -v 'docs/superpowers/' +``` + +Expected: no output. + +- [ ] **Step 10: Commit and open the PR against `development`** + +```bash +git add -A +git commit -m "Consume the OpenFGA model from cdcf-infra instead of owning a copy + +cdcf-infra now owns every authorization model on the shared umbrella instance. +This repo's copy had diverged from it in both directions over time, and in +August 2026 the infra copy came within one command of reverting the deployed +model. Delete the local model, seed the store from cdcf-infra via an +authz-seed service (same pattern as martyrology-api), and reduce +scripts/setup-openfga.sh to ID discovery plus env wiring." +git push -u origin feat/consume-cdcf-infra-openfga-model +gh pr create --base development --title "Consume the OpenFGA model from cdcf-infra" --body "See CatholicOS/cdcf-infra design doc 2026-08-04-openfga-model-ownership-and-upgrade-design.md. Model ownership for the shared OpenFGA instance is centralized in cdcf-infra; this repo seeds its local store from there and no longer keeps a model file. Requires the cdcf-infra PR to be merged first (it is)." +``` + +--- + +### Task 7: Verify the guard on production, read-only + +**Files:** none. + +- [ ] **Step 1: Confirm the VPS has the merged code** + +```bash +ssh ubuntu@catholicdigitalcommons.org 'git -C /opt/cdcf-auth log --oneline -1; ls /opt/cdcf-auth/auth/models/' +``` + +Expected: the Task 5 merge commit, and both `.lock.json` files present. + +- [ ] **Step 2: Confirm the lock matches the live store** + +```bash +ssh ubuntu@catholicdigitalcommons.org 'bash -s' <<'EOS' +set -euo pipefail +cd /opt/cdcf-auth/auth +KEY=$(grep -m1 '^OPENFGA_PRESHARED_KEY=' .env.production | cut -d= -f2- | tr -d '"') +MISMATCH=0 +for n in LiturgicalCalendar Martyrology; do + # jq -er with `// empty`: a missing field prints nothing and jq exits + # non-zero, instead of printing the literal string "null" — two "null"s + # (a broken lock file and a failed API call) would otherwise compare equal + # and read as a pass. + if ! s=$(jq -er '.store_id // empty' "models/$n.lock.json") || [[ -z "$s" ]]; then + echo "$n: FAILURE — lock file missing store_id"; MISMATCH=1; continue + fi + if ! m=$(jq -er '.model_id // empty' "models/$n.lock.json") || [[ -z "$m" ]]; then + echo "$n: FAILURE — lock file missing model_id"; MISMATCH=1; continue + fi + # Status-code check rather than `curl --fail-with-body` (requires curl + # >= 7.76.0, not guaranteed on the VPS). + http_code=$(curl -sS -o "/tmp/live-$n.json" -w '%{http_code}' "http://127.0.0.1:8081/stores/$s/authorization-models?page_size=1" -H "Authorization: Bearer $KEY") + if [[ "$http_code" != "200" ]]; then + echo "$n: FAILURE — live model request returned HTTP $http_code"; MISMATCH=1; continue + fi + if ! live=$(jq -er '.authorization_models[0].id // empty' "/tmp/live-$n.json") || [[ -z "$live" ]]; then + echo "$n: FAILURE — live response has no model id"; MISMATCH=1; continue + fi + if [[ "$m" == "$live" ]]; then + echo "$n: OK ($m)" + else + echo "$n: MISMATCH lock=$m live=$live" + MISMATCH=1 + fi +done +exit "$MISMATCH" +EOS +``` + +Expected: `OK` for both, and the command exits 0. A mismatch prints `MISMATCH` for that +store **and** makes the script exit 1 — the explicit flag means a mismatch fails the +check instead of being swallowed by an `&&`/`||` chain that always reports success. +A `FAILURE` line (broken lock file, non-200 from the live request, or a response with +no model id) also sets the exit code — it is treated the same as a mismatch rather than +comparing empty-to-empty and passing. A mismatch or failure means investigate before +running any store action. + +--- + +### Task 8: Operator — apply the synced model to production + +**Files:** none (operator action on the VPS). + +This is the only step that can change production, and it is expected to be a **no-op upload**: the synced file already equals the deployed model. + +- [ ] **Step 1: Dry expectation** + +State the expectation before running, so a surprise is obvious: the run should print `✓ Model unchanged (01KW4FW2ZCT1E693PY8D9TJEFM) — no upload needed` and must NOT print `Uploaded model`. + +- [ ] **Step 2: Run it** + +```bash +ssh ubuntu@catholicdigitalcommons.org +cd /opt/cdcf-auth/auth +./setup-openfga.sh --target production --create-litcal-store +``` + +- [ ] **Step 3: Check the outcome** + +If it printed `Uploaded model`, the file and the deployed model diverged after all — record the new model ID, update `auth/models/LiturgicalCalendar.lock.json` in a follow-up PR, and tell the LitCal maintainers their `OPENFGA_MODEL_ID` pin must move. If it printed `Model unchanged`, nothing further is needed. + +- [ ] **Step 4: Confirm the store is untouched** + +```bash +KEY=$(grep -m1 '^OPENFGA_PRESHARED_KEY=' /opt/cdcf-auth/auth/.env.production | cut -d= -f2- | tr -d '"') +curl -sS "http://127.0.0.1:8081/stores/01KRSCF4GVX0X4ZNXXJQEC4XXJ/authorization-models" \ + -H "Authorization: Bearer $KEY" | jq -r '.authorization_models | length, .[0].id' +``` + +Expected: still `3` models, latest still `01KW4FW2ZCT1E693PY8D9TJEFM`. diff --git a/docs/superpowers/specs/2026-08-04-openfga-model-ownership-and-upgrade-design.md b/docs/superpowers/specs/2026-08-04-openfga-model-ownership-and-upgrade-design.md new file mode 100644 index 0000000..b71407d --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-openfga-model-ownership-and-upgrade-design.md @@ -0,0 +1,154 @@ +# OpenFGA model ownership + 1.15.1 → 1.18.2 upgrade — design + +**Date:** 2026-08-04 +**Repos touched:** `cdcf-infra`, `LiturgicalCalendarAPI`, `LiturgicalCalendarFrontend`, `martyrology-api` +**Status:** design, pending implementation plan + +--- + +## 1. Why + +The LiturgicalCalendar authorization model exists in two repos with no rule about which one wins, and they have diverged: + +| Copy | State | +| --- | --- | +| `LiturgicalCalendarAPI/scripts/openfga-model.json` | Matches the deployed model exactly (verified 2026-08-04 under `upload_model_if_changed`'s own normalization). | +| `cdcf-infra/auth/models/LiturgicalCalendar.json` | Frozen at the 2026-05-16 version (`dd343e5`); two revisions behind. | + +The deployed store `01KRSCF4GVX0X4ZNXXJQEC4XXJ` holds three models: `01KRSCF4K9W2EWZ1X2PP1QVH3B` (2026-05-16, what cdcf-infra still describes), `01KW40P7AM87W4Y864D2RZDR0B` (2026-06-27T07:46Z), and the current `01KW4FW2ZCT1E693PY8D9TJEFM` (2026-06-27T12:11Z). The LitCal team evolved it in their repo — `2060b19a` (calendar-scoped test types), `76033bfb` (admin-superset, `member_nation` TTU, drop `deleter`), `ea6fdd6c` (drop `test_definition`) — and uploaded from there. + +**The live hazard:** `upload_model_if_changed` (`auth/setup-openfga.sh:204`) uploads a new model version whenever the file differs from the store's latest. They differ. So `./setup-openfga.sh --target production --create-litcal-store` — a command the handbook presented as routine and idempotent — would push the May model on top as the new latest, silently reverting `general_roman_calendar`, three `*_test` types and `member_nation`, and resurrecting `test_definition`/`deleter`. Pinned consumers survive; anything resolving "latest" regresses. + +Martyrology, meanwhile, does the opposite: cdcf-infra owns `auth/models/Martyrology.json` and `Martyrology.tuples.json`, and `martyrology-api`'s local stack consumes them by cloning this repo (`martyrology-api/docker-compose.yml:172-197`). + +Two patterns, opposite directions, one shared instance. This design picks one. + +## 2. Decision + +**cdcf-infra owns every authorization model on the shared OpenFGA instance.** + +For each store it owns: the model (`auth/models/.json`), the structural tuples (`auth/models/.tuples.json`), store creation, and the `--create-{project}-store` shorthand. Project repos hold no model file. + +Rejected alternatives: + +- **Project repos own models.** Defensible if models are read as domain artifacts, but it fragments disaster recovery across four repos, and dismantles working machinery (martyrology's `authz-seed`, the centralized tuples file) to do it. +- **Snapshot + drift check.** Keeps two copies and adds a check to police them — the "do both" this decision exists to avoid. + +Consumers reach models two ways, both already proven: + +1. **Production** — an operator runs `setup-openfga.sh` on the VPS. +2. **Local dev** — an `authz-seed` container clones cdcf-infra and runs the store action against the local OpenFGA. + +## 3. LitCal migration + +**3.1 — cdcf-infra PR (must land first).** + +1. Copy `LiturgicalCalendarAPI/scripts/openfga-model.json` → `auth/models/LiturgicalCalendar.json`. The file already matches production, so the next `--create-litcal-store` reports *"Model unchanged — no upload needed"* rather than uploading. +2. Add the lock-file guard (§4) and generate `auth/models/LiturgicalCalendar.lock.json` + `auth/models/Martyrology.lock.json` recording the currently deployed model IDs. +3. Remove the "do not re-run `--create-litcal-store`" warning added to `docs/SYSADMIN.md` §4.8 and `auth/handoffs/liturgicalcalendar.md` on 2026-08-04 — the hazard is gone once the file is synced and the guard is in place. + +**3.2 — LitCal PR (after 3.1 is on cdcf-infra's `main`).** + +Target the `development` branch, not `main` — that is the default branch across the Liturgical-Calendar GitHub org (`docs/SYSADMIN.md` §10.3). Applies to both LitCal repos if the frontend needs a companion change. + +1. Delete `scripts/openfga-model.json` from `LiturgicalCalendarAPI`. +2. Add an `authz-seed` service to the local compose, ported from `martyrology-api/docker-compose.yml:172-197`: alpine, `git clone --depth 1 --branch "$CDCF_INFRA_REF"`, write a throwaway `.env.local`, run `./setup-openfga.sh --target local --create-litcal-store`. +3. Repoint anything that read the deleted file — the implementation plan must grep both LitCal repos for `openfga-model.json` references (compose services, CI steps, test fixtures, docs) rather than assuming the local stack is the only consumer. +4. Stop uploading models from LitCal's deploy path. + +The resulting model-change process: PR to cdcf-infra → operator runs `--create-litcal-store` → new model ID → LitCal re-pins `OPENFGA_MODEL_ID`. The re-pin step already existed, so this adds one PR, not one process. + +`LiturgicalCalendarFrontend` holds no model and needs no ownership change — it appears in this design only for the version bump (§5). + +## 4. Guardrail: per-store lock file + +The invariant centralization creates is *cdcf-infra is the only writer of models to the shared instance*. The guard detects violations of exactly that. + +**`auth/models/.lock.json`**, committed: + +```json +{ + "store_name": "LiturgicalCalendar", + "store_id": "01KRSCF4GVX0X4ZNXXJQEC4XXJ", + "model_id": "01KW4FW2ZCT1E693PY8D9TJEFM" +} +``` + +Three fields, deliberately. No commit hash or timestamp: the lock file is committed, so `git log auth/models/.lock.json` already gives the provenance chain and does it more reliably than the script could — on the VPS the worktree may sit at a different commit than the one that authored the model, or be dirty. Recording state needed to detect drift, and nothing about the file itself, is what keeps lockfiles honest. + +`upload_model_if_changed` checks it before doing anything: + +| Store's latest model ID | Lock | Behaviour | +| --- | --- | --- | +| matches lock | present | Today's behaviour: compare file to store, upload if changed, rewrite lock. | +| ≠ lock | present | **Refuse and report**, printing both IDs. `--force-model-upload` overrides. | +| — | absent, file matches store | Adopt: write the lock, continue. | +| — | absent, file differs | Refuse; require `--force-model-upload`. | + +Against the 2026-08-04 incident this fires correctly: lock `01KRSCF4K9…` vs store `01KW4FW2ZC…` → refuse instead of regress. + +Model upload stays inside `--create-store` rather than moving to a separate action: splitting adds a step to every workflow to defend a case the lock already catches, and an explicit `--upload-model` run with a stale file regresses the store just the same. + +Side benefit: the lock is a committed, greppable record of each store's current model ID, so handoff docs can reference it instead of hardcoding IDs that rot — the exact failure fixed by hand in `9fb397e` and `a43109e`. + +## 5. OpenFGA 1.15.1 → 1.18.2 + +**Postgres is unaffected by the v1.18.0 migration warning.** That warning covers MySQL schema migration `008_collate_identifiers.sql`, a case-sensitivity fix for MySQL identifier comparison (CVE-2026-55170, CVE-2026-55689). Postgres and SQLite were already case-sensitive. Every deployment here sets `OPENFGA_DATASTORE_ENGINE: postgres`. + +Postgres migration assets by tag: + +| Tag | Postgres migrations | +| --- | --- | +| v1.8.12 | 001–005 | +| v1.15.1 | 001–006 | +| v1.18.2 | 001–006 | + +So 1.15.1 → 1.18.2 applies **no** schema migration; the `openfga-migrate` one-shot no-ops. Rollback is a pin revert with no schema to undo — the property that makes this low-risk. + +| Target | From | Environment | +| --- | --- | --- | +| `cdcf-infra/auth/docker-compose.prod.yml` (both services) | 1.15.1 | Production | +| `martyrology-api/docker-compose.yml` (both services) | 1.15.1 | Local dev | +| `LiturgicalCalendarAPI/docker-compose.yml` (both services) | 1.8.12 | Local dev | +| `LiturgicalCalendarFrontend/docker-compose.yml` (both services) | 1.8.12 | Local dev | + +The 1.8.12 → 1.18.2 jump picks up `006_add_collate_index`, applied to throwaway dev databases. + +**Verify before the production pin changes** (each is an assumption today, not a fact): + +1. `/usr/local/bin/grpc_health_probe` still ships in the 1.18.2 image — both our compose and martyrology's healthchecks depend on it. +2. Whether the playground-plus-preshared startup panic documented at `martyrology-api/docker-compose.yml:141-145` still applies at 1.18.2. +3. `OPENFGA_AUTHN_METHOD` / `OPENFGA_AUTHN_PRESHARED_KEYS` semantics unchanged across the range. + +**Post-upgrade verification on production:** `/healthz` on both subdomains; `ListAuthorizationModels` on both stores returns the same latest IDs as before the bump; one `Check` against each store returns the expected decision. + +## 6. Deployment mechanics + +`.github/workflows/sync-to-vps.yml` runs `git -C /opt/cdcf-auth pull --ff-only origin main` on merge to `main`. It is **pull-only by design** — no `docker compose up`, no provisioning runs. Therefore: + +- Compose changes (the image pin bump) reach the VPS by merge, then need an operator to bring the stack up. +- `setup-openfga.sh` runs stay manual on the VPS. + +## 7. Sequencing + +1. cdcf-infra PR — model sync, lock files + guard, warning removal (§3.1, §4). +2. LitCal PR — delete model, add `authz-seed` (§3.2). Requires 1 on `main`, since the seed clones this repo. +3. Version bumps — local composes first, production last, after the §5 image checks. + +Steps 1–2 (ownership) and step 3 (upgrade) are independent and may become two implementation plans; the only ordering constraint that matters is 1 before 2. + +## 8. Out of scope + +- Zitadel version (`v4.15.0`) and any Zitadel-side provisioning. +- Re-homing Martyrology's model or tuples (already centralized). +- The unresolved `unassigned_en_translatio` governance question (`auth/handoffs/martyrology.md`). +- Promoting generated IDs to GitHub Actions secrets (`docs/SYSADMIN.md` §8). + +## 9. Risks + +| Risk | Mitigation | +| --- | --- | +| LitCal model changes now need a cdcf-infra PR, adding friction to the fastest-moving model. | Accepted deliberately; the coordinated `OPENFGA_MODEL_ID` re-pin already made this a two-repo operation. | +| A `--force-model-upload` escape hatch can still regress a store. | It is explicit and logged; the failure mode being removed is the *silent* one. | +| Local dev depends on cloning cdcf-infra. | Already true for martyrology-api and working; `CDCF_INFRA_REF` allows pinning a branch. | +| 1.18.2 image or flag differences break the stack. | The three §5 checks precede the production bump; rollback is a pin revert with no schema change. |