Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,63 @@ on a `governance_body` is a per-person operator action keyed to a Zitadel `sub`

The Zitadel script prints handoff values per property at the end (issuer, org ID, project ID, app/client IDs, and — for confidential clients like CDCF — the **one-time client secret**). The OpenFGA script prints store ID + model ID. Use those values to write `handoffs/<property>.md` per the template in `handoffs/README.md`. **The CDCF client secret is unrecoverable** once the run finishes — capture it from the script output and store it in the consumer repo's deploy env at the moment of first provisioning.

## `--target local`: one env file per property

`--target production` and `--target staging` both name a single instance — the one on the VPS. **`--target local` does not.** Every umbrella property runs its own local Zitadel in its own compose stack, so "local" means a different instance depending on which property you are working on:

| Property | Local issuer | PAT location |
| --- | --- | --- |
| `cdcf-website` | `http://localhost:8090` | `cdcf-website/.zitadel-data/automation-user.pat` |
| `martyrology-api` | `http://localhost:8080` | `martyrology-api/.zitadel-data/automation-user.pat` |
| `martyrology-frontend` | own stack | `martyrology-frontend/.zitadel-data/automation-user.pat` |

So use one env file per property, and name it after the property:

```bash
ENV_FILE=.env.local.cdcf-website \
./setup-zitadel.sh --target local --create-orgs --provision-cdcf-website
```

`ENV_FILE` needs no code change — it has always overridden the `.env.$target` default. `.gitignore`'s `.env.*` already covers `.env.local.<property>`, so these files never land in git.

### Why not one shared `.env.local`

Because the failure is silent rather than loud. With a single `.env.local`, whichever property you configured last wins. Run `--provision-martyrology` while it still points at cdcf-website's instance and Martyrology's Project, roles and OIDC app are created **inside cdcf-website's local Zitadel** — and nothing errors. That PAT is a valid `IAM_OWNER` for the instance it belongs to, so every API call succeeds and the output looks completely normal. Both instances are "local", so there is no target name to tip you off either.

### The guard

`setup-zitadel.sh` refuses that combination rather than trusting the convention to be followed. Local stacks keep their PAT at `<property>/.zitadel-data/automation-user.pat`, so the script reads the owning property out of the PAT's own path and checks it against the stacks each action may legitimately target:

```text
[setup-zitadel] Target: local (issuer: http://localhost:8090, internal: http://127.0.0.1:8090)
[setup-zitadel] PAT file: /home/you/dev/cdcf-website/.zitadel-data/automation-user.pat
[setup-zitadel] Local property: cdcf-website
✗ --provision-martyrology provisions martyrology-api, but the resolved PAT belongs to cdcf-website.
⚠ Fix: ENV_FILE=.env.local.martyrology-api ./setup-zitadel.sh --target local --provision-martyrology
```

Exit code **17**, before anything is written.

It is an **allow-list per action, not one property per action**, because a stack may legitimately host another property's Project:

| Action | May run against |
| --- | --- |
| `--provision-cdcf-website` | `cdcf-website` |
| `--provision-martyrology` | `martyrology-api`, `martyrology-frontend` |
| `--provision-martyrology-frontend` | `martyrology-frontend` |

`martyrology-frontend/scripts/setup-stack.sh` runs `--provision-martyrology` against its **own** instance — the frontend authenticates there, so the Martyrology Project and roles have to exist in it. Pinning that action to `martyrology-api` alone would refuse a correct run. What stays refused is the cross-**family** case, which is the one actually reported: Martyrology provisioned while the PAT points at cdcf-website.

Three more details worth knowing:

- **Instance-wide actions are exempt.** `--create-orgs`, `--create-org` and `--rename-bootstrap-admin` act on the instance rather than a property, and `--create-orgs` is a prerequisite for provisioning a fresh local stack at all, so none of them are guarded.
- **LitCal skips on local, it does not refuse.** There is no LitCal local stack here — `--provision-litcal` and `--provision-litcal-frontend` both warn and exit 0, naming `LiturgicalCalendarAPI/scripts/setup-zitadel.sh`, which is what provisions LitCal locally. That keeps `--target local --all` sweeping, per the skip contract in `--help`.
- **`--target local --all` is refused**, because a sweep spans properties and no single PAT can be right for all of them. Provision one property at a time on local.

