Files
pulse/internal/api/cloud_org_admin_auth.go
T
rcourtman 563a3aa06c fix(api): align the platform admin route with the capability it publishes
canAccessPlatformAdminSurface publishes billingAdmin for any instance
administrator. RequirePlatformAdmin compared the session user against
cfg.AuthUser alone, so on an instance whose only administrators are SSO
principals the UI offered the surface and the route refused it. Same
capability against enforcement split as 28fd2d1c1, on the hosted routes.

The session branch now uses sessionUserCarriesAdminPrivileges, which is what
the capability already resolves to.

A straight swap would have been worse than the bug. That helper treats any SSO
principal as an administrator when no local admin is configured, and a hosted
control plane authenticates its tenants by SSO, so on a control plane with no
local admin every tenant would have become a platform admin. The session
branch is therefore also gated on the request not being org-scoped, matching
what ensureAdminSession and the security status snapshot already do. Removing
that gate lets an org-scoped tenant session reach the surface with a 200,
which the parity test pins.

The org-scope test itself was written inline in two places and is now one
helper, sessionIsOrgScoped, so the instance-versus-tenant boundary has a
single definition rather than a copy per caller.

RequireOrgOwnerOrPlatformAdmin is untouched. It has no session branch in its
platform-admin switch by design and requires org ownership instead.

Contract-Neutral: behavioral fix on existing routes, no request or response shape change; platform admin route aligned with the billingAdmin capability it already publishes
2026-08-05 13:32:42 +01:00

177 lines
5.8 KiB
Go

package api
import (
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
// RequirePlatformAdmin restricts access to control-plane hosted routes.
// Allowed callers:
// - Basic auth admin
// - Proxy auth admin role
// - Dev bypass
//
// Session/OIDC are allowed only for an instance administrator, which is the
// configured platform admin user, a holder of an RBAC admin grant, or an SSO
// principal on an instance that configures no local admin at all. That is the
// same rule canAccessPlatformAdminSurface uses to publish the billingAdmin
// capability, so the surface the UI offers and the routes behind it agree.
// An org-scoped tenant session is never a platform admin whatever its username,
// which is what keeps a tenant on a control plane with no local admin from
// inheriting the OIDC-only fallback.
// API tokens are denied to prevent tenant users from invoking hosted
// control-plane operations with bearer credentials.
func RequirePlatformAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if adminBypassEnabled() {
handler(w, r)
return
}
authWriter := &responseCapture{ResponseWriter: w}
if !checkAuth(cfg, authWriter, r, false) {
if !authWriter.wrote {
writeAuthenticationRequired(w, r)
}
return
}
authMethod := strings.TrimSpace(w.Header().Get("X-Auth-Method"))
authUser := strings.TrimSpace(w.Header().Get("X-Authenticated-User"))
switch authMethod {
case "basic", "bypass":
handler(w, r)
return
case "session", "oidc":
if cfg != nil && !sessionIsOrgScoped(r) && sessionUserCarriesAdminPrivileges(cfg, authUser) {
handler(w, r)
return
}
case "proxy":
if cfg != nil && cfg.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid && isAdmin {
log.Debug().Str("user", username).Msg("Allowing platform admin via proxy auth")
handler(w, r)
return
}
}
}
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Platform admin required", nil)
}
}
// RequireOrgOwnerOrPlatformAdmin restricts access to routes scoped by a path org ID (`{id}`).
//
// Allowed callers:
// - Platform admin:
// - Basic auth (configured AuthUser/AuthPass)
// - Proxy auth with the configured admin role
// - Dev bypass (ALLOW_ADMIN_BYPASS in dev)
//
// - Org owner:
// - Session/OIDC/proxy user matching org.OwnerUserID
//
// - Org-bound API token:
// - Token that is authorized for the target org via token.CanAccessOrg(orgID)
//
// This is intentionally stricter than TenantMiddleware membership checks; "owner" is required.
func RequireOrgOwnerOrPlatformAdmin(cfg *config.Config, orgs OrgPersistenceProvider, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Dev bypass (disabled by default).
if adminBypassEnabled() {
handler(w, r)
return
}
// Authenticate first. We intentionally do not reuse RequireAdmin because
// RequireAdmin treats *all* authenticated non-proxy users as "admin".
authWriter := &responseCapture{ResponseWriter: w}
if !checkAuth(cfg, authWriter, r, false) {
// Match RequireAdmin's behavior for API routes.
if !authWriter.wrote {
writeAuthenticationRequired(w, r)
}
return
}
orgID := strings.TrimSpace(r.PathValue("id"))
if !isValidOrganizationID(orgID) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_org_id", "Invalid organization ID", nil)
return
}
// Promote auth info into request context for downstream checks.
r = extractAndStoreAuthContext(cfg, nil, r)
// Platform admin checks.
authMethod := strings.TrimSpace(w.Header().Get("X-Auth-Method"))
switch authMethod {
case "basic":
handler(w, r)
return
case "proxy":
if cfg != nil && cfg.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid && isAdmin {
log.Debug().Str("user", username).Str("org_id", orgID).Msg("Allowing platform admin via proxy auth")
handler(w, r)
return
}
}
case "bypass":
handler(w, r)
return
}
// Org-bound API token checks.
if authMethod == "api_token" {
if token := internalauth.GetAPIToken(r.Context()); token != nil {
if record, ok := token.(*config.APITokenRecord); ok && record != nil && record.CanAccessOrg(orgID) {
handler(w, r)
return
}
}
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Token is not authorized for this organization", nil)
return
}
// Owner check for session/OIDC/proxy users.
userID := internalauth.GetUser(r.Context())
if strings.TrimSpace(userID) == "" {
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Organization owner or platform admin required", nil)
return
}
if orgs == nil {
// Fail closed: don't allow cross-tenant admin actions if we can't verify ownership.
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
return
}
// Avoid leaking existence: invalid org IDs are already handled above; if the org doesn't exist
// or can't be loaded, treat it as not found (consistent with handler-side behavior elsewhere).
if orgID != "default" && !orgs.OrgExists(orgID) {
writeErrorResponse(w, http.StatusNotFound, "org_not_found", "Organization not found", nil)
return
}
org, err := orgs.LoadOrganization(orgID)
if err != nil || org == nil {
writeErrorResponse(w, http.StatusNotFound, "org_not_found", "Organization not found", nil)
return
}
if !org.IsOwnerUserID(userID) {
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Organization owner or platform admin required", nil)
return
}
handler(w, r)
}
}