Skip to content

Commit 60c8cc5

Browse files
authored
fix: allow platform admins to access endpoints without tenant context (#1263)
* feat: enable Dex OIDC authentication for demo environment Replace fake JWT workarounds with real Dex OIDC authentication. The backend now gracefully handles standard OIDC tokens by falling back to the sub claim for user ID and applying configurable defaults for tenant ID and roles when custom Meridian claims are absent. Backend: - Add Email/Name OIDC fields and EffectiveUserID() to Claims - Add DEFAULT_TENANT_ID and DEFAULT_ROLES env vars to gateway config - JWT middleware injects configured defaults for missing claims - Wire defaults through CombinedAuthMiddleware to JWTMiddleware Frontend: - parseJWT accepts standard OIDC tokens (sub fallback, array aud) - Login page with email/password form using Dex password grant - Dev-only fake JWT buttons preserved for local development - Demo mode defaults to platform lens for DevTenantAutoSelector Dex config: - Real bcrypt hashes for admin@volterra.energy and operator@volterra.energy - Password: demo2026 * fix: store effective claims object in context for platform-admin bypass TenantAuthorizationMiddleware checks claims.HasRole() on the Claims object stored in context. The previous approach stored default roles only as context values, making them invisible to the authorization check. Fix by creating a shallow copy of claims with effective values (UserID, TenantID, Roles) applied, then storing the copy in context. This ensures platform-admin default role is visible when DEFAULT_ROLES=platform-admin is configured with empty DEFAULT_TENANT_ID, enabling cross-tenant access for demo users. * fix: pass DEFAULT_TENANT_ID and DEFAULT_ROLES to meridian container These env vars were defined in .env but not listed in the docker-compose.yml environment section, so they were never passed to the container. Also update .env.demo.example to document the new vars and enable AUTH_ENABLED=true by default. * fix: trim whitespace from DEFAULT_TENANT_ID env var * fix: allow platform admins to access endpoints without tenant context Move the platform-admin bypass in TenantAuthorizationMiddleware before the resolved tenant check. Previously, platform-level endpoints like ListTenants would be rejected with "tenant context not resolved" because TenantResolverMiddleware correctly skips them (no tenant needed), but TenantAuthorizationMiddleware checked for a resolved tenant before checking for the platform-admin role. Now platform admins with an empty JWT tenant claim bypass authorization early, enabling access to both platform-level endpoints (no tenant context) and tenant-scoped endpoints (cross-tenant access via subdomain/header). * fix: restore LOCAL_DEV_MODE in demo env example Demo uses a single domain without subdomains, so X-Tenant-Slug header resolution via LOCAL_DEV_MODE=true is required. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 9ae47ef commit 60c8cc5

2 files changed

Lines changed: 19 additions & 14 deletions

File tree

deploy/demo/.env.demo.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ DEFAULT_ROLES=platform-admin
102102
# [OPTIONAL] Gateway base domain for subdomain-based tenant resolution.
103103
BASE_DOMAIN=demo.meridianhub.cloud
104104

105+
# [OPTIONAL] Enable X-Tenant-Slug header for tenant resolution.
106+
# Required for demo since it uses a single domain (no subdomains).
107+
LOCAL_DEV_MODE=true
108+
105109
# ---------------------------------------------------------------------------
106110
# Billing
107111
# ---------------------------------------------------------------------------

services/gateway/auth/combined_middleware.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -245,25 +245,15 @@ func (m *TenantAuthorizationMiddleware) Handler(next http.Handler) http.Handler
245245
return
246246
}
247247

248-
// Get resolved tenant from context
249-
resolvedTenant, hasTenant := tenant.FromContext(ctx)
250-
if !hasTenant {
251-
// No resolved tenant - this shouldn't happen if tenant middleware ran
252-
m.logger.Warn("no resolved tenant in context",
253-
slog.String("path", r.URL.Path),
254-
)
255-
writeForbidden(w, "tenant context not resolved")
256-
return
257-
}
258-
259248
// Platform-admin/super-admin bypass: when JWT has no tenant claim but has
260-
// elevated platform role, allow cross-tenant access via subdomain resolution.
249+
// elevated platform role, allow access without tenant context (platform-level
250+
// endpoints like ListTenants) or cross-tenant access (tenant-scoped endpoints
251+
// resolved via subdomain).
261252
if jwtTenantID == "" {
262253
claims, hasClaims := GetClaimsFromContext(ctx)
263254
if hasClaims && hasPlatformAdminRole(claims) {
264-
m.logger.Info("platform admin cross-tenant access",
255+
m.logger.Debug("platform admin access",
265256
slog.String("user_id", claims.UserID),
266-
slog.String("resolved_tenant", resolvedTenant.String()),
267257
slog.String("path", r.URL.Path),
268258
)
269259
next.ServeHTTP(w, r)
@@ -274,6 +264,17 @@ func (m *TenantAuthorizationMiddleware) Handler(next http.Handler) http.Handler
274264
return
275265
}
276266

267+
// Get resolved tenant from context
268+
resolvedTenant, hasTenant := tenant.FromContext(ctx)
269+
if !hasTenant {
270+
// No resolved tenant - this shouldn't happen if tenant middleware ran
271+
m.logger.Warn("no resolved tenant in context",
272+
slog.String("path", r.URL.Path),
273+
)
274+
writeForbidden(w, "tenant context not resolved")
275+
return
276+
}
277+
277278
// Compare JWT tenant with resolved tenant
278279
if !tenantsMatch(jwtTenantID, resolvedTenant) {
279280
m.logger.Warn("JWT tenant does not match resolved tenant",

0 commit comments

Comments
 (0)