diff --git a/internal/openai/claude_bridge.go b/internal/openai/claude_bridge.go index 7ab8bf38..6ab3707a 100644 --- a/internal/openai/claude_bridge.go +++ b/internal/openai/claude_bridge.go @@ -35,11 +35,11 @@ import ( // claudeRequest 表示 Anthropic Messages API 的请求体。 type claudeRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - System string `json:"system,omitempty"` - Messages []claudeMessage `json:"messages"` - Tools []claudeTool `json:"tools,omitempty"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []claudeMessage `json:"messages"` + Tools []claudeTool `json:"tools,omitempty"` Stream bool `json:"stream,omitempty"` Thinking json.RawMessage `json:"thinking,omitempty"` OutputConfig json.RawMessage `json:"output_config,omitempty"` @@ -316,10 +316,73 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) { req.OutputConfig = json.RawMessage(raw) } } + if err := validateClaudeToolPairs(req.Messages); err != nil { + return nil, err + } return req, nil } +// validateClaudeToolPairs prevents malformed OpenAI history from reaching +// Anthropic/Bedrock. Every tool_use batch must be answered by the immediately +// following user message, with exactly one tool_result for every ID. +func validateClaudeToolPairs(messages []claudeMessage) error { + validatedResultMessages := make(map[int]struct{}) + for i, msg := range messages { + expected := make(map[string]struct{}) + for _, block := range msg.Content.Blocks { + if block.Type != "tool_use" { + continue + } + id := strings.TrimSpace(block.ID) + if id == "" { + return fmt.Errorf("claude bridge: assistant message %d has tool_use without id", i) + } + if _, duplicate := expected[id]; duplicate { + return fmt.Errorf("claude bridge: assistant message %d has duplicate tool_use id %q", i, id) + } + expected[id] = struct{}{} + } + if len(expected) == 0 { + continue + } + if i+1 >= len(messages) || messages[i+1].Role != "user" { + return fmt.Errorf("claude bridge: assistant message %d tool_use is not immediately followed by user tool_result", i) + } + seen := make(map[string]struct{}, len(expected)) + for _, block := range messages[i+1].Content.Blocks { + if block.Type != "tool_result" { + continue + } + id := strings.TrimSpace(block.ToolUseID) + if _, ok := expected[id]; !ok { + return fmt.Errorf("claude bridge: user message %d has unexpected tool_result id %q", i+1, id) + } + if _, duplicate := seen[id]; duplicate { + return fmt.Errorf("claude bridge: user message %d has duplicate tool_result id %q", i+1, id) + } + seen[id] = struct{}{} + } + for id := range expected { + if _, ok := seen[id]; !ok { + return fmt.Errorf("claude bridge: user message %d is missing tool_result for id %q", i+1, id) + } + } + validatedResultMessages[i+1] = struct{}{} + } + for i, msg := range messages { + if _, ok := validatedResultMessages[i]; ok { + continue + } + for _, block := range msg.Content.Blocks { + if block.Type == "tool_result" { + return fmt.Errorf("claude bridge: user message %d has orphan tool_result id %q", i, block.ToolUseID) + } + } + } + return nil +} + // ============================================================ // Conversion: Claude Response → OpenAI Response (non-streaming) // ============================================================ diff --git a/internal/openai/claude_reasoning_roundtrip_test.go b/internal/openai/claude_reasoning_roundtrip_test.go index 2f67389f..4ae176c5 100644 --- a/internal/openai/claude_reasoning_roundtrip_test.go +++ b/internal/openai/claude_reasoning_roundtrip_test.go @@ -106,6 +106,65 @@ func TestConvertOpenAIToClaude_OutputConfigEffort(t *testing.T) { } } +func TestConvertOpenAIToClaudeAcceptsCompleteMultiToolBatch(t *testing.T) { + payload := map[string]interface{}{ + "model": "claude-test", + "messages": []interface{}{ + map[string]interface{}{ + "role": "assistant", + "tool_calls": []interface{}{ + map[string]interface{}{"id": "c1", "function": map[string]interface{}{"name": "one", "arguments": "{}"}}, + map[string]interface{}{"id": "c2", "function": map[string]interface{}{"name": "two", "arguments": "{}"}}, + }, + }, + map[string]interface{}{"role": "tool", "tool_call_id": "c1", "content": "r1"}, + map[string]interface{}{"role": "tool", "tool_call_id": "c2", "content": "r2"}, + map[string]interface{}{"role": "assistant", "content": "done"}, + }, + } + req, err := convertOpenAIToClaude(payload) + if err != nil { + t.Fatal(err) + } + if len(req.Messages) != 3 || len(req.Messages[1].Content.Blocks) != 2 { + t.Fatalf("unexpected converted messages: %+v", req.Messages) + } +} + +func TestConvertOpenAIToClaudeRejectsMissingToolResult(t *testing.T) { + payload := map[string]interface{}{ + "model": "claude-test", + "messages": []interface{}{ + map[string]interface{}{ + "role": "assistant", + "tool_calls": []interface{}{ + map[string]interface{}{"id": "c1", "function": map[string]interface{}{"name": "one", "arguments": "{}"}}, + map[string]interface{}{"id": "c2", "function": map[string]interface{}{"name": "two", "arguments": "{}"}}, + }, + }, + map[string]interface{}{"role": "tool", "tool_call_id": "c1", "content": "r1"}, + }, + } + _, err := convertOpenAIToClaude(payload) + if err == nil || !strings.Contains(err.Error(), "missing tool_result") { + t.Fatalf("expected missing tool_result error, got %v", err) + } +} + +func TestConvertOpenAIToClaudeRejectsOrphanToolResult(t *testing.T) { + payload := map[string]interface{}{ + "model": "claude-test", + "messages": []interface{}{ + map[string]interface{}{"role": "user", "content": "start"}, + map[string]interface{}{"role": "tool", "tool_call_id": "orphan", "content": "result"}, + }, + } + _, err := convertOpenAIToClaude(payload) + if err == nil || !strings.Contains(err.Error(), "orphan tool_result") { + t.Fatalf("expected orphan tool_result error, got %v", err) + } +} + func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) { claudeBody := []byte(`{ "id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn", diff --git a/internal/security/auth_manager.go b/internal/security/auth_manager.go index 3b9bd17b..e824e962 100644 --- a/internal/security/auth_manager.go +++ b/internal/security/auth_manager.go @@ -1,11 +1,14 @@ package security import ( + "database/sql" "errors" "strings" "sync" "time" + "cyberstrike-ai/internal/database" + "github.com/google/uuid" ) @@ -16,14 +19,21 @@ var ( // Session represents an authenticated user session. type Session struct { - Token string - ExpiresAt time.Time + Token string + ExpiresAt time.Time + UserID string + Username string + DisplayName string + Roles []string + Permissions map[string]bool + Scope string } // AuthManager manages password-based authentication and session lifecycle. type AuthManager struct { password string sessionDuration time.Duration + db *database.DB mu sync.RWMutex sessions map[string]Session @@ -46,23 +56,94 @@ func NewAuthManager(password string, sessionDurationHours int) (*AuthManager, er }, nil } -// Authenticate validates the password and creates a new session. -func (a *AuthManager) Authenticate(password string) (string, time.Time, error) { - if password != a.password { - return "", time.Time{}, ErrInvalidPassword +// AttachRBACStore enables multi-user RBAC authentication and bootstraps the +// built-in admin account from the legacy auth.password value. +func (a *AuthManager) AttachRBACStore(db *database.DB) error { + if db == nil { + return nil } + hash, err := HashPassword(a.password) + if err != nil { + return err + } + if err := db.BootstrapRBAC(hash, PermissionCatalog); err != nil { + return err + } + a.mu.Lock() + a.db = db + a.mu.Unlock() + return nil +} +// Authenticate validates the password and creates a new session. +func (a *AuthManager) Authenticate(username, password string) (string, time.Time, error) { + session, err := a.authenticateSession(username, password) + if err != nil { + return "", time.Time{}, err + } + a.mu.Lock() + a.sessions[session.Token] = session + a.mu.Unlock() + return session.Token, session.ExpiresAt, nil +} + +func (a *AuthManager) authenticateSession(username, password string) (Session, error) { token := uuid.NewString() expiresAt := time.Now().Add(a.sessionDuration) - a.mu.Lock() - a.sessions[token] = Session{ - Token: token, - ExpiresAt: expiresAt, - } - a.mu.Unlock() + a.mu.RLock() + db := a.db + legacyPassword := a.password + a.mu.RUnlock() - return token, expiresAt, nil + if db == nil { + if password != legacyPassword { + return Session{}, ErrInvalidPassword + } + return Session{ + Token: token, + ExpiresAt: expiresAt, + UserID: "admin", + Username: "admin", + DisplayName: "管理员", + Roles: []string{database.RBACSystemRoleAdmin}, + Permissions: allPermissions(), + Scope: database.RBACScopeAll, + }, nil + } + + username = strings.TrimSpace(strings.ToLower(username)) + if username == "" { + username = "admin" + } + user, err := db.GetRBACUserByUsername(username) + if err != nil { + if err == sql.ErrNoRows { + return Session{}, ErrInvalidPassword + } + return Session{}, err + } + if !user.Enabled || !VerifyPasswordHash(password, user.PasswordHash) { + return Session{}, ErrInvalidPassword + } + access, err := db.ResolveRBACAccess(user.ID) + if err != nil { + return Session{}, err + } + roleIDs := make([]string, 0, len(access.Roles)) + for _, role := range access.Roles { + roleIDs = append(roleIDs, role.ID) + } + return Session{ + Token: token, + ExpiresAt: expiresAt, + UserID: user.ID, + Username: user.Username, + DisplayName: user.DisplayName, + Roles: roleIDs, + Permissions: access.Permissions, + Scope: access.Scope, + }, nil } // ValidateToken checks whether the provided token is still valid. @@ -90,9 +171,51 @@ func (a *AuthManager) ValidateToken(token string) (Session, bool) { // CheckPassword verifies whether the provided password matches the current password. func (a *AuthManager) CheckPassword(password string) bool { + return a.CheckUserPassword("admin", password) +} + +// CheckUserPassword verifies whether the provided password matches a user. +func (a *AuthManager) CheckUserPassword(username, password string) bool { a.mu.RLock() - defer a.mu.RUnlock() - return password == a.password + db := a.db + legacyPassword := a.password + a.mu.RUnlock() + if db == nil { + return password == legacyPassword + } + user, err := db.GetRBACUserByUsername(username) + if err != nil { + return false + } + return VerifyPasswordHash(password, user.PasswordHash) +} + +func (a *AuthManager) UpdateUserPassword(userID, password string) error { + password = strings.TrimSpace(password) + if password == "" { + return errors.New("auth password must be configured") + } + hash, err := HashPassword(password) + if err != nil { + return err + } + a.mu.RLock() + db := a.db + a.mu.RUnlock() + if db == nil { + return a.UpdateConfig(password, a.SessionDurationHours()) + } + if err := db.UpdateRBACUserPassword(userID, hash); err != nil { + return err + } + a.mu.Lock() + for token, session := range a.sessions { + if session.UserID == userID { + delete(a.sessions, token) + } + } + a.mu.Unlock() + return nil } // RevokeToken invalidates the specified token. @@ -106,6 +229,26 @@ func (a *AuthManager) RevokeToken(token string) { a.mu.Unlock() } +func (a *AuthManager) RevokeUserSessions(userID string) { + userID = strings.TrimSpace(userID) + if userID == "" { + return + } + a.mu.Lock() + for token, session := range a.sessions { + if session.UserID == userID { + delete(a.sessions, token) + } + } + a.mu.Unlock() +} + +func (a *AuthManager) RevokeAllSessions() { + a.mu.Lock() + a.sessions = make(map[string]Session) + a.mu.Unlock() +} + // SessionDurationHours returns the configured session duration in hours. func (a *AuthManager) SessionDurationHours() int { return int(a.sessionDuration / time.Hour) @@ -122,11 +265,34 @@ func (a *AuthManager) UpdateConfig(password string, sessionDurationHours int) er sessionDurationHours = 12 } - a.mu.Lock() - defer a.mu.Unlock() + hash := "" + if a.db != nil { + var err error + hash, err = HashPassword(password) + if err != nil { + return err + } + } + a.mu.Lock() a.password = password a.sessionDuration = time.Duration(sessionDurationHours) * time.Hour a.sessions = make(map[string]Session) + db := a.db + a.mu.Unlock() + + if db != nil { + if err := db.UpdateRBACAdminPassword(hash); err != nil { + return err + } + } return nil } + +func allPermissions() map[string]bool { + out := make(map[string]bool, len(PermissionCatalog)) + for key := range PermissionCatalog { + out[key] = true + } + return out +} diff --git a/internal/security/auth_manager_test.go b/internal/security/auth_manager_test.go new file mode 100644 index 00000000..7e9dcf92 --- /dev/null +++ b/internal/security/auth_manager_test.go @@ -0,0 +1,53 @@ +package security + +import ( + "path/filepath" + "testing" + + "cyberstrike-ai/internal/database" + + "go.uber.org/zap" +) + +func TestAuthManagerAuthenticatesCreatedRBACUser(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "auth-rbac.db"), zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + manager, err := NewAuthManager("admin-secret", 12) + if err != nil { + t.Fatalf("NewAuthManager: %v", err) + } + if err := manager.AttachRBACStore(db); err != nil { + t.Fatalf("AttachRBACStore: %v", err) + } + hash, err := HashPassword("operator-secret") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + user, err := db.CreateRBACUser("operator1", "Operator One", hash, true, []string{database.RBACSystemRoleViewer}) + if err != nil { + t.Fatalf("CreateRBACUser: %v", err) + } + + token, _, err := manager.Authenticate("operator1", "operator-secret") + if err != nil { + t.Fatalf("Authenticate created user: %v", err) + } + session, ok := manager.ValidateToken(token) + if !ok { + t.Fatalf("expected created user session to validate") + } + if session.UserID != user.ID || session.Username != "operator1" { + t.Fatalf("session user = %s/%s, want %s/operator1", session.UserID, session.Username, user.ID) + } + if !session.Permissions["auth:self"] || !session.Permissions["chat:read"] { + t.Fatalf("expected viewer permissions in session, got %#v", session.Permissions) + } + + if _, _, err := manager.Authenticate("", "operator-secret"); err == nil { + t.Fatalf("empty username must not authenticate non-admin user") + } +} diff --git a/internal/security/auth_middleware.go b/internal/security/auth_middleware.go index e7924a7a..ad116992 100644 --- a/internal/security/auth_middleware.go +++ b/internal/security/auth_middleware.go @@ -4,12 +4,18 @@ import ( "net/http" "strings" + "cyberstrike-ai/internal/database" + "github.com/gin-gonic/gin" ) const ( ContextAuthTokenKey = "authToken" ContextSessionExpiry = "authSessionExpiry" + ContextUserIDKey = "authUserID" + ContextUsernameKey = "authUsername" + ContextUserScopeKey = "authUserScope" + ContextSessionKey = "authSession" ) // AuthMiddleware enforces authentication on protected routes. @@ -26,10 +32,94 @@ func AuthMiddleware(manager *AuthManager) gin.HandlerFunc { c.Set(ContextAuthTokenKey, session.Token) c.Set(ContextSessionExpiry, session.ExpiresAt) + c.Set(ContextUserIDKey, session.UserID) + c.Set(ContextUsernameKey, session.Username) + c.Set(ContextUserScopeKey, session.Scope) + c.Set(ContextSessionKey, session) c.Next() } } +func RequirePermission(permission string) gin.HandlerFunc { + permission = strings.TrimSpace(permission) + return func(c *gin.Context) { + if permission == "" || SessionHasPermission(c, permission) { + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "权限不足", + "permission": permission, + }) + } +} + +func RequireAnyPermission(permissions ...string) gin.HandlerFunc { + return func(c *gin.Context) { + for _, permission := range permissions { + if SessionHasPermission(c, permission) { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "权限不足", + "permissions": permissions, + }) + } +} + +func RequireResourcePermission(db *database.DB, permission, resourceType, paramName string) gin.HandlerFunc { + return func(c *gin.Context) { + if !SessionHasPermission(c, permission) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "权限不足", + "permission": permission, + }) + return + } + if db == nil { + c.Next() + return + } + resourceID := strings.TrimSpace(c.Param(paramName)) + if resourceID == "" { + c.Next() + return + } + session, ok := CurrentSession(c) + if !ok || !db.UserCanAccessResource(session.UserID, session.Scope, resourceType, resourceID) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "无权访问该资源", + "resource_type": resourceType, + "resource_id": resourceID, + }) + return + } + c.Next() + } +} + +func CurrentSession(c *gin.Context) (Session, bool) { + if c == nil { + return Session{}, false + } + v, ok := c.Get(ContextSessionKey) + if !ok { + return Session{}, false + } + session, ok := v.(Session) + return session, ok +} + +func SessionHasPermission(c *gin.Context, permission string) bool { + session, ok := CurrentSession(c) + if !ok { + return false + } + return session.Permissions[permission] +} + func extractTokenFromRequest(c *gin.Context) string { authHeader := c.GetHeader("Authorization") if authHeader != "" { diff --git a/internal/security/rbac.go b/internal/security/rbac.go new file mode 100644 index 00000000..c15049e1 --- /dev/null +++ b/internal/security/rbac.go @@ -0,0 +1,112 @@ +package security + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// Platform permissions use module:action naming. They are intentionally +// separate from AI testing roles under roles/. +var PermissionCatalog = map[string]string{ + "auth:self": "Manage own session and password", + "dashboard:read": "View dashboard summaries", + "chat:read": "View conversations", + "chat:write": "Create and update conversations", + "chat:delete": "Delete conversations and turns", + "agent:execute": "Run AI agents and workflows", + "hitl:read": "View HITL queues and logs", + "hitl:write": "Approve, dismiss, and configure HITL", + "tasks:read": "View task queues", + "tasks:write": "Create and run task queues", + "tasks:delete": "Delete task queues", + "project:read": "View projects and project facts", + "project:write": "Create and update projects and facts", + "project:delete": "Delete projects and facts", + "vulnerability:read": "View vulnerabilities", + "vulnerability:write": "Create and update vulnerabilities", + "vulnerability:delete": "Delete vulnerabilities", + "webshell:read": "View WebShell connections", + "webshell:write": "Manage and use WebShell connections", + "webshell:delete": "Delete WebShell connections", + "c2:read": "View C2 listeners, sessions, tasks, events, and profiles", + "c2:write": "Operate C2 listeners, sessions, tasks, payloads, files, and profiles", + "c2:delete": "Delete C2 objects", + "mcp:read": "View MCP status and external MCP configuration", + "mcp:write": "Manage external MCP servers and invoke MCP endpoint", + "knowledge:read": "View knowledge base and retrieval logs", + "knowledge:write": "Create, update, index, and scan knowledge base", + "knowledge:delete": "Delete knowledge items and retrieval logs", + "skills:read": "View skills and skill stats", + "skills:write": "Create and update skills", + "skills:delete": "Delete skills and stats", + "agents:read": "View markdown agents", + "agents:write": "Create and update markdown agents", + "agents:delete": "Delete markdown agents", + "roles:read": "View AI testing roles", + "roles:write": "Create and update AI testing roles", + "roles:delete": "Delete AI testing roles", + "workflow:read": "View workflow definitions and runs", + "workflow:write": "Create, update, validate, run, and resume workflows", + "workflow:delete": "Delete workflows", + "config:read": "View system configuration", + "config:write": "Update and apply system configuration", + "terminal:execute": "Run terminal commands", + "audit:read": "View and export audit logs", + "audit:delete": "Delete audit logs", + "rbac:read": "View users, platform roles, permissions, and assignments", + "rbac:write": "Manage users, platform roles, permissions, and assignments", + "notification:read": "View notifications", + "notification:write": "Mark notifications as read", + "robot:read": "View robot binding status", + "robot:write": "Manage robot bindings and test robot callbacks", + "files:read": "View chat uploads", + "files:write": "Upload, edit, and rename chat files", + "files:delete": "Delete chat files", + "attackchain:read": "View attack chains", + "attackchain:write": "Regenerate attack chains", + "fofa:execute": "Run FOFA searches and query parsing", + "openapi:read": "Read OpenAPI aggregation results", + "group:read": "View conversation groups", + "group:write": "Create and update conversation groups", + "group:delete": "Delete conversation groups", + "monitor:read": "View execution monitor", + "monitor:write": "Cancel monitor executions", + "monitor:delete": "Delete monitor executions", +} + +func HashPassword(password string) (string, error) { + password = strings.TrimSpace(password) + if password == "" { + return "", fmt.Errorf("password is empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func VerifyPasswordHash(password, encoded string) bool { + if strings.HasPrefix(encoded, "$2a$") || strings.HasPrefix(encoded, "$2b$") || strings.HasPrefix(encoded, "$2y$") { + return bcrypt.CompareHashAndPassword([]byte(encoded), []byte(strings.TrimSpace(password))) == nil + } + parts := strings.Split(encoded, "$") + if len(parts) != 3 || parts[0] != "sha256" { + return false + } + salt, err := hex.DecodeString(parts[1]) + if err != nil { + return false + } + expected, err := hex.DecodeString(parts[2]) + if err != nil { + return false + } + sum := sha256.Sum256(append(salt, []byte(strings.TrimSpace(password))...)) + return subtle.ConstantTimeCompare(sum[:], expected) == 1 +} diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go new file mode 100644 index 00000000..3179364a --- /dev/null +++ b/internal/security/rbac_middleware.go @@ -0,0 +1,156 @@ +package security + +import ( + "net/http" + "strings" + + "cyberstrike-ai/internal/database" + + "github.com/gin-gonic/gin" +) + +// RBACMiddleware maps protected API routes to platform permissions. It keeps +// enforcement centralized so route declarations stay readable. +func RBACMiddleware(db *database.DB) gin.HandlerFunc { + return func(c *gin.Context) { + permission := permissionForRequest(c.Request.Method, c.FullPath()) + if permission == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "未配置访问权限", + }) + return + } + if SessionHasPermission(c, permission) { + if db != nil && !resourceAllowed(c, db) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "权限不足", + "permission": permission, + }) + } +} + +func permissionForRequest(method, fullPath string) string { + path := strings.TrimPrefix(fullPath, "/api") + switch { + case path == "/rbac/me": + return "auth:self" + case path == "/rbac/resources": + // The picker enumerates resource names and IDs and is only needed by + // administrators who can actually create assignments. + return "rbac:write" + case strings.HasPrefix(path, "/rbac"): + if method == http.MethodGet { + return "rbac:read" + } + return "rbac:write" + case strings.HasPrefix(path, "/robot/wechat/status"): + return "robot:read" + case strings.HasPrefix(path, "/robot"): + return "robot:write" + case strings.HasPrefix(path, "/eino-agent"), strings.HasPrefix(path, "/multi-agent"): + if strings.Contains(path, "/markdown-agents") { + return crudPermission(method, "agents") + } + return "agent:execute" + case strings.HasPrefix(path, "/hitl"): + return crudPermission(method, "hitl") + case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"): + return crudPermission(method, "tasks") + case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"): + return crudPermission(method, "chat") + case strings.HasPrefix(path, "/groups"): + return crudPermission(method, "group") + case strings.HasPrefix(path, "/monitor"): + return crudPermission(method, "monitor") + case strings.HasPrefix(path, "/notifications"): + if method == http.MethodGet { + return "notification:read" + } + return "notification:write" + case strings.HasPrefix(path, "/config"): + return crudPermission(method, "config") + case strings.HasPrefix(path, "/terminal"): + return "terminal:execute" + case strings.HasPrefix(path, "/audit"): + return crudPermission(method, "audit") + case strings.HasPrefix(path, "/external-mcp"), path == "/mcp": + return crudPermission(method, "mcp") + case strings.HasPrefix(path, "/attack-chain"): + return crudPermission(method, "attackchain") + case strings.HasPrefix(path, "/knowledge"): + return crudPermission(method, "knowledge") + case strings.HasPrefix(path, "/vulnerabilities"): + return crudPermission(method, "vulnerability") + case strings.HasPrefix(path, "/projects"): + return crudPermission(method, "project") + case strings.HasPrefix(path, "/webshell"): + return crudPermission(method, "webshell") + case strings.HasPrefix(path, "/c2"): + return crudPermission(method, "c2") + case strings.HasPrefix(path, "/chat-uploads"): + return crudPermission(method, "files") + case strings.HasPrefix(path, "/roles"): + return crudPermission(method, "roles") + case strings.HasPrefix(path, "/workflows"): + return crudPermission(method, "workflow") + case strings.HasPrefix(path, "/skills"): + return crudPermission(method, "skills") + case strings.HasPrefix(path, "/openapi"): + return "openapi:read" + case strings.HasPrefix(path, "/fofa"): + return "fofa:execute" + default: + return "" + } +} + +func crudPermission(method, module string) string { + switch method { + case http.MethodGet, http.MethodHead: + return module + ":read" + case http.MethodDelete: + return module + ":delete" + default: + return module + ":write" + } +} + +func resourceAllowed(c *gin.Context, db *database.DB) bool { + session, ok := CurrentSession(c) + if !ok || session.Scope == database.RBACScopeAll { + return ok + } + path := strings.TrimPrefix(c.FullPath(), "/api") + switch { + case strings.HasPrefix(path, "/projects/:id"): + return db.UserCanAccessResource(session.UserID, session.Scope, "project", c.Param("id")) + case strings.HasPrefix(path, "/conversations/:id"): + return db.UserCanAccessResource(session.UserID, session.Scope, "conversation", c.Param("id")) + case strings.HasPrefix(path, "/messages/:id/process-details"): + return db.UserCanAccessMessage(session.UserID, session.Scope, c.Param("id")) + case strings.HasPrefix(path, "/process-details/:id"): + return db.UserCanAccessProcessDetail(session.UserID, session.Scope, c.Param("id")) + case strings.HasPrefix(path, "/attack-chain/:conversationId"): + return db.UserCanAccessResource(session.UserID, session.Scope, "conversation", c.Param("conversationId")) + case strings.HasPrefix(path, "/webshell/connections/:id"): + return db.UserCanAccessResource(session.UserID, session.Scope, "webshell", c.Param("id")) + case strings.HasPrefix(path, "/batch-tasks/:queueId"): + return db.UserCanAccessResource(session.UserID, session.Scope, "batch_task", c.Param("queueId")) + case strings.HasPrefix(path, "/vulnerabilities/:id"): + return db.UserCanAccessResource(session.UserID, session.Scope, "vulnerability", c.Param("id")) + case strings.HasPrefix(path, "/c2/listeners/:id"): + return db.UserCanAccessResource(session.UserID, session.Scope, "c2_listener", c.Param("id")) + case strings.HasPrefix(path, "/c2/sessions/:id"): + return db.UserCanAccessResource(session.UserID, session.Scope, "c2_session", c.Param("id")) + case strings.HasPrefix(path, "/c2/tasks/:id"): + return db.UserCanAccessResource(session.UserID, session.Scope, "c2_task", c.Param("id")) + default: + return true + } +} diff --git a/internal/security/rbac_middleware_test.go b/internal/security/rbac_middleware_test.go new file mode 100644 index 00000000..1d33bac1 --- /dev/null +++ b/internal/security/rbac_middleware_test.go @@ -0,0 +1,120 @@ +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) + } +}