Commit 64edee2
authored
Let a linked self-hosted instance read its own SaaS data (#7798)
## Current state
A self-hosted instance that has linked its SaaS account talks to SaaS
over two channels:
1. **Server to server.** `AccountLinkClient` uses
`java.net.http.HttpClient` with the device credential against
`/api/v1/instance/*`. No browser, so CORS never applies. This works
today.
2. **Browser to SaaS.** The portal's `apiClient.saas` fetches SaaS
directly from the instance's own page, carrying the signed-in admin's
Supabase JWT.
Channel 2 is blocked. `corsConfigurationSource()` allows a fixed origin
list (`localhost:3000/5173/8080`, `stirling.com`, `app.stirling.com`,
`api.stirling.com`, the Tauri origins, plus loopback-any-port outside
production). A customer's instance is on none of them.
## Problem
**Self-hosted origins cannot be allow-listed.** Every deployment has a
different scheme, host and port, they are not known ahead of time, and
each entry would be a standing grant to return credentialed responses to
that origin. `setAllowCredentials(true)` also rules out `*`, since
browsers reject that pair.
**Three endpoints were pointed at the wrong backend.** `fetchDocuments`,
`fetchAuditLog` and `exportAuditLog` chose their backend with
`apiClient.saas.isConfigured()`, which answers "is a SaaS URL set", not
"am I the SaaS build". Those coincide only while self-hosted never sets
`VITE_SAAS_API_URL` — which linking now requires.
## Solution
### 1. Send the instance's own data to the instance
Documents and the audit trail are local to a self-hosted deployment.
SaaS holds no audit rows for a linked instance: the daily sync carries
three counters (`api`, `ai`, `automation`) and nothing else. So a linked
instance was silently showing the admin's **cloud team** data in place
of the server's, with no error.
`apiClient.local` already resolves per flavor via the `localBackend`
seam (self-hosted → local Spring bearer, SaaS → SaaS backend + Supabase
JWT), so these three just use it. No-op on the SaaS build, correct on
self-hosted.
### 2. Remove the affordance that caused it
`apiClient.saas.isConfigured()` is deleted. With those three call sites
fixed it was dead code, and it contradicted the contract the same file
documents a few lines above: calls throw `SaasUnconfiguredError` so
callers can surface a "configure" state "rather than silently routing to
the wrong domain". Removing it makes a relapse a **compile error**
rather than a silent wrong-backend read, which is stronger than a lint
rule. The module header now states the rule directly.
Nothing replaces it: the correct pattern is already in use in
`Usage.tsx`, which catches `SaasUnconfiguredError`, and `http.test.ts`
already pins that behaviour.
### 3. Any-origin CORS for the cloud-only surface
What remains genuinely cross-origin is what has no self-hosted
equivalent: billing, procurement, and legal documents. Register a second
CORS config for those, with `allowedOrigins("*")` and
`allowCredentials(false)`, ahead of the existing `/**` entry.
`UrlBasedCorsConfigurationSource` returns the first matching pattern
rather than the most specific, so order matters; the tests pin the
behaviour either way.
| Pattern | Contents |
| --- | --- |
| `/api/v1/payg/**` | wallet, wallet/refresh, invoices, payment-method,
cap |
| `/api/v1/procurement/**` | one controller |
| `/api/v1/legal/**` | one controller |
| `/api/v1/account-link/instances/**` | the Settings page's "who is
linked" table, and revoke |
All are cloud-only, which is what makes a prefix safe: a team's roster
of linked instances spans instances, and an instance knows only itself.
Only the `instances` half of account-link is opened; the `connect/*`
handshake never reaches a browser on the customer's origin, since the
instance backend calls `request` and `claim` server-side and we serve
the approval page. Methods are limited to GET, POST, PATCH and OPTIONS;
headers to `Authorization`, `Content-Type` and `Accept`.
`/api/v1/instance/**` is deliberately excluded: server to server, should
never see a browser origin. The block sits behind the existing
`stirling.billing.account-link.enabled` flag, so a deployment not
running account linking gets no wildcard at all.
## Why the wildcard is safe here
**Only because it carries no credentials**, which holds on this chain:
- bearer-token only: `STATELESS`, no form login, no HTTP basic
- nothing in `app/saas` reads a cookie (no `@CookieValue`, no
`getCookies()`)
- the cookie/session chain, `SecurityConfiguration`, is
`@Profile("!saas")` and does not run here
- `apiClient.saas` never sets `credentials` on `fetch`, so it defaults
to `same-origin` and sends no cookies cross-origin
- the JWT is in localStorage and attached explicitly, so a hostile page
has nothing to ride on: it cannot read another origin's storage
That is the same reasoning the file already uses to justify disabling
CSRF on this chain.
**Authorisation is unchanged.** Callers still present a JWT and are
still resolved to a team by the existing gates; this decides only which
origins may read a response. In particular it does **not** let the
instance act as a user — the device credential gains no new reach, which
a backend proxy would have given it.
**First-party is unaffected.** On the SaaS build `saasApiBase()` returns
`""` (`VITE_API_BASE_URL=/`), so `app.stirling.com` calls these paths
same-origin and is exempt from CORS entirely. `allowCredentials(false)`
cannot reach it.
## How to test
```bash
ENABLE_SAAS=true ./gradlew :saas:test --tests "*SupabaseSecurityConfigMoreTest*"
```
18 cases in the new `LinkedInstanceCors` class: every remaining
`apiClient.saas` path resolves to the wildcard config and never has
`allowCredentials=true`; PATCH is permitted for the cap endpoint;
`/api/v1/instance/sync`, the three now-local ui-data paths, and
`admin-settings` / `database` all keep the credentialed allow-list; the
`connect/*` endpoints keep it too; and with the flag off there is no
wildcard anywhere.
Frontend:
```bash
cd frontend && npx vitest run --root editor src/portal
```
Verified locally: saas 1307 tests / 0 failures, portal 90 files / 578
tests / 0 failures, typecheck clean on the portal, proprietary, saas and
cloud cascades.
End to end, against a preview with account linking on: link an instance,
open Plan and Usage and confirm the wallet loads with no CORS error;
then open Documents and the audit log and confirm they show the
instance's own activity.1 parent 7b413ce commit 64edee2
5 files changed
Lines changed: 144 additions & 14 deletions
File tree
- app/saas/src
- main/java/stirling/software/saas/security
- test/java/stirling/software/saas/security
- frontend/editor/src/portal/api
Lines changed: 52 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
88 | 91 | | |
89 | 92 | | |
90 | 93 | | |
| |||
288 | 291 | | |
289 | 292 | | |
290 | 293 | | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
291 | 316 | | |
292 | 317 | | |
293 | 318 | | |
| |||
375 | 400 | | |
376 | 401 | | |
377 | 402 | | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
378 | 411 | | |
379 | 412 | | |
380 | 413 | | |
381 | 414 | | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
382 | 434 | | |
383 | 435 | | |
384 | 436 | | |
| |||
Lines changed: 86 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
| 16 | + | |
15 | 17 | | |
16 | 18 | | |
17 | 19 | | |
| 20 | + | |
18 | 21 | | |
| 22 | + | |
19 | 23 | | |
20 | 24 | | |
21 | 25 | | |
| |||
352 | 356 | | |
353 | 357 | | |
354 | 358 | | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
355 | 441 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
126 | | - | |
| 126 | + | |
127 | 127 | | |
128 | 128 | | |
129 | | - | |
130 | | - | |
131 | | - | |
| 129 | + | |
132 | 130 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
324 | 324 | | |
325 | 325 | | |
326 | 326 | | |
327 | | - | |
328 | | - | |
329 | 327 | | |
330 | 328 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
109 | | - | |
| 109 | + | |
110 | 110 | | |
111 | 111 | | |
112 | | - | |
113 | | - | |
114 | | - | |
| 112 | + | |
115 | 113 | | |
116 | 114 | | |
117 | | - | |
| 115 | + | |
118 | 116 | | |
119 | 117 | | |
120 | 118 | | |
121 | 119 | | |
122 | 120 | | |
123 | 121 | | |
124 | 122 | | |
125 | | - | |
126 | | - | |
127 | | - | |
| 123 | + | |
128 | 124 | | |
0 commit comments