Skip to content

Commit b56f1cd

Browse files
committed
Docs
1 parent 2a15525 commit b56f1cd

3 files changed

Lines changed: 123 additions & 1 deletion

File tree

.claude/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"Bash(git config *)",
1111
"Bash(command -v gh)",
1212
"Bash(gh api *)",
13-
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")"
13+
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")",
14+
"Bash(grep -nA6 \"TENANT_BYPASSES: list\" api/main.py)",
15+
"Bash(grep -nA10 \"STRIP_REQUEST_HEADERS = \" api/main.py)"
1416
]
1517
}
1618
}

CLAUDE.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,84 @@ enforced at this layer; if required, it must be enforced downstream
116116
(`workspaces-openstreetmap-website/`, `workspaces-cgimap/`) — that has not been
117117
audited here.
118118

119+
## The OSM proxy layer: routing, auth, and user provisioning
120+
121+
This service is a reverse proxy in front of the OSM website (`osm-rails`) and
122+
cgimap. The non-obvious parts, learned the hard way:
123+
124+
### Deployment routing (workspaces-stack)
125+
126+
In `workspaces-stack`, the **api container (this backend) serves the public OSM
127+
host** (`osm.workspaces-<env>...`) alongside the API hosts — its traefik router
128+
rule is `Host(new-api) || Host(api) || Host(osm)`, and the `osm-web` /
129+
`osm-log-proxy` routers are commented out. So **every OSM call the frontend
130+
makes routes through this backend**: `validate_token`, the `X-Workspace` gate,
131+
and the CORS middleware all apply. The backend then proxies `/api/0.6/*` to
132+
`WS_OSM_HOST` (config default `http://osm-web` — the internal service, *not* the
133+
public domain). `osm-web`'s nginx splits traffic: changeset read/write subpaths
134+
to cgimap, everything else to osm-rails.
135+
136+
The frontend (`services/osm.ts`) calls the OSM host directly with
137+
`credentials: 'include'` + a Bearer TDEI token. Because it's *credentialed*
138+
CORS, `CORS_ORIGINS` must list the exact frontend origin (no `*`;
139+
`allow_credentials` is on). The stack supplies `WS_API_CORS_ORIGINS` as a **JSON
140+
array** (`["https://..."]`), while older config comma-split it — a JSON array
141+
would then become one malformed origin. `api/main.py` therefore reads
142+
`settings.cors_origins_list`, which parses **both** a JSON array and a
143+
comma-separated string (and `*`); set `CORS_ORIGINS` in either form.
144+
145+
### Two databases; `users` is owned by OSM Rails
146+
147+
`TASK_DATABASE_URL` (`alembic_task` tree) holds workspaces / tasking-manager
148+
tables; `OSM_DATABASE_URL` (`alembic_osm` tree) holds OSM data, `users`, and the
149+
`tasking_*` tables. The **`users` table is owned by the OSM Rails website**, not
150+
either alembic tree — the trees only FK to it, and integration tests stub it
151+
(`tests/integration/conftest.py`). The backend provisions `users` rows itself
152+
via raw SQL with `auth_provider='TDEI'` and `auth_uid = str(<JWT sub>)` (the OSM
153+
`auth_uid` **is** the token's `sub` claim).
154+
155+
### How OSM authenticates, and the TDEI token bridge
156+
157+
* **osm-rails** authenticates API calls *only* via **doorkeeper OAuth2**: it
158+
looks the bearer token up in `oauth_access_tokens` (`token`
159+
`resource_owner_id``users.id`). Doorkeeper stores tokens **plaintext**
160+
(`SecretStoring::Plain`), so the raw JWT matches directly. It has **no**
161+
TDEI/JWT auth path.
162+
* **cgimap** has both: the oauth2 lookup *and* a custom TDEI path
163+
(`get_user_id_for_tdei_token`, which verifies the JWT and matches
164+
`users.auth_uid`).
165+
166+
To make TDEI tokens work against osm-rails without forking it, the **token
167+
bridge** (`_bridge_token_to_osm` in `api/core/security.py`) mirrors a validated
168+
TDEI JWT into `oauth_access_tokens` — on the cache-miss path of `validate_token`
169+
(so ~once per token, not per request). Gated by `WS_OSM_TOKEN_BRIDGE_ENABLED`.
170+
It auto-provisions everything: a dedicated **system user** (`WS_OSM_SYSTEM_USER_*`)
171+
to own the doorkeeper `oauth_applications` row it creates (keyed by
172+
`WS_OSM_OAUTH_CLIENT_UID`; `oauth_applications.owner_id` is a NOT-NULL FK to
173+
`users`, hence the system user), the caller's `users` row, and the plaintext
174+
`oauth_access_tokens` row (`expires_in` from the JWT `exp`;
175+
`ON CONFLICT (token) DO UPDATE` so re-presenting reactivates it). On token
176+
rotation (new `jti`) the superseded token is revoked. Since the token then lives
177+
in `oauth_access_tokens`, both osm-rails and cgimap authenticate it via plain
178+
OAuth2 — cgimap's custom TDEI path becomes redundant.
179+
180+
### Provisioning gotcha: `pass_crypt` must be 8..255 chars
181+
182+
The OSM `User` model has `validates :pass_crypt, :length => 8..255`. When the
183+
backend provisions a `users` row, **`pass_crypt` must be 8–255 chars** (use a
184+
random throwaway — TDEI manages auth, so it's never used to log in). A too-short
185+
value (the old `'none'`) makes the user *invalid*. This is silent for **cgimap**
186+
operations — cgimap runs no Rails validations — but breaks any **osm-rails**
187+
operation that re-validates the author via `validates :author, :associated =>
188+
true`: notably `POST /api/0.6/changeset/{id}/comment` and note comments. The
189+
comment fails to save (no `id`), then rendering it throws *"Unable to serialize
190+
… without an id"*. Both provisioning paths (`_ensure_osm_user`,
191+
`_provision_users_from_tdei`) set a valid `pass_crypt`; migration
192+
`alembic_osm/versions/f3a7b9c1d2e4` heals legacy short rows. General principle:
193+
a backend-provisioned `users` row must satisfy OSM's `User` validations (also
194+
`display_name` 3..255 + unique, `email` present + unique) or Rails operations
195+
that touch it will fail even though cgimap operations succeed.
196+
119197
## Testing
120198

