diff --git a/internal/api/action_authority.go b/internal/api/action_authority.go index 535cc56d7..c24060ef4 100644 --- a/internal/api/action_authority.go +++ b/internal/api/action_authority.go @@ -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 diff --git a/internal/api/action_authority_test.go b/internal/api/action_authority_test.go index 07ee47cf2..c9be47592 100644 --- a/internal/api/action_authority_test.go +++ b/internal/api/action_authority_test.go @@ -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) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 96b334ebd..0d59789fd 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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) { diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index d59276484..730ec86fe 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -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))) diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index dbd3915ab..b0ec539b8 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -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", }, }