Skip to content

Commit 64edee2

Browse files
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/SupabaseSecurityConfig.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ public class SupabaseSecurityConfig {
8585
@Value("${app.supabase.clock-skew-seconds:120}")
8686
private long clockSkewSeconds;
8787

88+
@Value("${stirling.billing.account-link.enabled:false}")
89+
private boolean accountLinkEnabled;
90+
8891
@Bean
8992
SecurityFilterChain saasSecurityFilterChain(
9093
HttpSecurity http,
@@ -288,6 +291,28 @@ public OAuth2TokenValidatorResult validate(Jwt token) {
288291
private static final List<String> LOOPBACK_ANY_PORT =
289292
List.of("http://localhost:[*]", "http://127.0.0.1:[*]");
290293

294+
/**
295+
* The surface a linked self-hosted instance calls from its own browser with the signed-in
296+
* admin's Supabase JWT, mirroring the frontend's {@code apiClient.saas}. Its origin is whatever
297+
* the customer deployed on, so these cannot be served by an allow-list. See {@link
298+
* #linkedInstanceCors()}.
299+
*
300+
* <p>Each hosts cloud-only endpoints, which is what makes a prefix safe here: billing,
301+
* procurement, legal documents and the team's roster of linked instances have no self-hosted
302+
* equivalent. Anything the instance also serves itself belongs on {@code apiClient.local}
303+
* instead of here.
304+
*
305+
* <p>Only the {@code instances} half of account-link is listed. The {@code connect/*} handshake
306+
* never reaches a browser on the customer's origin: the instance backend calls {@code request}
307+
* and {@code claim} server-side, and the approval page is served by us.
308+
*/
309+
private static final List<String> LINKED_INSTANCE_PATHS =
310+
List.of(
311+
"/api/v1/payg/**",
312+
"/api/v1/procurement/**",
313+
"/api/v1/legal/**",
314+
"/api/v1/account-link/instances/**");
315+
291316
/**
292317
* Profiles that mean "a developer's machine or a preview environment", never the production
293318
* deployment. Production runs the bare {@code saas} profile.
@@ -375,10 +400,37 @@ CorsConfigurationSource corsConfigurationSource() {
375400
cfg.setAllowCredentials(true);
376401
cfg.setMaxAge(3600L);
377402
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
403+
// Registered ahead of "/**": the source returns the first pattern that matches, not the
404+
// most specific one.
405+
if (accountLinkEnabled) {
406+
CorsConfiguration linked = linkedInstanceCors();
407+
for (String path : LINKED_INSTANCE_PATHS) {
408+
source.registerCorsConfiguration(path, linked);
409+
}
410+
}
378411
source.registerCorsConfiguration("/**", cfg);
379412
return source;
380413
}
381414

415+
/**
416+
* Any-origin CORS for the reads a linked self-hosted instance makes from its own browser, whose
417+
* origin cannot be known in advance.
418+
*
419+
* <p>Safe only because it carries no credentials: this chain is bearer-token only and nothing
420+
* in the SaaS module reads a cookie, so the browser attaches no ambient authority and a hostile
421+
* page has nothing to ride on. The same reasoning already justifies disabling CSRF here.
422+
* Authorisation is unchanged; this decides only who may read the response.
423+
*/
424+
private static CorsConfiguration linkedInstanceCors() {
425+
CorsConfiguration cfg = new CorsConfiguration();
426+
cfg.setAllowedOrigins(List.of(CorsConfiguration.ALL));
427+
cfg.setAllowedMethods(List.of("GET", "POST", "PATCH", "OPTIONS"));
428+
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept"));
429+
cfg.setAllowCredentials(false);
430+
cfg.setMaxAge(3600L);
431+
return cfg;
432+
}
433+
382434
/**
383435
* Maps Supabase JWT claims onto Spring Security authorities. Package-private static so unit
384436
* tests can call it directly without instantiating the full security config.

app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212
import org.junit.jupiter.api.Nested;
1313
import org.junit.jupiter.api.Test;
1414
import org.junit.jupiter.api.extension.ExtendWith;
15+
import org.junit.jupiter.params.ParameterizedTest;
16+
import org.junit.jupiter.params.provider.ValueSource;
1517
import org.mockito.Mock;
1618
import org.mockito.junit.jupiter.MockitoExtension;
1719
import org.springframework.core.env.Environment;
20+
import org.springframework.http.HttpMethod;
1821
import org.springframework.mock.env.MockEnvironment;
22+
import org.springframework.mock.web.MockHttpServletRequest;
1923
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
2024
import org.springframework.security.oauth2.jwt.Jwt;
2125
import org.springframework.security.oauth2.jwt.JwtDecoder;
@@ -352,4 +356,86 @@ void anonymousMapsLimited() {
352356
.contains("ROLE_LIMITED_API_USER");
353357
}
354358
}
359+
360+
@Nested
361+
@DisplayName("linked-instance CORS")
362+
class LinkedInstanceCors {
363+
364+
/** An origin no allow-list could ever contain: a customer's own deployment. */
365+
private static final String SELF_HOSTED = "http://54.175.155.236:7779";
366+
367+
private CorsConfigurationSource source(boolean accountLinkEnabled) {
368+
SupabaseSecurityConfig cfg = config(new ApplicationProperties());
369+
ReflectionTestUtils.setField(cfg, "accountLinkEnabled", accountLinkEnabled);
370+
return cfg.corsConfigurationSource();
371+
}
372+
373+
private CorsConfiguration resolve(CorsConfigurationSource source, String path) {
374+
return source.getCorsConfiguration(new MockHttpServletRequest("GET", path));
375+
}
376+
377+
@ParameterizedTest
378+
@ValueSource(
379+
strings = {
380+
"/api/v1/payg/wallet",
381+
"/api/v1/payg/wallet/refresh",
382+
"/api/v1/payg/invoices",
383+
"/api/v1/payg/cap",
384+
"/api/v1/procurement/quote",
385+
"/api/v1/legal/consent",
386+
// The Settings page's "who is linked" table, and its revoke button.
387+
"/api/v1/account-link/instances",
388+
"/api/v1/account-link/instances/42/revoke"
389+
})
390+
@DisplayName("every apiClient.saas path is readable from any origin")
391+
void portalReadsAllowAnyOrigin(String path) {
392+
CorsConfiguration cfg = resolve(source(true), path);
393+
394+
assertThat(cfg.checkOrigin(SELF_HOSTED)).isEqualTo("*");
395+
// The wildcard is only defensible without credentials. These must never both be set:
396+
// the browser rejects the pair outright, and it would be an open credentialed API.
397+
assertThat(cfg.getAllowCredentials()).isNotEqualTo(Boolean.TRUE);
398+
}
399+
400+
@Test
401+
@DisplayName("PATCH is allowed; the cap endpoint needs it")
402+
void patchAllowed() {
403+
CorsConfiguration cfg = resolve(source(true), "/api/v1/payg/cap");
404+
405+
assertThat(cfg.checkHttpMethod(HttpMethod.PATCH)).isNotNull();
406+
}
407+
408+
@ParameterizedTest
409+
@ValueSource(
410+
strings = {
411+
"/api/v1/instance/sync",
412+
// The instance serves its own audit trail and Documents feed, so the portal
413+
// reads these from apiClient.local. They must never need cross-origin access.
414+
"/api/v1/proprietary/ui-data/documents",
415+
"/api/v1/proprietary/ui-data/audit-export",
416+
"/api/v1/proprietary/ui-data/infrastructure/audit-log",
417+
"/api/v1/proprietary/ui-data/admin-settings",
418+
"/api/v1/proprietary/ui-data/database",
419+
// The handshake is server-side plus our own approval page, never the
420+
// customer's origin.
421+
"/api/v1/account-link/connect/request",
422+
"/api/v1/account-link/connect/claim"
423+
})
424+
@DisplayName("every other path keeps the credentialed allow-list")
425+
void otherPathsUnchanged(String path) {
426+
CorsConfiguration cfg = resolve(source(true), path);
427+
428+
assertThat(cfg.getAllowCredentials()).isTrue();
429+
assertThat(cfg.checkOrigin(SELF_HOSTED)).isNull();
430+
}
431+
432+
@Test
433+
@DisplayName("no wildcard at all when account linking is off")
434+
void flagOffKeepsAllowList() {
435+
CorsConfiguration cfg = resolve(source(false), "/api/v1/payg/wallet");
436+
437+
assertThat(cfg.getAllowCredentials()).isTrue();
438+
assertThat(cfg.checkOrigin(SELF_HOSTED)).isNull();
439+
}
440+
}
355441
}

frontend/editor/src/portal/api/documents.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,8 @@ export const DOC_AUDIT_TONE: Record<DocAuditKind, StatusTone> = {
123123
elevation: "purple",
124124
};
125125

126-
/** GET the audit-derived Documents feed; SaaS or local, scoped server-side. `tier` ignored. */
126+
/** GET the audit-derived Documents feed, scoped server-side. `tier` ignored. */
127127
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
128128
const path = `/api/v1/proprietary/ui-data/documents?tier=${encodeURIComponent(tier)}`;
129-
return apiClient.saas.isConfigured()
130-
? apiClient.saas.json<DocumentsResponse>(path)
131-
: apiClient.local.json<DocumentsResponse>(path);
129+
return apiClient.local.json<DocumentsResponse>(path);
132130
}