121199
Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,48 @@ This is a combination API backend for workspaces, providing /workspaces* methods
66
the OSM API ("openstreetmap website") plus OSM CGI-map (the C-accelerated methods) that enforces
77
authorization and authentication based on a TDEI/Keycloak JWT token (see main.py for this proxy logic).
88

9+
## What the proxy must provide for osm-rails / osm-web
10+
11+
This backend is the **only entry point** to the OSM tier (osm-rails + cgimap,
12+
behind `osm-web`): in the deployment, the public OSM host routes to this
13+
container, which proxies `/api/0.6/*` to `WS_OSM_HOST` (default
14+
`http://osm-web`). For the OSM services to work, the proxy must uphold the
15+
following contract. `CLAUDE.md` has the full rationale.
16+
17+
1. **Bridge TDEI auth into OSM's OAuth2.** osm-rails authenticates the API *only*
18+
via doorkeeper OAuth2 (`oauth_access_tokens`); it has no TDEI/JWT path. So on
19+
token validation the backend mirrors the TDEI JWT into `oauth_access_tokens`
20+
in the OSM DB (the "token bridge" in `api/core/security.py`), and forwards the
21+
incoming `Authorization: Bearer <token>` header unchanged. Then osm-rails and
22+
cgimap authenticate the token via plain OAuth2. Controlled by
23+
`WS_OSM_TOKEN_BRIDGE_ENABLED` (on by default) — with it off, osm-rails returns
24+
**401** for TDEI tokens. The backend auto-creates the doorkeeper application
25+
(and a system user to own it), so no manual OSM setup is required.
26+
27+
2. **Provision valid OSM `users` rows.** The backend creates OSM `users` rows for
28+
TDEI users (`auth_provider='TDEI'`, `auth_uid` = the JWT `sub`). These must
29+
satisfy OSM's `User` validations — in particular `pass_crypt` length 8..255.
30+
A too-short value is invisible to cgimap but makes osm-rails operations that
31+
re-validate the user fail (e.g. posting a changeset comment or a note),
32+
surfacing as *"Unable to serialize … without an id"*. The
33+
`alembic_osm` migration `*_heal_short_tdei_pass_crypt` repairs legacy rows.
34+
35+
3. **Carry workspace tenancy.** Workspace-scoped OSM requests must include an
36+
`X-Workspace: <id>` header. The proxy authorizes it against the caller's
37+
workspaces and forwards it (it is *not* stripped) so cgimap/osm-rails scope to
38+
the `workspace-<id>` schema. A few paths are exempt (`TENANT_BYPASSES` in
39+
`api/main.py`): workspace create/delete (`PUT`/`DELETE /api/0.6/workspaces/{id}`)
40+
and user provisioning during sign-in (`PUT /api/0.6/user/{uid}`).
41+
42+
4. **Set the proxy headers.** The proxy rewrites `Host` to the OSM host and sets
43+
`X-Real-IP` / `X-Forwarded-For` / `-Host` / `-Proto`, while stripping
44+
hop-by-hop headers and any spoofed forwarding headers from the client. It does
45+
*not* strip `Authorization` or `X-Workspace`.
46+
47+
5. **Connectivity.** `WS_OSM_HOST` must reach `osm-web`, and the backend needs
48+
**both** `OSM_DATABASE_URL` and `TASK_DATABASE_URL` — the token bridge and
49+
user provisioning write to the OSM database.
50+
951
## Branch Index
1052

1153
* ```develop``` merge your work here; keep this up to date with the "development" environment / dev tag

0 commit comments

Comments
 (0)