Files
CyberStrikeAI/internal/security/rbac_middleware_test.go
T
2026-07-10 19:47:53 +08:00

264 lines
8.6 KiB
Go

package security
import (
"net/http"
"net/http/httptest"
"testing"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
)
func TestRBACMiddlewareUsesMatchedFullPath(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "operator",
Permissions: map[string]bool{"project:read": true},
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/projects/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/projects/p1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestRBACMiddlewareRejectsMissingPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "viewer",
Permissions: map[string]bool{"project:read": true},
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.POST("/api/projects", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/projects", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRBACMiddlewareRejectsUnmappedProtectedRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "admin",
Permissions: allPermissions(),
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/new-module", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/new-module", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRBACMiddlewareMapsOpenAPISpec(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "viewer",
Permissions: map[string]bool{"openapi:read": true},
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/openapi/spec", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/openapi/spec", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestRBACResourcePickerRequiresWritePermission(t *testing.T) {
if got := permissionForRequest(http.MethodGet, "/api/rbac/resources"); got != "rbac:write" {
t.Fatalf("picker permission = %q, want rbac:write", got)
}
if got := permissionForRequest(http.MethodGet, "/api/rbac/resource-assignments"); got != "rbac:read" {
t.Fatalf("assignment list permission = %q, want rbac:read", got)
}
}
func TestMCPInvocationPermissionIsSeparateFromMCPAdministration(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" {
t.Fatalf("MCP invocation permission = %q, want mcp:execute", got)
}
if got := permissionForRequest(http.MethodPut, "/api/external-mcp/example"); got != "mcp:write" {
t.Fatalf("external MCP admin permission = %q, want mcp:write", got)
}
}
func TestConfigToolsReadAllowsMCPReadWithoutConfigRead(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "viewer",
Username: "viewer",
Permissions: map[string]bool{"mcp:read": true},
Scope: database.RBACScopeAssigned,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/config/tools", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"tools": []any{}})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/config/tools", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestWorkflowRunPermissionIsSeparateFromDefinitionManagement(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
t.Fatalf("resume permission = %q, want workflow:execute", got)
}
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
t.Fatalf("definition permission = %q, want workflow:write", got)
}
}
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
gin.SetMode(gin.TestMode)
called := false
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{UserID: "viewer", Permissions: map[string]bool{"project:read": true}, Scope: database.RBACScopeAssigned})
c.Next()
})
router.Use(RBACMiddlewareWithDenyHook(nil, func(_ *gin.Context, reason, permission string) {
called = reason == "permission_denied" && permission == "project:write"
}))
router.POST("/api/projects", func(c *gin.Context) { c.Status(http.StatusNoContent) })
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/projects", nil))
if w.Code != http.StatusForbidden || !called {
t.Fatalf("denial = status %d, hook called %v", w.Code, called)
}
}
func TestRBACMiddlewareBindsPermissionSpecificScope(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "mixed", Scope: database.RBACScopeAll,
Permissions: map[string]bool{"project:read": true, "project:write": true},
PermissionScopes: map[string]string{"project:read": database.RBACScopeAll, "project:write": database.RBACScopeOwn},
})
c.Next()
})
router.Use(RBACMiddleware(nil))
handler := func(c *gin.Context) {
session, _ := CurrentSession(c)
c.String(http.StatusOK, session.Scope)
}
router.GET("/api/projects/:id", handler)
router.PUT("/api/projects/:id", handler)
for _, tc := range []struct{ method, want string }{
{http.MethodGet, database.RBACScopeAll},
{http.MethodPut, database.RBACScopeOwn},
} {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(tc.method, "/api/projects/p1", nil))
if w.Code != http.StatusOK || w.Body.String() != tc.want {
t.Fatalf("%s scope response = %d/%q, want 200/%q", tc.method, w.Code, w.Body.String(), tc.want)
}
}
}
func TestRBACMiddlewareRejectsAssignedScopeForGlobalMonitorAggregates(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
method string
path string
permission string
}{
{method: http.MethodGet, path: "/api/monitor/stats", permission: "monitor:read"},
} {
t.Run(tc.path, func(t *testing.T) {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "assigned-user", Permissions: map[string]bool{tc.permission: true}, Scope: database.RBACScopeAssigned,
})
c.Next()
})
router.Use(RBACMiddleware(&database.DB{}))
router.Handle(tc.method, tc.path, func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
}
})
}
}
func TestAssignedScopeCannotMutateProcessGlobalAssets(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{"/api/roles/demo", "/api/skills/demo", "/api/external-mcp/demo", "/api/workflows/demo", "/api/knowledge/items/demo"} {
t.Run(path, func(t *testing.T) {
permission := permissionForRequest(http.MethodPut, path)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{UserID: "operator", Scope: database.RBACScopeAssigned, Permissions: map[string]bool{permission: true}, PermissionScopes: map[string]string{permission: database.RBACScopeAssigned}})
c.Next()
})
router.Use(RBACMiddleware(&database.DB{}))
router.PUT(path, func(c *gin.Context) { c.Status(http.StatusNoContent) })
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodPut, path, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("global mutation status = %d, want 403", w.Code)
}
})
}
}