From 3aa856c90448784e707c7edca07d2fbfc3a2bbc5 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 4 Aug 2026 12:57:40 +0200 Subject: [PATCH 1/7] Flag the LitCal model drift; add ownership + upgrade design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parts, both stemming from the same finding: cdcf-infra's copy of the LiturgicalCalendar authorization model is two revisions behind the deployed one, and setup-openfga.sh would upload it over the top. Protective docs (accurate until the design lands): - auth/handoffs/liturgicalcalendar.md — record the deployed model 01KW4FW2ZCT1E693PY8D9TJEFM and its real shape (test_definition and deleter are gone; general_roman_calendar, three *_test types and member_nation are new), correct the sample OPENFGA_MODEL_ID, and warn that --create-litcal-store must not be re-run until the file is synced. The Zitadel half of that handoff was re-verified against production and is accurate as recorded. - docs/SYSADMIN.md — same warning inline next to the command in 4.8, plus 5.4 and the component inventory noting the LitCal repo owns that model today. Design doc (docs/superpowers/specs/2026-08-04-openfga-model-ownership- and-upgrade-design.md): - Decision: centralize every model in cdcf-infra; project repos hold no model file and consume via the authz-seed pattern already working in martyrology-api. - LitCal migration in two PRs, cdcf-infra first. - Guardrail: per-store lock file recording the model ID this repo last uploaded, so an out-of-band upload makes the script refuse rather than silently regress a store. - OpenFGA 1.15.1 -> 1.18.2 is migration-free on Postgres (assets 001-006 identical at both tags); the v1.18.0 warning is MySQL-only. Includes the three image assumptions to verify before the production pin moves. Co-Authored-By: Claude Opus 5 (1M context) --- auth/handoffs/liturgicalcalendar.md | 24 ++- docs/SYSADMIN.md | 8 +- ...nfga-model-ownership-and-upgrade-design.md | 154 ++++++++++++++++++ 3 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-04-openfga-model-ownership-and-upgrade-design.md diff --git a/auth/handoffs/liturgicalcalendar.md b/auth/handoffs/liturgicalcalendar.md index 9cc19a2..815aa32 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 model that no longer exists. + +### ⚠ 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/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. | From 1cd299612ae40d2431965afe77f78360b617e611 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 4 Aug 2026 14:28:34 +0200 Subject: [PATCH 2/7] Add implementation plans for model ownership and the OpenFGA upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two plans from the 2026-08-04 design, independent except that ownership Task 6 (the LitCal PR) needs ownership Task 5 merged first. 2026-08-04-openfga-model-ownership.md — sync the LitCal model, add the lock-file guard with a reproduce-then-fix cycle against a throwaway in-memory OpenFGA, generate lock files from the deployed IDs, update the docs, then the two PRs and a read-only production verification. 2026-08-04-openfga-1182-upgrade.md — verify the three image assumptions first (healthcheck binary, playground/preshared behaviour, and a v1.18.2 migrate run against a v1.15.1 database to confirm the no-op), then local stacks, then production with health, model-listing and Check probes plus a rollback that needs no schema work. Scoping note found while planning: the LitCal repo owns more than a model file. scripts/setup-openfga.sh creates the store, uploads the model, and its --update-env writes IDs into env files that the ops runbook and infrastructure README depend on, so the plan reduces that script to ID discovery plus env wiring rather than deleting it. The Frontend repo has no model copy, contrary to that repo's 2026-06-20 spec. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-04-openfga-1182-upgrade.md | 400 +++++++++ .../2026-08-04-openfga-model-ownership.md | 815 ++++++++++++++++++ 2 files changed, 1215 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md create mode 100644 docs/superpowers/plans/2026-08-04-openfga-model-ownership.md 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..a5d3c3d --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md @@ -0,0 +1,400 @@ +# 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 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 2>/dev/null +docker run --rm --name fga-pg -p 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 2>&1 | head -20 +``` + +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 +docker compose exec api pytest -q 2>/dev/null || pytest -q +``` + +Expected: pass. 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 +docker compose logs openfga-migrate | tail -20 +``` + +Expected: migration runs, exits 0. + +- [ ] **Step 3: Bring the rest up** + +```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 +docker compose -f docker-compose.prod.yml logs --tail=30 openfga-migrate +``` + +Expected: migrate exits 0 having applied nothing. + +```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. 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 . +``` + +Expected: both model IDs identical to Step 1, and the Check returns `{"allowed": true}` — the superuser inheritance path, which exercises model evaluation end to end rather than just liveness. + +- [ ] **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..3cfbe7c --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md @@ -0,0 +1,815 @@ +# 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 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: + +``` + --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** + +The throwaway store is still in the out-of-band state from Step 3, and no lock file exists yet, so the adoption branch must fire: + +```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 +STORE=$(curl -sS -H "Authorization: Bearer test-key" http://127.0.0.1:18081/stores | jq -r '.stores[] | select(.name=="LiturgicalCalendar") | .id') +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 the out-of-band model ID from Step 3. + +- [ ] **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: Write the lock files** + +```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 3: 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 4: 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 '"') +for n in LiturgicalCalendar Martyrology; do + s=$(jq -r .store_id "models/$n.lock.json"); m=$(jq -r .model_id "models/$n.lock.json") + live=$(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') + [[ "$m" == "$live" ]] && echo "$n: OK ($m)" || echo "$n: MISMATCH lock=$m live=$live" +done +EOS +``` + +Expected: `OK` for both. A mismatch means someone uploaded between Task 3 and now — 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`. From 8900bb6289ebf16bd10e92d18b5f918148d1a2f1 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 4 Aug 2026 17:10:42 +0200 Subject: [PATCH 3/7] Point Tasks 3-7's local verification at the local stack PR #23 made --provision-martyrology-frontend target-aware, and the local dev stack (martyrology-api / martyrology-frontend feat/local-dev-stack, unmerged) now provisions a localhost Zitadel client in its own instance. Replace the "stale pending local-stack design" notice with the resolved guidance: verify Tasks 3, 5, 6 and 7's live steps against the local stack, one app per Zitadel instance, production untouched. --- ...026-08-03-martyrology-oidc-login-client.md | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) 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..df22643 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 @@ -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-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 From 8f517348ee3992164c466e416d0080dcfd13577c Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 4 Aug 2026 17:25:50 +0200 Subject: [PATCH 4/7] Fix step bodies that still asserted the stale Dev-app claims Review found the notice's own step bodies contradicted it: Task 3 Step 9 sent readers to nonexistent "Dev app values from Task 2" and created a `.env.local` Next.js never needed; Task 7's handoff template and issue-close comment still claimed two apps in one Zitadel project. Point Step 9 (and its Task 3 Step 6c / Task 5 Step 8 cross-references) at the local stack's `.env`, written by `setup-stack.sh --update-env`, and rewrite Task 7 Steps 1, 3 and 5 to the one-app-per-instance reality: one production app recorded in the handoff, a separately-provisioned local app in the local stack's own Zitadel instance. --- ...026-08-03-martyrology-oidc-login-client.md | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) 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 df22643..4be4208 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 @@ -772,9 +772,10 @@ 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 four `AUTH_*` values 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** @@ -828,20 +829,17 @@ 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): ```bash +docker compose stop martyrology-frontend npm run dev ``` @@ -1325,7 +1323,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. @@ -1513,18 +1511,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 @@ -1533,8 +1535,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 @@ -1562,19 +1564,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 @@ -1586,11 +1583,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** @@ -1598,13 +1595,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\"\`." @@ -1637,6 +1634,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 secret must be pasted by the operator, 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. From 1abfd888060d3b80e2569f75f9d094dda139a069 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 4 Aug 2026 17:33:36 +0200 Subject: [PATCH 5/7] Fix the Architecture summary's own two-apps claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line 7 said "Two confidential OIDC Web apps are provisioned in the existing MartyrologyAPI Zitadel project" — the same two-apps-in-one-project model the notice two sections down now warns against, missed by an exact-phrase grep in the prior round. State one app per instance instead. --- .../plans/2026-08-03-martyrology-oidc-login-client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4be4208..19a2be8 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`). From 924dd9d47776e53b8467581c6ef618a90bdb7feb Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 4 Aug 2026 22:07:31 +0200 Subject: [PATCH 6/7] Address CodeRabbit review feedback on PR #25 Fixes across the OIDC login-client plan, both OpenFGA plan documents, and the LiturgicalCalendar handoff: correct scope/count claims, bind throwaway OpenFGA ports to 127.0.0.1, make the Playground startup probe terminate in both outcomes, stop pytest's container fallback from masking real failures, wait for openfga-migrate to exit 0 before starting openfga, add a LiturgicalCalendar Check probe alongside the Martyrology one, fix the Step 10 out-of-band regression test whose fixture state was already consumed by Step 3, verify deployed model content (not just ID) before writing a lock, fix a verification script whose &&/|| chain always exited 0, and clean up markdown lint issues. --- auth/handoffs/liturgicalcalendar.md | 2 +- ...026-08-03-martyrology-oidc-login-client.md | 18 +++-- .../plans/2026-08-04-openfga-1182-upgrade.md | 55 ++++++++++--- .../2026-08-04-openfga-model-ownership.md | 81 ++++++++++++++++--- 4 files changed, 125 insertions(+), 31 deletions(-) diff --git a/auth/handoffs/liturgicalcalendar.md b/auth/handoffs/liturgicalcalendar.md index 815aa32..978aa1d 100644 --- a/auth/handoffs/liturgicalcalendar.md +++ b/auth/handoffs/liturgicalcalendar.md @@ -40,7 +40,7 @@ Everything in this Zitadel section was re-verified against production on 2026-08 - **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 model that no longer exists. + 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` 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 19a2be8..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 @@ -395,7 +395,7 @@ shred -u /tmp/martyrology-token.json 2>/dev/null || rm -f /tmp/martyrology-token --- -> ## Tasks 3-7: local verification now runs against the local stack +> ## Tasks 3, 5-7: local verification now runs against the local stack > > **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 @@ -772,10 +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 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. +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** @@ -836,7 +837,10 @@ 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): +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 @@ -1634,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 index a5d3c3d..441c439 100644 --- a/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md +++ b/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md @@ -56,7 +56,7 @@ Expected: the file listed. **If it is missing**, stop and revise every affected - [ ] **Step 3: (c) Preshared auth still honoured** ```bash -docker run --rm -d --name fga-1182 -p 18081:8080 \ +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 @@ -72,14 +72,24 @@ Expected: `401` without the token, `200` with it. `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 2>/dev/null -docker run --rm --name fga-pg -p 18082:8080 \ +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 2>&1 | head -20 + 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** @@ -161,10 +171,18 @@ Expected: store created, model uploaded, 11 structural tuples written. - [ ] **Step 5: Run the API's own test suite** ```bash -docker compose exec api pytest -q 2>/dev/null || pytest -q +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. If the suite needs the stack's env, follow this repo's README for the canonical invocation rather than inventing one. +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** @@ -209,12 +227,14 @@ This stack is coming from v1.8.12, so it genuinely applies `006_add_collate_inde ```bash docker compose down -v docker compose up -d db openfga-migrate +MIGRATE_EXIT=$(docker wait "$(docker compose ps -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. +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** +- [ ] **Step 3: Bring the rest up, now that migration is confirmed clean** ```bash docker compose up -d @@ -347,17 +367,19 @@ 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 +MIGRATE_EXIT=$(docker wait "$(docker compose -f docker-compose.prod.yml ps -q openfga-migrate)") docker compose -f docker-compose.prod.yml logs --tail=30 openfga-migrate +[ "$MIGRATE_EXIT" = "0" ] || { echo "openfga-migrate exited $MIGRATE_EXIT" >&2; exit 1; } ``` -Expected: migrate exits 0 having applied nothing. +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. Zitadel containers are untouched. +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** @@ -380,9 +402,20 @@ 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 the Check returns `{"allowed": true}` — the superuser inheritance path, which exercises model evaluation end to end rather than just liveness. +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)** diff --git a/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md b/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md index 3cfbe7c..b6f07a4 100644 --- a/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md +++ b/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md @@ -134,7 +134,7 @@ wider_region, drops test_definition and the deleter relation." In-memory datastore, so nothing persists and no database is needed: ```bash -docker run --rm -d --name fga-test -p 18081:8080 \ +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 @@ -338,7 +338,7 @@ In the argument `while` loop, add before `-h|--help)`: 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. @@ -354,7 +354,21 @@ And in the header comment block, under the `--seed-tuples` entry, add: - [ ] **Step 10: Verify the guard blocks the regression** -The throwaway store is still in the out-of-band state from Step 3, and no lock file exists yet, so the adoption branch must fire: +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 -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) +echo "out-of-band model: $OOB_MODEL" +``` + +No lock file exists yet, so the adoption branch must fire: ```bash cd auth && ./setup-openfga.sh --target local --create-store LiturgicalCalendar; echo "exit=$?"; cd .. @@ -363,11 +377,10 @@ cd auth && ./setup-openfga.sh --target local --create-store LiturgicalCalendar; Expected: `✗ No lock file for store 'LiturgicalCalendar' …`, `exit=7`, and **no** new model uploaded. Confirm the store's latest model is unchanged: ```bash -STORE=$(curl -sS -H "Authorization: Bearer test-key" http://127.0.0.1:18081/stores | jq -r '.stores[] | select(.name=="LiturgicalCalendar") | .id') 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 the out-of-band model ID from Step 3. +Expected: still `$OOB_MODEL`, the out-of-band model just re-uploaded above. - [ ] **Step 11: Verify `--force-model-upload` overrides** @@ -445,7 +458,40 @@ 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: Write the lock files** +- [ ] **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" "Martyrology:01KZ1M9NJR1JHTMTV091X5DMYZ"; do + name=${pair%%:*}; store=${pair##*:} + ssh ubuntu@catholicdigitalcommons.org \ + "curl -sS 'http://127.0.0.1:8081/stores/$store/authorization-models' -H 'Authorization: Bearer $KEY'" \ + > "/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" == "$file" ]]; then + echo "$name: content matches — safe to lock" + else + echo "$name: CONTENT MISMATCH — 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"}' \ @@ -454,7 +500,7 @@ jq -n '{store_name:"Martyrology", store_id:"01KZ1M9NJR1JHTMTV091X5DMYZ", model_i > auth/models/Martyrology.lock.json ``` -- [ ] **Step 3: Verify shape** +- [ ] **Step 4: Verify shape** ```bash for f in auth/models/*.lock.json; do echo "$f"; jq -S . "$f"; done @@ -468,7 +514,7 @@ jq -r 'keys | join(",")' auth/models/*.lock.json Expected for each: `model_id,store_id,store_name`. -- [ ] **Step 4: Commit** +- [ ] **Step 5: Commit** ```bash git add auth/models/LiturgicalCalendar.lock.json auth/models/Martyrology.lock.json @@ -519,7 +565,7 @@ Each store also has `auth/models/.lock.json`, recording the model ID this - [ ] **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: +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 | @@ -708,7 +754,7 @@ git rm scripts/openfga-model.json 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** +- [ ] **Step 8: Verify the local stack end-to-end** ```bash docker compose down -v @@ -770,15 +816,26 @@ 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 s=$(jq -r .store_id "models/$n.lock.json"); m=$(jq -r .model_id "models/$n.lock.json") live=$(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') - [[ "$m" == "$live" ]] && echo "$n: OK ($m)" || echo "$n: MISMATCH lock=$m live=$live" + 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. A mismatch means someone uploaded between Task 3 and now — investigate before running any store action. +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 mismatch means someone uploaded between Task 3 and now — investigate before running +any store action. --- From a9cd7cdb31ddb5a898d0ebdb6412344c9fd2ea4e Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 4 Aug 2026 22:42:20 +0200 Subject: [PATCH 7/7] Address CodeRabbit round-2 findings on the OpenFGA plan docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `docker compose ps -a -q` for the openfga-migrate lookup before `docker wait` in both the LitCal local task and the production task — plain `ps -q` misses an already-exited one-shot (reproduced empirically). - Add a no-op assertion to the production migration step: grep the captured logs for the pre-migration `"current version": 6` line, the only signal that actually distinguishes a no-op from an applied migration (verified against the v1.15.1 image — the "running all migrations"/"migration done" lines print identically either way). - Fix Step 10 of the model-ownership plan: it exercises the no-lock refusal branch, not "adoption" as previously labeled: validate the out-of-band setup upload fails loudly instead of silently producing an empty ID. - Stop discarding the fetched model ID in the production lock-verification loop; compare it against the expected ID alongside the content diff. - Harden the Task 7 live-vs-lock check: `jq -er '// empty'` instead of `-r` (a missing field printed literal "null" and could false-positive against another failure), plus an explicit HTTP status check. --- .../plans/2026-08-04-openfga-1182-upgrade.md | 23 ++++++-- .../2026-08-04-openfga-model-ownership.md | 52 ++++++++++++++----- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md b/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md index 441c439..50a24df 100644 --- a/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md +++ b/docs/superpowers/plans/2026-08-04-openfga-1182-upgrade.md @@ -227,7 +227,10 @@ This stack is coming from v1.8.12, so it genuinely applies `006_add_collate_inde ```bash docker compose down -v docker compose up -d db openfga-migrate -MIGRATE_EXIT=$(docker wait "$(docker compose ps -q 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; } ``` @@ -367,9 +370,23 @@ 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 -MIGRATE_EXIT=$(docker wait "$(docker compose -f docker-compose.prod.yml ps -q openfga-migrate)") -docker compose -f docker-compose.prod.yml logs --tail=30 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. diff --git a/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md b/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md index b6f07a4..5751b53 100644 --- a/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md +++ b/docs/superpowers/plans/2026-08-04-openfga-model-ownership.md @@ -363,12 +363,17 @@ 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 -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) +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, so the adoption branch must fire: +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 .. @@ -472,17 +477,21 @@ 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" "Martyrology:01KZ1M9NJR1JHTMTV091X5DMYZ"; do - name=${pair%%:*}; store=${pair##*:} +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" == "$file" ]]; then - echo "$name: content matches — safe to lock" + if [[ "$deployed_id" == "$expected_id" && "$deployed" == "$file" ]]; then + echo "$name: id ($deployed_id) and content match — safe to lock" else - echo "$name: CONTENT MISMATCH — do NOT write a lock for this store; sync auth/models/$name.json from the deployed model (or investigate) before Step 3" >&2 + 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 ``` @@ -818,8 +827,25 @@ 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 - s=$(jq -r .store_id "models/$n.lock.json"); m=$(jq -r .model_id "models/$n.lock.json") - live=$(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') + # 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 @@ -834,8 +860,10 @@ 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 mismatch means someone uploaded between Task 3 and now — investigate before running -any store action. +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. ---