mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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
This commit is contained in:
@@ -15,7 +15,14 @@ import (
|
|||||||
// - Proxy auth admin role
|
// - Proxy auth admin role
|
||||||
// - Dev bypass
|
// - Dev bypass
|
||||||
//
|
//
|
||||||
// Session/OIDC are allowed only for the configured platform admin user.
|
// 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
|
// API tokens are denied to prevent tenant users from invoking hosted
|
||||||
// control-plane operations with bearer credentials.
|
// control-plane operations with bearer credentials.
|
||||||
func RequirePlatformAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
|
func RequirePlatformAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||||
@@ -41,12 +48,9 @@ func RequirePlatformAdmin(cfg *config.Config, handler http.HandlerFunc) http.Han
|
|||||||
handler(w, r)
|
handler(w, r)
|
||||||
return
|
return
|
||||||
case "session", "oidc":
|
case "session", "oidc":
|
||||||
if cfg != nil {
|
if cfg != nil && !sessionIsOrgScoped(r) && sessionUserCarriesAdminPrivileges(cfg, authUser) {
|
||||||
configuredAdmin := strings.TrimSpace(cfg.AuthUser)
|
handler(w, r)
|
||||||
if configuredAdmin != "" && authUser == configuredAdmin {
|
return
|
||||||
handler(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case "proxy":
|
case "proxy":
|
||||||
if cfg != nil && cfg.ProxyAuthSecret != "" {
|
if cfg != nil && cfg.ProxyAuthSecret != "" {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func platformAdminSession(t *testing.T, user string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
tok := "platform-admin-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
GetSessionStore().CreateSession(tok, time.Hour, "browser", "127.0.0.1", user)
|
||||||
|
return &http.Cookie{Name: sessionCookieName(false), Value: tok}
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformAdminConfig(t *testing.T, adminUser string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &config.Config{DataPath: dir, ConfigPath: dir}
|
||||||
|
if adminUser != "" {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte("platform-admin-password"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bcrypt: %v", err)
|
||||||
|
}
|
||||||
|
cfg.AuthUser = adminUser
|
||||||
|
cfg.AuthPass = string(hashed)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func billingAdminCapability(t *testing.T, router *Router, user string) bool {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
||||||
|
req.AddCookie(platformAdminSession(t, user))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.Handler().ServeHTTP(rec, req)
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("security status: %v", err)
|
||||||
|
}
|
||||||
|
caps, ok := payload["settingsCapabilities"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("settingsCapabilities missing from %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
return caps["billingAdmin"] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The billingAdmin capability is published from canAccessPlatformAdminSurface,
|
||||||
|
// which treats any instance administrator as a platform admin. The route behind
|
||||||
|
// it 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.
|
||||||
|
func TestPlatformAdminRouteAgreesWithBillingAdminCapability(t *testing.T) {
|
||||||
|
prev := auth.GetAuthorizer()
|
||||||
|
auth.SetAuthorizer(&auth.DefaultAuthorizer{})
|
||||||
|
defer auth.SetAuthorizer(prev)
|
||||||
|
|
||||||
|
cfg := platformAdminConfig(t, "")
|
||||||
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||||
|
ssoOwner := "sso:owner@example.com"
|
||||||
|
|
||||||
|
if !billingAdminCapability(t, router, ssoOwner) {
|
||||||
|
t.Fatal("precondition: billingAdmin must be advertised to the OIDC-only administrator")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/hosted/organizations", nil)
|
||||||
|
req.AddCookie(platformAdminSession(t, ssoOwner))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code == http.StatusForbidden {
|
||||||
|
t.Fatalf("platform admin route refused the caller its own capability advertised (body %s)", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An org-scoped tenant session is not an instance administrator, whatever its
|
||||||
|
// username. Without this the SSO fallback in sessionUserCarriesAdminPrivileges
|
||||||
|
// would make every tenant on a control plane with no local admin a platform
|
||||||
|
// admin, which is a far worse failure than the one being fixed.
|
||||||
|
func TestPlatformAdminRouteRefusesOrgScopedTenantSession(t *testing.T) {
|
||||||
|
prev := auth.GetAuthorizer()
|
||||||
|
auth.SetAuthorizer(&auth.DefaultAuthorizer{})
|
||||||
|
defer auth.SetAuthorizer(prev)
|
||||||
|
|
||||||
|
cfg := platformAdminConfig(t, "")
|
||||||
|
tenant := "sso:tenant@example.com"
|
||||||
|
|
||||||
|
handlerReached := false
|
||||||
|
guarded := RequirePlatformAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handlerReached = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/hosted/organizations", nil)
|
||||||
|
req.AddCookie(platformAdminSession(t, tenant))
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), OrgContextKey, &models.Organization{
|
||||||
|
ID: "tenant-org",
|
||||||
|
DisplayName: "Tenant Org",
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if handlerReached || rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("org-scoped tenant session reached the platform admin surface (code %d)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same principal without an org binding is the instance administrator.
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/api/hosted/organizations", nil)
|
||||||
|
req2.AddCookie(platformAdminSession(t, tenant))
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code == http.StatusForbidden {
|
||||||
|
t.Fatal("instance-scoped SSO administrator must still reach the platform admin surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unrelated SSO principal on an instance that does configure a local admin
|
||||||
|
// is not an administrator and must stay refused.
|
||||||
|
func TestPlatformAdminRouteRefusesUnrelatedSSOUser(t *testing.T) {
|
||||||
|
prev := auth.GetAuthorizer()
|
||||||
|
auth.SetAuthorizer(&auth.DefaultAuthorizer{})
|
||||||
|
defer auth.SetAuthorizer(prev)
|
||||||
|
|
||||||
|
cfg := platformAdminConfig(t, "admin")
|
||||||
|
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||||
|
outsider := "sso:outsider@example.com"
|
||||||
|
|
||||||
|
if billingAdminCapability(t, router, outsider) {
|
||||||
|
t.Fatal("precondition: billingAdmin must not be advertised to a non-admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/hosted/organizations", nil)
|
||||||
|
req.AddCookie(platformAdminSession(t, outsider))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("unrelated SSO user on the platform admin route = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,24 @@ func ensureAdminSession(cfg *config.Config, w http.ResponseWriter, req *http.Req
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionIsOrgScoped reports whether the request is bound to a tenant
|
||||||
|
// organization rather than the instance itself. Instance-admin rules must not
|
||||||
|
// be applied to an org-scoped session: those callers are governed by their
|
||||||
|
// organization's own management rules, and on a control plane configuring no
|
||||||
|
// local admin they would otherwise inherit the SSO fallback in
|
||||||
|
// sessionUserCarriesAdminPrivileges and each become an instance administrator.
|
||||||
|
func sessionIsOrgScoped(req *http.Request) bool {
|
||||||
|
if req == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
org := GetOrganization(req.Context())
|
||||||
|
if org == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
orgID := strings.TrimSpace(org.ID)
|
||||||
|
return orgID != "" && orgID != "default"
|
||||||
|
}
|
||||||
|
|
||||||
// sessionUserCarriesAdminPrivileges reports whether a non-org-scoped session
|
// sessionUserCarriesAdminPrivileges reports whether a non-org-scoped session
|
||||||
// username carries instance admin privileges: the configured local admin
|
// username carries instance admin privileges: the configured local admin
|
||||||
// identity, an RBAC assignment granting the admin action (how SSO group role
|
// identity, an RBAC assignment granting the admin action (how SSO group role
|
||||||
|
|||||||
@@ -157,12 +157,7 @@ func (r *Router) buildSecurityStatusAuthSnapshot(req *http.Request) securityStat
|
|||||||
// session on an instance with no local admin. Org-scoped sessions keep
|
// session on an instance with no local admin. Org-scoped sessions keep
|
||||||
// their own management rules.
|
// their own management rules.
|
||||||
sessionIsAdmin := false
|
sessionIsAdmin := false
|
||||||
orgScoped := false
|
if !sessionIsOrgScoped(req) {
|
||||||
if org := GetOrganization(req.Context()); org != nil {
|
|
||||||
orgID := strings.TrimSpace(org.ID)
|
|
||||||
orgScoped = orgID != "" && orgID != "default"
|
|
||||||
}
|
|
||||||
if !orgScoped {
|
|
||||||
sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, username)
|
sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, username)
|
||||||
}
|
}
|
||||||
return securityStatusAuthSnapshot{
|
return securityStatusAuthSnapshot{
|
||||||
|
|||||||
Reference in New Issue
Block a user