docs: identity-binding rules + promotion-window hardening checklist
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c674db4746
commit
74d206cb4a
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user