mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Require authority for infrastructure actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user