mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-14 07:57:26 +02:00
Add files via upload
This commit is contained in:
@@ -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)
|
||||
// ============================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user