docs: identity-binding rules + promotion-window hardening checklist

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nirav Patel
2026-08-19 09:18:54 -04:00
co-authored by Claude Fable 5
parent c674db4746
commit 74d206cb4a
+59 -12
View File
@@ -32,18 +32,47 @@ git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh.
* `GET /health` * `GET /health`
* `POST /v1/link {zitadel_access_token, device_name}` → validates the token * `POST /v1/link {zitadel_access_token, device_name}` → validates the token
against `https://id.shre.ai/oidc/v1/userinfo`, derives a login from against `https://id.shre.ai/oidc/v1/userinfo`, applies the **identity
`preferred_username` (email local-part fallback), ensures the Gitea user binding rules** (below), mints a token scoped
exists, mints a token scoped `write:repository,write:user`, returns `write:repository,write:user`, returns
`{gitea_base, login, token, token_name}`. `{gitea_base, login, token, token_name}`. POST bodies are capped at
**64 KB** (413 beyond; missing `Content-Length` → 411, invalid → 400).
* `POST /v1/repos {token, name, private}` → creates the user repo with the * `POST /v1/repos {token, name, private}` → creates the user repo with the
USER token; clone/html URLs are rebased onto `public_gitea_base` because the USER token; clone/html URLs are rebased onto `public_gitea_base` because the
container `ROOT_URL` (https://granthi-beta.shre.ai) does not resolve for container `ROOT_URL` (https://granthi-beta.shre.ai) does not resolve for
tailnet-only clients. tailnet-only clients.
Deployment: `/opt/granthi-link/{granthi_link.py,config.json}` + systemd unit Deployment: `/opt/granthi-link/{granthi_link.py,config.json,state.json}` +
`granthi-link.service`; binds `127.0.0.1:3042` **and** `100.111.127.127:3042` systemd unit `granthi-link.service`; binds `127.0.0.1:3042` **and**
(tailnet). **Not publicly exposed** — see promotion window. `100.111.127.127:3042` (tailnet). **Not publicly exposed** — see promotion
window. The service **refuses to start** (exit 2) unless `config.json` is
mode 0600/0400 and owned by the user it runs as — the config carries the
forge admin password, so permissive perms fail closed, not open.
#### Identity binding (`state.json`)
`/v1/link` originally bound purely by `preferred_username` / email
local-part — any Zitadel identity whose derived login collided with an
existing account got a token for that account (account takeover). The
service now persists a map of Zitadel `sub` → Gitea login in
`/opt/granthi-link/state.json` (0600, atomic tmp+rename writes) and applies:
1. **Mapped sub** → always the mapped login, regardless of what the current
userinfo claims. If the mapped login was deleted from the forge it is
re-created only when the service created it originally; adopted accounts
are refused (409).
2. **Unmapped sub, login free** → create the user, record the mapping
(`created_by_service: true`). A concurrent-create 409 from Gitea is
handled idempotently: the user is re-fetched and accepted only if its
primary email is exactly the one this request would have set.
3. **Unmapped sub, login taken** → bind ONLY when the Gitea user's primary
email equals the Zitadel userinfo `email` **and** `email_verified` is
true (recorded with `created_by_service: false`); anything else →
`409 login exists and is not linked to this identity`.
A Gitea token is **never minted before the binding rule passes**, and a
corrupt/unreadable `state.json` fails closed (500) instead of falling back
to an empty map.
Empirically verified mechanics on Gitea **1.27.1** (beta forge): Empirically verified mechanics on Gitea **1.27.1** (beta forge):
@@ -62,7 +91,9 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge):
regardless of this choice. regardless of this choice.
* `test_mode` (config flag, **never in production**): allows `/v1/link` to * `test_mode` (config flag, **never in production**): allows `/v1/link` to
accept `test_userinfo` in the body instead of a Zitadel round-trip, so E2E accept `test_userinfo` in the body instead of a Zitadel round-trip, so E2E
can exercise the ensure-user + mint path headlessly. can exercise the ensure-user + mint path headlessly. It is honored **only
when the service environment also sets `GRANTHI_LINK_ALLOW_TEST_MODE=1`**;
a config flag without the env gate is logged as an ERROR and ignored.
### `client/granthi_sync_client.py` (+ `bin/granthi-sync`) — client daemon ### `client/granthi_sync_client.py` (+ `bin/granthi-sync`) — client daemon
@@ -88,11 +119,15 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge):
## Tests ## Tests
* `python3 -m unittest discover -s tests`24 tests: autocommit/ff/diverged * `python3 -m unittest discover -s tests`53 tests: autocommit/ff/diverged
logic against real temp git repos (including "diverged never touches the logic against real temp git repos (including "diverged never touches the
remote"), config 0600 handling, mocked device-flow polling, credential remote"), config 0600 handling (including umask-proof creation and a
helper protocol, and the full `/v1/link` + `/v1/repos` service flows no-chmod guard), credential-helper quoting/injection, mocked device-flow
against an in-process stub playing Zitadel + Gitea. polling, the full `/v1/link` + `/v1/repos` service flows against an
in-process stub playing Zitadel + Gitea, all identity-binding rules
(collision 409, verified-email adoption, deleted-login re-create/refuse,
concurrent-create race, corrupt-state fail-closed), the test_mode env
gate, config-permission refusal, and the 64 KB body cap.
* Live E2E against the beta forge is recorded in the delivery notes * Live E2E against the beta forge is recorded in the delivery notes
(link → add → watch ff/push → forced divergence → DIVERGED skip verified (link → add → watch ff/push → forced divergence → DIVERGED skip verified
via API, remote sha untouched). via API, remote sha untouched).
@@ -120,5 +155,17 @@ Every linked folder becomes a private repo under their account.
promotion is an operator action, not an agent action). promotion is an operator action, not an agent action).
4. Add rate limiting / abuse controls before public exposure (one token mint 4. Add rate limiting / abuse controls before public exposure (one token mint
per link call today). per link call today).
5. **Hardening checklist (must all hold before exposing):**
- [ ] `config.json` is 0600 (or 0400) and owned by the service user —
the service refuses to start otherwise; verify with
`systemctl status granthi-link` after any config edit.
- [ ] `test_mode` is absent from the production config **and**
`GRANTHI_LINK_ALLOW_TEST_MODE` is not set in the unit environment.
- [ ] `/opt/granthi-link/state.json` exists, is 0600, and is included in
VPS backups — losing it orphans sub→login bindings (existing users
would need verified-email re-adoption).
- [ ] `GET /health` returns 200 on both binds after restart.
- [ ] Spot-check the identity map: a repeat link for a known sub returns
the same login; a colliding username with a different sub gets 409.
<!-- review-service live check 20260819T131430Z --> <!-- review-service live check 20260819T131430Z -->