diff --git a/README.md b/README.md index f91fb09..96cdeed 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,47 @@ git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh. * `GET /health` * `POST /v1/link {zitadel_access_token, device_name}` → validates the token - against `https://id.shre.ai/oidc/v1/userinfo`, derives a login from - `preferred_username` (email local-part fallback), ensures the Gitea user - exists, mints a token scoped `write:repository,write:user`, returns - `{gitea_base, login, token, token_name}`. + against `https://id.shre.ai/oidc/v1/userinfo`, applies the **identity + binding rules** (below), mints a token scoped + `write:repository,write:user`, returns + `{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 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 tailnet-only clients. -Deployment: `/opt/granthi-link/{granthi_link.py,config.json}` + systemd unit -`granthi-link.service`; binds `127.0.0.1:3042` **and** `100.111.127.127:3042` -(tailnet). **Not publicly exposed** — see promotion window. +Deployment: `/opt/granthi-link/{granthi_link.py,config.json,state.json}` + +systemd unit `granthi-link.service`; binds `127.0.0.1:3042` **and** +`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): @@ -62,7 +91,9 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge): regardless of this choice. * `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 - 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 @@ -88,11 +119,15 @@ Empirically verified mechanics on Gitea **1.27.1** (beta forge): ## 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 - remote"), config 0600 handling, mocked device-flow polling, credential - helper protocol, and the full `/v1/link` + `/v1/repos` service flows - against an in-process stub playing Zitadel + Gitea. + remote"), config 0600 handling (including umask-proof creation and a + no-chmod guard), credential-helper quoting/injection, mocked device-flow + 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 (link → add → watch ff/push → forced divergence → DIVERGED skip verified 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). 4. Add rate limiting / abuse controls before public exposure (one token mint 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.