frontend/editor/src/portal/api/http.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,5 @@ export const apiClient = {
324324
json: saasJson,
325325
text: saasText,
326326
blob: saasBlob,
327-
/** True when a SaaS base URL is resolvable. Doesn't check session liveness. */
328-
isConfigured: (): boolean => saasBaseUrl() !== null,
329327
},
330328
} as const;

frontend/editor/src/portal/api/infrastructure.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,23 +106,19 @@ export async function revokeApiKey(id: string): Promise<void> {
106106
await apiClient.local.json<void>(path, { method: "DELETE" });
107107
}
108108

109-
/** GET the audit log; SaaS or local, backend-scoped (admin → server, SaaS lead → team). */
109+
/** GET the audit log, backend-scoped (admin → server, SaaS lead → team). */
110110
export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> {
111111
const path = `/api/v1/proprietary/ui-data/infrastructure/audit-log${q(tier)}`;
112-
return apiClient.saas.isConfigured()
113-
? apiClient.saas.json<AuditLogResponse>(path)
114-
: apiClient.local.json<AuditLogResponse>(path);
112+
return apiClient.local.json<AuditLogResponse>(path);
115113
}
116114

117-
/** Download the audit log as a CSV/JSON blob (admin-only, whole-server); SaaS or local. */
115+
/** Download the audit log as a CSV/JSON blob (admin-only, whole-server). */
118116
export async function exportAuditLog(
119117
format: "csv" | "json",
120118
fields: string,
121119
): Promise<Blob> {
122120
const path = `/api/v1/proprietary/ui-data/audit-export?format=${format}&fields=${encodeURIComponent(
123121
fields,
124122
)}`;
125-
return apiClient.saas.isConfigured()
126-
? apiClient.saas.blob(path)
127-
: apiClient.local.blob(path);
123+
return apiClient.local.blob(path);
128124
}

0 commit comments

Comments
 (0)