@@ -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
117117audited 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
121199Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or
0 commit comments