Require authority for infrastructure actions

This commit is contained in:
pulse-triage[bot]
2026-08-30 08:36:31 +01:00
parent f70da05467
commit 74cf2c1283
5 changed files with 96 additions and 11 deletions
+19 -1
View File
@@ -188,8 +188,26 @@ func authorizeActionCapability(ctx context.Context, authorizer auth.Authorizer,
return nil
}
func requireActionCapability(authorizer auth.Authorizer, capability string, handler http.HandlerFunc) http.HandlerFunc {
// requireActionCapability keeps the OSS default authorizer from turning an
// authenticated organization membership into infrastructure-control
// authority. A real RBAC authorizer remains authoritative for explicit action
// grants, while the default authorizer requires the canonical administrator
// boundary for browser and proxy sessions. Explicitly scoped API tokens keep
// using the scope and owner checks enforced by the surrounding route and the
// action lifecycle.
func requireActionCapability(cfg *config.Config, authorizer auth.Authorizer, capability string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, isDefaultAuthorizer := authorizer.(*auth.DefaultAuthorizer); isDefaultAuthorizer && getAPITokenRecordFromRequest(r) == nil {
if cfg != nil && strings.TrimSpace(cfg.ProxyAuthSecret) != "" {
if valid, _, isAdmin := CheckProxyAuth(cfg, r); valid && !isAdmin {
writeJSONError(w, http.StatusForbidden, "action_capability_denied", "Administrator privileges or an explicit action grant are required")
return
}
}
if !ensureAdminSession(cfg, w, r) {
return
}
}
if err := authorizeActionCapability(r.Context(), authorizer, capability); err != nil {
writeJSONError(w, http.StatusForbidden, "action_capability_denied", "You do not have permission to perform this action")
return
+67
View File
@@ -3,7 +3,10 @@ package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
@@ -45,6 +48,70 @@ func testActionAuthority() actionAuthority {
return actionAuthority{authorizer: allowActionAuthorityAuthorizer{}, orgChecker: NewAuthorizationChecker(nil)}
}
func TestDefaultActionAuthorityRejectsNonAdminBrowserSession(t *testing.T) {
InitSessionStore(t.TempDir())
const sessionToken = "viewer-action-session"
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "viewer")
req := httptest.NewRequest(http.MethodPost, "/api/actions/act-test/execute", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
req = req.WithContext(auth.WithUser(req.Context(), "viewer"))
rec := httptest.NewRecorder()
called := false
requireActionCapability(
&config.Config{AuthUser: "admin"},
&auth.DefaultAuthorizer{},
auth.ActionExecute,
func(http.ResponseWriter, *http.Request) { called = true },
)(rec, req)
if called {
t.Fatal("default authorizer admitted a non-admin browser session")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestDefaultActionAuthorityAllowsConfiguredAdminBrowserSession(t *testing.T) {
InitSessionStore(t.TempDir())
const sessionToken = "admin-action-session"
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "admin")
req := httptest.NewRequest(http.MethodPost, "/api/actions/act-test/execute", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
req = req.WithContext(auth.WithUser(req.Context(), "admin"))
rec := httptest.NewRecorder()
called := false
requireActionCapability(
&config.Config{AuthUser: "admin"},
&auth.DefaultAuthorizer{},
auth.ActionExecute,
func(http.ResponseWriter, *http.Request) { called = true },
)(rec, req)
if !called {
t.Fatalf("configured administrator was denied: status=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestExplicitActionAuthorizerCanGrantNonAdminBrowserSession(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/actions/act-test/execute", nil)
req = req.WithContext(auth.WithUser(req.Context(), "operator"))
rec := httptest.NewRecorder()
called := false
requireActionCapability(
&config.Config{AuthUser: "admin"},
allowActionAuthorityAuthorizer{},
auth.ActionExecute,
func(http.ResponseWriter, *http.Request) { called = true },
)(rec, req)
if !called {
t.Fatalf("explicit action grant was denied: status=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestActionAuthorityAllowsOwnerBoundTokenWithCanonicalApproveAndExecuteScopes(t *testing.T) {
authority := testActionAuthority()
record := ownerBoundActionToken("owned", "alice", config.ScopeActionsApprove, config.ScopeActionsExecute)
+1 -1
View File
@@ -371,7 +371,7 @@ func TestContract_BasicAuthenticatedAdminReachesActionAuthorizationAsVerifiedPri
cfg := &config.Config{AuthUser: "admin", AuthPass: hashedPassword}
reachedActionHandler := false
handler := RequireAuth(cfg, requireActionCapability(
handler := RequireAuth(cfg, requireActionCapability(cfg,
basicActionContractAuthorizer{wantUser: "admin"},
authpkg.ActionApprove,
func(w http.ResponseWriter, _ *http.Request) {
+8 -8
View File
@@ -138,30 +138,30 @@ func (r *Router) registerMonitoringResourceRoutes(
r.agentEventBroadcaster.HandleAgentEvents,
))))
}
r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireAnyScope([]string{config.ScopeActionsPlan, config.ScopeAIExecute}, requireActionCapability(r.authorizer, auth.ActionPlan, r.withExternalAgentCapabilityActivity(
r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireAnyScope([]string{config.ScopeActionsPlan, config.ScopeAIExecute}, requireActionCapability(r.config, r.authorizer, auth.ActionPlan, r.withExternalAgentCapabilityActivity(
agentcapabilities.PlanActionCapabilityName,
r.resourceHandlers.HandlePlanAction,
)))))
r.mux.HandleFunc("GET /api/actions/pending", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRoutePendingActions,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListPendingActions),
requireActionCapability(r.config, r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListPendingActions),
)))
r.mux.HandleFunc("GET /api/actions", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionsList,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListActions),
requireActionCapability(r.config, r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListActions),
)))
r.mux.HandleFunc("GET /api/actions/{id}", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDetail,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleGetAction),
requireActionCapability(r.config, r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleGetAction),
)))
r.mux.HandleFunc("POST /api/actions/{id}/refresh", RequireAuth(r.config, RequireAnyScope([]string{config.ScopeActionsPlan, config.ScopeAIExecute}, r.withExternalAgentCapabilityActivity(
agentcapabilities.PlanActionCapabilityName,
requireActionCapability(r.authorizer, auth.ActionPlan, r.resourceHandlers.HandleRefreshAction),
requireActionCapability(r.config, r.authorizer, auth.ActionPlan, r.resourceHandlers.HandleRefreshAction),
))))
r.mux.HandleFunc("POST /api/actions/{id}/decision", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDecision, r.withExternalAgentCapabilityActivity(
agentcapabilities.DecideActionCapabilityName,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleDecideAction),
requireActionCapability(r.config, r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleDecideAction),
))))
r.mux.HandleFunc("POST /api/actions/{id}/execute", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionExecute, r.withExternalAgentCapabilityActivity(
agentcapabilities.ExecuteActionCapabilityName,
requireActionCapability(r.authorizer, auth.ActionExecute, r.resourceHandlers.HandleExecuteAction),
requireActionCapability(r.config, r.authorizer, auth.ActionExecute, r.resourceHandlers.HandleExecuteAction),
))))
// Operator override for an action wedged in executing without agent
// completion evidence. It is deliberately not an agent capability and not
@@ -169,7 +169,7 @@ func (r *Router) registerMonitoringResourceRoutes(
// execute (admin plus settings:write on top of the execute capability)
// because it writes terminal audit truth Pulse could not observe.
r.mux.HandleFunc("POST /api/actions/{id}/force-fail", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite,
requireActionCapability(r.authorizer, auth.ActionExecute, r.resourceHandlers.HandleForceFailAction),
requireActionCapability(r.config, r.authorizer, auth.ActionExecute, r.resourceHandlers.HandleForceFailAction),
)))
// Guest metadata routes
r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata)))
@@ -1052,7 +1052,7 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) {
},
filepath.Join("..", "api", "router_routes_monitoring.go"): {
`"POST /api/actions/{id}/execute"`,
"requireActionCapability(r.authorizer, auth.ActionExecute",
"requireActionCapability(r.config, r.authorizer, auth.ActionExecute",
"r.resourceHandlers.HandleExecuteAction",
},
}