Skip to content

Commit 37a10cb

Browse files
authored
fix: allow platform paths (ListTenants) without tenant claim in JWT (#1400)
Platform paths like /v1/tenants are bootstrap endpoints where the caller discovers available tenants. Requiring a tenant claim to list tenants is circular — the user needs to know which tenants exist before they can scope requests to one. Export IsPlatformPath from shared/platform/gateway and use it in TenantAuthorizationMiddleware to bypass tenant authorization for authenticated users hitting platform endpoints. The endpoint itself handles access control based on the caller's identity. Fixes the demo login flow where Dex OIDC tokens (which lack custom tenant claims) could not call ListTenants to populate the tenant picker. Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent b5ae569 commit 37a10cb

4 files changed

Lines changed: 117 additions & 39 deletions

File tree

services/api-gateway/auth/combined_middleware.go

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010

1111
platformauth "github.com/meridianhub/meridian/shared/platform/auth"
12+
"github.com/meridianhub/meridian/shared/platform/gateway"
1213
"github.com/meridianhub/meridian/shared/platform/tenant"
1314
)
1415

@@ -245,35 +246,13 @@ func (m *TenantAuthorizationMiddleware) Handler(next http.Handler) http.Handler
245246
return
246247
}
247248

248-
// When JWT has no tenant claim (e.g. standard OIDC tokens from Dex):
249-
// 1. Platform-admin/super-admin: allow access to any tenant
250-
// 2. Regular user with resolved tenant (subdomain/slug): scope to that tenant
251-
// 3. Otherwise: deny
249+
// When JWT has no tenant claim (e.g. standard OIDC tokens from Dex),
250+
// delegate to authorizeWithoutTenantClaim which checks platform-admin
251+
// role, resolved tenant from subdomain, and platform paths.
252252
if jwtTenantID == "" {
253-
claims, hasClaims := GetClaimsFromContext(ctx)
254-
if hasClaims && hasPlatformAdminRole(claims) {
255-
m.logger.Debug("platform admin access",
256-
slog.String("user_id", claims.UserID),
257-
slog.String("path", r.URL.Path),
258-
)
253+
if m.authorizeWithoutTenantClaim(w, r) {
259254
next.ServeHTTP(w, r)
260-
return
261-
}
262-
263-
// Allow if tenant was resolved from subdomain/slug — user inherits
264-
// tenant scope from the request routing rather than from JWT claims.
265-
// This supports OIDC providers (like Dex) that issue identity-only
266-
// tokens without custom tenant claims.
267-
if resolvedTenant, ok := tenant.FromContext(ctx); ok && !resolvedTenant.IsEmpty() {
268-
m.logger.Debug("tenant resolved from request context (no JWT tenant claim)",
269-
slog.String("tenant", resolvedTenant.String()),
270-
slog.String("path", r.URL.Path),
271-
)
272-
next.ServeHTTP(w, r)
273-
return
274255
}
275-
276-
writeForbidden(w, "missing tenant claim in token")
277256
return
278257
}
279258

@@ -307,6 +286,51 @@ func (m *TenantAuthorizationMiddleware) Handler(next http.Handler) http.Handler
307286
})
308287
}
309288

