Pulse/internal/api/cloud_org_admin_auth.go
2026-03-18 16:06:30 +00:00

178 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 the configured platform admin user.
// 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
}
if !CheckAuth(cfg, w, r) {
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"Authentication required"}`))
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
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 {
configuredAdmin := strings.TrimSpace(cfg.AuthUser)
if configuredAdmin != "" && authUser == configuredAdmin {
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".
if !CheckAuth(cfg, w, r) {
// Match RequireAdmin's behavior for API routes.
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"Authentication required"}`))
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
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 !strings.EqualFold(strings.TrimSpace(org.OwnerUserID), strings.TrimSpace(userID)) {
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Organization owner or platform admin required", nil)
return
}
handler(w, r)
}
}