Files
pulse/internal/api/security_status_capabilities.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

282 lines
9.2 KiB
Go

package api
import (
"context"
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
type securityStatusSettingsCapabilities struct {
APIAccessRead bool `json:"apiAccessRead"`
APIAccessWrite bool `json:"apiAccessWrite"`
AuthenticationRead bool `json:"authenticationRead"`
AuthenticationWrite bool `json:"authenticationWrite"`
SingleSignOnRead bool `json:"singleSignOnRead"`
SingleSignOnWrite bool `json:"singleSignOnWrite"`
Roles bool `json:"roles"`
Users bool `json:"users"`
AuditLog bool `json:"auditLog"`
AuditWebhooksRead bool `json:"auditWebhooksRead"`
AuditWebhooksWrite bool `json:"auditWebhooksWrite"`
RelayRead bool `json:"relayRead"`
RelayWrite bool `json:"relayWrite"`
BillingAdmin bool `json:"billingAdmin"`
}
type securityStatusSessionCapabilities struct {
DemoMode bool `json:"demoMode"`
AssistantEnabled bool `json:"assistantEnabled"`
}
type securityStatusPresentationPolicy struct {
DemoMode bool `json:"demoMode"`
ReadOnly bool `json:"readOnly"`
HideCommercial bool `json:"hideCommercial"`
HideUpgrade bool `json:"hideUpgrade"`
}
type securityStatusAuthSnapshot struct {
request *http.Request
authenticated bool
authMethod string
username string
proxyIsAdmin bool
sessionIsAdmin bool
tokenRecord *config.APITokenRecord
}
func (s securityStatusAuthSnapshot) tokenScopes() []string {
if s.tokenRecord == nil {
return nil
}
return append([]string{}, s.tokenRecord.Scopes...)
}
func (s securityStatusAuthSnapshot) hasScopes(scopes ...string) bool {
if s.tokenRecord == nil {
return true
}
for _, scope := range scopes {
if scope == "" {
continue
}
if !s.tokenRecord.HasScope(scope) {
return false
}
}
return true
}
func (s securityStatusAuthSnapshot) passesPrivilegedSessionGate() bool {
if !s.authenticated {
return false
}
if s.authMethod == "session" {
return s.sessionIsAdmin
}
return true
}
func (s securityStatusAuthSnapshot) canAccessAdminSurface(scopes ...string) bool {
if !s.authenticated {
return false
}
switch s.authMethod {
case "proxy":
if !s.proxyIsAdmin {
return false
}
case "session":
if !s.sessionIsAdmin {
return false
}
}
return s.hasScopes(scopes...)
}
func (r *Router) buildSecurityStatusAuthSnapshot(req *http.Request) securityStatusAuthSnapshot {
if r == nil || req == nil || r.config == nil {
return securityStatusAuthSnapshot{}
}
if adminBypassEnabled() {
snapshotReq := attachAdminBypassContext(attachUserContext(req, "admin"))
return securityStatusAuthSnapshot{
request: snapshotReq,
authenticated: true,
authMethod: "bypass",
username: "admin",
sessionIsAdmin: true,
}
}
if r.config.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid {
snapshotReq := req
if username != "" {
snapshotReq = attachUserContext(req, username)
}
return securityStatusAuthSnapshot{
request: snapshotReq,
authenticated: true,
authMethod: "proxy",
username: username,
proxyIsAdmin: isAdmin,
sessionIsAdmin: false,
}
}
}
if token := strings.TrimSpace(req.Header.Get("X-API-Token")); token != "" {
if record, ok := r.config.ValidateAPIToken(token); ok {
snapshotReq := req
attachAPITokenRecord(snapshotReq, record)
tokenUsername := apiTokenAuthenticatedUser(record)
snapshotReq = attachUserContext(snapshotReq, tokenUsername)
recordClone := record.Clone()
return securityStatusAuthSnapshot{
request: snapshotReq,
authenticated: true,
authMethod: "api_token",
username: tokenUsername,
tokenRecord: &recordClone,
}
}
}
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
username := strings.TrimSpace(GetSessionUsername(cookie.Value))
snapshotReq := attachUserContext(req, username)
// Same privilege rule as ensureAdminSession: the configured admin
// identity, an RBAC admin grant (SSO group role mappings), or an SSO
// session on an instance with no local admin. Org-scoped sessions keep
// their own management rules.
sessionIsAdmin := false
if !sessionIsOrgScoped(req) {
sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, username)
}
return securityStatusAuthSnapshot{
request: snapshotReq,
authenticated: true,
authMethod: "session",
username: username,
sessionIsAdmin: sessionIsAdmin,
}
}
return securityStatusAuthSnapshot{}
}
func (r *Router) canAccessPermissionSurface(snapshot securityStatusAuthSnapshot, action, resource string, scopes ...string) bool {
if !snapshot.authenticated || snapshot.request == nil {
return false
}
// Without a real RBAC authorizer, Authorize allows every action, so it
// cannot be the sole input to a capability. The routes these capabilities
// describe are still gated, by ensureSettingsScope and in turn
// ensureAdminSession, so reporting the capability from the authorizer alone
// advertises a surface the caller will be refused: the tab renders and its
// first request comes back 403. Fall back to the same admin identity the
// routes enforce, which snapshot.sessionIsAdmin already derives from
// sessionUserCarriesAdminPrivileges. Only the proxy half of this rule was
// ever written, so session and SSO callers were told they could reach API
// token management and SSO provider configuration when they could not.
if _, isDefaultAuthorizer := r.authorizer.(*internalauth.DefaultAuthorizer); isDefaultAuthorizer {
switch snapshot.authMethod {
case "proxy":
if !snapshot.proxyIsAdmin {
return false
}
case "session":
if !snapshot.sessionIsAdmin {
return false
}
}
}
allowed, err := r.authorizer.Authorize(snapshot.request.Context(), action, resource)
if err != nil || !allowed {
return false
}
return snapshot.hasScopes(scopes...)
}
func (r *Router) canAccessPlatformAdminSurface(snapshot securityStatusAuthSnapshot) bool {
if !snapshot.authenticated {
return false
}
switch snapshot.authMethod {
case "bypass":
return true
case "session":
return snapshot.sessionIsAdmin
case "proxy":
return snapshot.proxyIsAdmin
case "api_token":
return false
default:
return false
}
}
func (r *Router) securityStatusSettingsCapabilitiesFromSnapshot(snapshot securityStatusAuthSnapshot) securityStatusSettingsCapabilities {
if !snapshot.authenticated {
return securityStatusSettingsCapabilities{}
}
canAdminSettings := snapshot.canAccessAdminSurface(config.ScopeSettingsRead, config.ScopeSettingsWrite)
canReadSettings := snapshot.canAccessAdminSurface(config.ScopeSettingsRead)
canManageUsers := r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers)
canReadAudit := snapshot.passesPrivilegedSessionGate() &&
r.canAccessPermissionSurface(snapshot, internalauth.ActionRead, internalauth.ResourceAuditLogs, config.ScopeAuditRead)
canManageRoles := snapshot.passesPrivilegedSessionGate() && canManageUsers
return securityStatusSettingsCapabilities{
APIAccessRead: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsRead),
APIAccessWrite: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsWrite),
AuthenticationRead: canReadSettings,
AuthenticationWrite: canAdminSettings,
SingleSignOnRead: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsRead),
SingleSignOnWrite: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsWrite),
Roles: canManageRoles,
Users: canManageRoles,
AuditLog: canReadAudit,
AuditWebhooksRead: snapshot.passesPrivilegedSessionGate() && r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceAuditLogs, config.ScopeSettingsRead),
AuditWebhooksWrite: snapshot.passesPrivilegedSessionGate() && r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceAuditLogs, config.ScopeSettingsWrite),
RelayRead: canReadSettings,
RelayWrite: canAdminSettings,
BillingAdmin: r.canAccessPlatformAdminSurface(snapshot),
}
}
func (r *Router) securityStatusSessionCapabilities(ctx context.Context) securityStatusSessionCapabilities {
demoMode := r != nil && r.config != nil && r.config.DemoMode
assistantEnabled := false
if r != nil && r.aiSettingsHandler != nil {
assistantEnabled = r.aiSettingsHandler.AssistantEnabled(ctx)
}
return securityStatusSessionCapabilities{
DemoMode: demoMode,
AssistantEnabled: assistantEnabled,
}
}
func (r *Router) securityStatusPresentationPolicy() securityStatusPresentationPolicy {
demoMode := r != nil && r.config != nil && r.config.DemoMode
hideUpgrade := demoMode || r == nil || !r.hostedMode
return securityStatusPresentationPolicy{
DemoMode: demoMode,
ReadOnly: demoMode,
HideCommercial: demoMode,
HideUpgrade: hideUpgrade,
}
}