`ZITADEL_ALLOW_FOREIGN_PAT=1` overrides the check for a layout that keeps its PAT somewhere other than `<property>/.zitadel-data/`. The guard never applies to staging or production, where the target does identify the instance.

`setup-zitadel.selftest.sh` covers all of the above; run `./auth/setup-zitadel.selftest.sh` after touching any of it.

## Plesk-side setup

Two subdomains + two Plesk Docker Proxy Rules (one per subdomain). DNS + Let's Encrypt set up in the standard Plesk UI; routing is handled by Tools & Settings → Docker → Proxy Rules (NOT by "Additional nginx directives", which gets shadowed by Plesk's default `location /` going to Apache).
Expand Down Expand Up @@ -243,6 +300,7 @@ The dedicated user is the principle-of-least-privilege boundary. Even if `VPS_SS
1. Decide whether the property gets its own Zitadel Org (per-property isolation, default) or fits under an existing Org.
2. Pre-provision the Org via `setup-zitadel.sh --target production --create-org <NAME>` if it doesn't exist.
3. Add provisioning logic to `setup-zitadel.sh` (a new `do_provision_<property>` function mirroring `do_provision_litcal`) that creates the Project + roles + OIDC app(s) the property needs. Roles + OIDC app config should be lifted from the property's own dev compose / config to ensure exact parity.
- If the property runs a **local Zitadel stack**, add its action to the `action_properties` allow-list so the cross-provisioning guard covers it, and document its `.env.local.<property>` in [`--target local`](#--target-local-one-env-file-per-property) above. An action missing from that table is silently unguarded on local; list every stack the action may legitimately run against, not just the property it is named after.
4. For OpenFGA-using properties:
- Drop the authorization model JSON into `auth/models/<StoreName>.json`.
- If the model is inert without structural tuples (as `Martyrology` is), add them to `auth/models/<StoreName>.tuples.json` — an object with a `tuples` array of `{"user","relation","object"}` entries. Structural wiring only; never human role grants.
Expand Down
137 changes: 129 additions & 8 deletions auth/setup-zitadel.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ ZITADEL_ADMIN_EMAIL=stub@example.org
EOF
done

# Property-shaped sandboxes for the --target local cross-provisioning guard
# (issue #34). The guard infers the property from the PAT's own location —
# <property>/.zitadel-data/automation-user.pat — so these cases need real
# directory shape, not just a distinct filename.
#
# The plain .env.local above deliberately keeps its PAT at $SANDBOX root, which
# infers NO property. That is what a hand-rolled shared .env.local looks like,
# and the guard must refuse it for property-bound actions.
for prop in cdcf-website martyrology-api martyrology-frontend; do
mkdir -p "$SANDBOX/${prop}/.zitadel-data"
printf 'stub-pat\n' > "$SANDBOX/${prop}/.zitadel-data/automation-user.pat"
cat > "$SANDBOX/.env.local.${prop}" <<EOF
ZITADEL_ISSUER=https://auth.catholicdigitalcommons.org
ZITADEL_INTERNAL_URL=http://127.0.0.1:${PORT}
ZITADEL_PAT_FILE=${SANDBOX}/${prop}/.zitadel-data/automation-user.pat
ZITADEL_ADMIN_EMAIL=stub@example.org
EOF
done

cat > "$SANDBOX/stub.py" <<'PYEOF'
"""Stub Zitadel. Serves only what setup-zitadel.sh calls.

Expand Down Expand Up @@ -205,15 +224,22 @@ start_stub() {
return 1
}

# expect EXPECTED_EXIT MODE PATTERN DESCRIPTION -- ARGS...
# expect EXPECTED_EXIT MODE PATTERN DESCRIPTION -- [VAR=value...] ARGS...
# MODE: has -> output MUST contain PATTERN
# lacks -> output must NOT contain PATTERN
#
# Any leading VAR=value tokens after the -- are exported for that run only, so
# a case can select its env file (ENV_FILE=.env.local.<property>) or set the
# guard override without leaking either into the next case.
expect() {
local want_exit="$1" mode="$2" pattern="$3" desc="$4"; shift 4
[[ "$1" == "--" ]] && shift

local envs=()
while [[ $# -gt 0 && "$1" == [A-Z_]*=* ]]; do envs+=("$1"); shift; done

local out got_exit
out=$(cd "$SANDBOX" && ./setup-zitadel.sh "$@" 2>&1)
out=$(cd "$SANDBOX" && env ${envs[@]+"${envs[@]}"} ./setup-zitadel.sh "$@" 2>&1)
got_exit=$?

local problem=""
Expand Down Expand Up @@ -249,9 +275,12 @@ expect_payload() {
local endpoint="$1" mode="$2" pattern="$3" desc="$4"; shift 4
[[ "$1" == "--" ]] && shift

local envs=()
while [[ $# -gt 0 && "$1" == [A-Z_]*=* ]]; do envs+=("$1"); shift; done

: > "$REQ_LOG"
local out got_exit
out=$(cd "$SANDBOX" && ./setup-zitadel.sh "$@" 2>&1)
out=$(cd "$SANDBOX" && env ${envs[@]+"${envs[@]}"} ./setup-zitadel.sh "$@" 2>&1)
got_exit=$?

local body
Expand Down Expand Up @@ -332,11 +361,11 @@ echo "${B}[selftest]${N} --- CDCF: one app per environment, no localhost in prod

expect 0 has "http://localhost:3000/api/auth/callback/zitadel" \
"local registers the localhost origin" -- \
--target local --provision-cdcf-website
ENV_FILE=.env.local.cdcf-website --target local --provision-cdcf-website

expect 0 has "devMode=true" \
"local sets devMode, which the HTTP localhost callback requires" -- \
--target local --provision-cdcf-website
ENV_FILE=.env.local.cdcf-website --target local --provision-cdcf-website

expect 0 has "https://staging.catholicdigitalcommons.org/api/auth/callback/zitadel" \
"staging registers the staging origin on the Non-Prod app" -- \
Expand All @@ -358,7 +387,7 @@ echo "${B}[selftest]${N} --- Martyrology: unchanged behaviour, shared skip helpe

expect 0 has "http://localhost:3000/api/auth/callback/zitadel" \
"local registers the localhost origin" -- \
--target local --provision-martyrology-frontend
ENV_FILE=.env.local.martyrology-frontend --target local --provision-martyrology-frontend

expect 0 has "https://romanmartyrology.com/api/auth/callback/zitadel" \
"production registers the production origin" -- \
Expand All @@ -376,7 +405,99 @@ expect 0 has "skipping" \

expect 0 has "skipping" \
"--all --target local sweeps past LitCal rather than failing the sweep" -- \
--target local --all
ZITADEL_ALLOW_FOREIGN_PAT=1 --target local --all

echo "${B}[selftest]${N} --- issue #34: --target local cross-provisioning guard ---"

# The failure this blocks is SILENT, not loud: a PAT that is a valid IAM_OWNER
# for cdcf-website's local Zitadel will happily create Martyrology's Project,
# roles and app inside it, and every API call succeeds. Both instances are
# "local", so there is no target-name difference to notice. The guard reads the
# property out of the PAT's own path and refuses when it disagrees with the
# action.

expect 17 has "martyrology-api" \
"provisioning Martyrology with cdcf-website's PAT refuses, and names the property it wanted" -- \
ENV_FILE=.env.local.cdcf-website --target local --provision-martyrology

expect 17 has "cdcf-website" \
"the refusal also names the property the PAT actually belongs to" -- \
ENV_FILE=.env.local.cdcf-website --target local --provision-martyrology

expect 17 lacks "handoff values" \
"a refused run writes nothing — no handoff block means no Project was created" -- \
ENV_FILE=.env.local.cdcf-website --target local --provision-martyrology

expect 0 has "Martyrology handoff values" \
"the same action with its OWN property's PAT provisions normally" -- \
ENV_FILE=.env.local.martyrology-api --target local --provision-martyrology

expect 17 has "martyrology-frontend" \
"the frontend app belongs only in the frontend's own stack, not the API's" -- \
ENV_FILE=.env.local.martyrology-api --target local --provision-martyrology-frontend

# A property's stack may legitimately host another property's Project.
# martyrology-frontend/scripts/setup-stack.sh runs --provision-martyrology
# against its OWN instance, because the frontend authenticates there and needs
# the Martyrology Project and roles to exist. Pinning the action to
# martyrology-api alone would refuse that correct run — so the check is an
# allow-list, and what stays refused is the cross-FAMILY case #34 reported.
expect 0 has "Martyrology handoff values" \
"the frontend's stack may provision the Martyrology Project it authenticates against" -- \
ENV_FILE=.env.local.martyrology-frontend --target local --provision-martyrology

expect 0 has "Martyrology Frontend" \
"martyrology-frontend's real setup-stack.sh invocation is not broken by the guard" -- \
ENV_FILE=.env.local.martyrology-frontend --target local \
--create-org Martyrology --provision-martyrology --provision-martyrology-frontend

expect 17 has "martyrology-api or martyrology-frontend" \
"but cdcf-website's stack still may not — that is the cross-family bug in #34" -- \
ENV_FILE=.env.local.cdcf-website --target local --provision-martyrology

expect 17 has "ENV_FILE=.env.local." \
"a bare shared .env.local infers no property at all, and is refused with the fix" -- \
--target local --provision-cdcf-website

expect 0 has "Provisioning CDCF Website" \
"the override lets an unusual layout through" -- \
ZITADEL_ALLOW_FOREIGN_PAT=1 --target local --provision-cdcf-website

echo "${B}[selftest]${N} --- the guard is local-only, and instance-wide actions are exempt ---"

expect 0 has "Org already exists" \
"--create-orgs is instance-wide, so it passes with any PAT" -- \
--target local --create-orgs

expect 0 lacks "belongs to" \
"--rename-bootstrap-admin is instance-wide too and is never guarded" -- \
--target local --rename-bootstrap-admin

expect 0 has "CDCF Website handoff values" \
"production is never guarded — the PAT path convention is a local-stack thing" -- \
--target production --provision-cdcf-website

expect 0 has "CDCF Website (Non-Prod)" \
"staging is never guarded either" -- \
--target staging --provision-cdcf-website

expect 17 has "belongs to" \
"a local --all spans properties, so it cannot resolve to one instance" -- \
ENV_FILE=.env.local.cdcf-website --target local --all

echo "${B}[selftest]${N} --- LitCal has no local stack: skip, never a silent write ---"

expect 0 has "No local stack is defined for LiturgicalCalendar" \
"--provision-litcal skips on local rather than landing LitCal in another property's Zitadel" -- \
ENV_FILE=.env.local.cdcf-website --target local --provision-litcal

expect 0 lacks "LiturgicalCalendar handoff values" \
"the skip is real: no Project, no roles, no app, no handoff block" -- \
ENV_FILE=.env.local.cdcf-website --target local --provision-litcal

expect 0 has "LiturgicalCalendarAPI/scripts/setup-zitadel.sh" \
"and it names the script that DOES provision LitCal locally" -- \
ENV_FILE=.env.local.cdcf-website --target local --provision-litcal

echo "${B}[selftest]${N} --- usage exits are unchanged ---"

Expand Down Expand Up @@ -427,7 +548,7 @@ expect_payload UpdateApplication has "https://catholicdigitalcommons.org/api/aut
existing_apps "CDCF Website"
expect_payload UpdateApplication has '"devMode": true' \
"CDCF local update sends devMode=true, which the HTTP callback requires" -- \
--target local --provision-cdcf-website
ENV_FILE=.env.local.cdcf-website --target local --provision-cdcf-website

# The name filter is how the target picks WHICH app to touch. If it regressed,
# a staging run would find and then overwrite the production app.
Expand Down
Loading
Loading