diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index bf481c25..8e021f01 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -779,7 +779,7 @@ func (h *HandlersApi) NodePostureHandler(w http.ResponseWriter, r *http.Request) apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissions(user, users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt by %s", user)) return } @@ -845,7 +845,7 @@ func (h *HandlersApi) NodePostureScoreHandler(w http.ResponseWriter, r *http.Req apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissions(user, users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt by %s", user)) return } diff --git a/cmd/api/handlers/nodes_posture_test.go b/cmd/api/handlers/nodes_posture_test.go index 8118ef57..8b6ef4d4 100644 --- a/cmd/api/handlers/nodes_posture_test.go +++ b/cmd/api/handlers/nodes_posture_test.go @@ -1,12 +1,16 @@ package handlers import ( + "context" + "net/http" + "net/http/httptest" "testing" "time" "github.com/jmpsec/osctrl/pkg/nodes" "github.com/jmpsec/osctrl/pkg/posture" "github.com/jmpsec/osctrl/pkg/tags" + "github.com/jmpsec/osctrl/pkg/users" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -157,3 +161,47 @@ func TestProjectNodeHealthUsesPostureRisk(t *testing.T) { t.Fatalf("expected at_risk health, got %+v", view.Health) } } + +func TestNodePostureHandlersRequireAdminLevel(t *testing.T) { + _, h, env, node := setupConsoleHandlers(t) + if err := h.Users.CreatePermission(users.UserPermission{ + Username: "bob", + AccessType: int(users.UserLevel), + AccessValue: true, + Environment: env.UUID, + EnvironmentID: env.ID, + }); err != nil { + t.Fatalf("create user-level permission: %v", err) + } + + for _, tc := range []struct { + name string + handler http.HandlerFunc + path string + }{ + { + name: "posture", + handler: h.NodePostureHandler, + path: "/api/v1/nodes/env/node/NODE-UUID/posture", + }, + { + name: "posture score", + handler: h.NodePostureScoreHandler, + path: "/api/v1/nodes/env/node/NODE-UUID/posture/score", + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.SetPathValue("env", env.Name) + req.SetPathValue("uuid", node.UUID) + req = req.WithContext(context.WithValue(req.Context(), ContextKey(contextAPI), ContextValue{ctxUser: "bob"})) + rr := httptest.NewRecorder() + + tc.handler(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d; body=%s", rr.Code, http.StatusForbidden, rr.Body.String()) + } + }) + } +} diff --git a/cmd/api/mcp.go b/cmd/api/mcp.go index f277d928..9c330195 100644 --- a/cmd/api/mcp.go +++ b/cmd/api/mcp.go @@ -45,8 +45,8 @@ const mcpInternalHost = "http://osctrl-api.internal" // client would hit, so authentication, the per-endpoint permission checks, // and audit logging all run exactly as they do for any other caller. The MCP // layer therefore contains no authorization logic of its own — which matters -// because osctrl's read permissions are not uniform (reading a node needs -// AdminLevel, its posture only UserLevel, queries QueryLevel), and restating +// because osctrl's read permissions are not uniform (node detail and posture +// need AdminLevel, queries need QueryLevel), and restating // that policy anywhere else would over-grant the moment the two drift. type loopbackTransport struct { handler http.Handler