289+
// authorizeWithoutTenantClaim handles authorization for JWTs that lack a tenant
290+
// claim (e.g. standard OIDC tokens from Dex). Returns true if the request is
291+
// allowed, false if a 403 was written.
292+
//
293+
// Authorization paths (checked in order):
294+
// 1. Platform-admin/super-admin: allow access to any tenant
295+
// 2. Resolved tenant from subdomain/slug: scope user to that tenant
296+
// 3. Platform path (e.g., ListTenants): allow — bootstrap endpoint
297+
// 4. Otherwise: deny with 403
298+
func (m *TenantAuthorizationMiddleware) authorizeWithoutTenantClaim(w http.ResponseWriter, r *http.Request) bool {
299+
ctx := r.Context()
300+
301+
claims, hasClaims := GetClaimsFromContext(ctx)
302+
if hasClaims && hasPlatformAdminRole(claims) {
303+
m.logger.Debug("platform admin access",
304+
slog.String("user_id", claims.UserID),
305+
slog.String("path", r.URL.Path),
306+
)
307+
return true
308+
}
309+
310+
// Allow if tenant was resolved from subdomain/slug — user inherits
311+
// tenant scope from the request routing rather than from JWT claims.
312+
if resolvedTenant, ok := tenant.FromContext(ctx); ok && !resolvedTenant.IsEmpty() {
313+
m.logger.Debug("tenant resolved from request context (no JWT tenant claim)",
314+
slog.String("tenant", resolvedTenant.String()),
315+
slog.String("path", r.URL.Path),
316+
)
317+
return true
318+
}
319+
320+
// Allow platform paths (e.g., ListTenants) for any authenticated user.
321+
// These are bootstrap endpoints where the caller discovers available
322+
// tenants — requiring a tenant claim would be circular.
323+
if gateway.IsPlatformPath(r.URL.Path) {
324+
m.logger.Debug("platform path access (no tenant claim required)",
325+
slog.String("path", r.URL.Path),
326+
)
327+
return true
328+
}
329+
330+
writeForbidden(w, "missing tenant claim in token")
331+
return false
332+
}
333+
310334
// hasPlatformAdminRole returns true if claims contain platform-admin or super-admin role.
311335
func hasPlatformAdminRole(claims *platformauth.Claims) bool {
312336
if claims == nil {

services/api-gateway/auth/combined_middleware_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,57 @@ func TestTenantAuthorizationMiddleware(t *testing.T) {
410410
assert.Contains(t, rr.Body.String(), "missing tenant claim")
411411
})
412412

413+
t.Run("platform path allowed without tenant claim for authenticated user", func(t *testing.T) {
414+
middleware := NewTenantAuthorizationMiddleware(logger)
415+
416+
var nextCalled bool
417+
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
418+
nextCalled = true
419+
})
420+
421+
handler := middleware.Handler(nextHandler)
422+
423+
// OIDC token with no tenant claim, no resolved tenant, but platform path
424+
claims := &platformauth.Claims{
425+
UserID: "oidc-user",
426+
}
427+
ctx := injectClaimsToContext(context.Background(), claims)
428+
429+
req := httptest.NewRequest(http.MethodGet, "/v1/tenants", nil)
430+
req = req.WithContext(ctx)
431+
rr := httptest.NewRecorder()
432+
433+
handler.ServeHTTP(rr, req)
434+
435+
assert.Equal(t, http.StatusOK, rr.Code)
436+
assert.True(t, nextCalled, "platform path should be accessible without tenant claim")
437+
})
438+
439+
t.Run("platform path allowed for Connect/gRPC path", func(t *testing.T) {
440+
middleware := NewTenantAuthorizationMiddleware(logger)
441+
442+
var nextCalled bool
443+
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
444+
nextCalled = true
445+
})
446+
447+
handler := middleware.Handler(nextHandler)
448+
449+
claims := &platformauth.Claims{
450+
UserID: "oidc-user",
451+
}
452+
ctx := injectClaimsToContext(context.Background(), claims)
453+
454+
req := httptest.NewRequest(http.MethodPost, "/meridian.tenant.v1.TenantService/ListTenants", nil)
455+
req = req.WithContext(ctx)
456+
rr := httptest.NewRecorder()
457+
458+
handler.ServeHTTP(rr, req)
459+
460+
assert.Equal(t, http.StatusOK, rr.Code)
461+
assert.True(t, nextCalled)
462+
})
463+
413464
t.Run("403 when no resolved tenant in context", func(t *testing.T) {
414465
middleware := NewTenantAuthorizationMiddleware(logger)
415466

shared/platform/gateway/tenant_resolver.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@ var platformPaths = []string{
7676
"/meridian.tenant.v1.TenantService/", // Connect/gRPC path
7777
}
7878

79-
// isPlatformPath returns true if the request path is a platform-level endpoint
80-
// that should bypass tenant resolution.
81-
func isPlatformPath(path string) bool {
79+
// IsPlatformPath returns true if the request path is a platform-level endpoint
80+
// that should bypass tenant resolution and tenant authorization.
81+
// Platform paths (e.g., ListTenants) are bootstrap endpoints that only require
82+
// authentication — the endpoint itself handles access control based on the
83+
// caller's identity.
84+
func IsPlatformPath(path string) bool {
8285
for _, prefix := range platformPaths {
8386
if strings.HasPrefix(path, prefix) {
8487
return true
@@ -188,7 +191,7 @@ func (m *TenantResolverMiddleware) extractSlugFromRequest(w http.ResponseWriter,
188191
func (m *TenantResolverMiddleware) Handler(next http.Handler) http.Handler {
189192
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
190193
// Skip tenant resolution for platform-level endpoints
191-
if isPlatformPath(r.URL.Path) {
194+
if IsPlatformPath(r.URL.Path) {
192195
next.ServeHTTP(w, r)
193196
return
194197
}

shared/platform/gateway/tenant_resolver_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -930,19 +930,19 @@ func TestServeHTTP(t *testing.T) {
930930

931931
func TestIsPlatformPath(t *testing.T) {
932932
// REST transcoding paths
933-
assert.True(t, isPlatformPath("/v1/tenants"))
934-
assert.True(t, isPlatformPath("/v1/tenants/acme_corp"))
933+
assert.True(t, IsPlatformPath("/v1/tenants"))
934+
assert.True(t, IsPlatformPath("/v1/tenants/acme_corp"))
935935

936936
// Connect/gRPC paths
937-
assert.True(t, isPlatformPath("/meridian.tenant.v1.TenantService/ListTenants"))
938-
assert.True(t, isPlatformPath("/meridian.tenant.v1.TenantService/CreateTenant"))
939-
assert.True(t, isPlatformPath("/meridian.tenant.v1.TenantService/GetTenant"))
937+
assert.True(t, IsPlatformPath("/meridian.tenant.v1.TenantService/ListTenants"))
938+
assert.True(t, IsPlatformPath("/meridian.tenant.v1.TenantService/CreateTenant"))
939+
assert.True(t, IsPlatformPath("/meridian.tenant.v1.TenantService/GetTenant"))
940940

941941
// Non-platform paths
942-
assert.False(t, isPlatformPath("/v1/accounts"))
943-
assert.False(t, isPlatformPath("/v1/parties"))
944-
assert.False(t, isPlatformPath("/health"))
945-
assert.False(t, isPlatformPath("/meridian.party.v1.PartyService/ListParties"))
942+
assert.False(t, IsPlatformPath("/v1/accounts"))
943+
assert.False(t, IsPlatformPath("/v1/parties"))
944+
assert.False(t, IsPlatformPath("/health"))
945+
assert.False(t, IsPlatformPath("/meridian.party.v1.PartyService/ListParties"))
946946
}
947947

948948
func TestPlatformPathBypassesTenantResolution(t *testing.T) {

0 commit comments

Comments
 (0)