mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-28 13:50:25 +02:00
Add files via upload
This commit is contained in:
@@ -35,11 +35,11 @@ import (
|
|||||||
|
|
||||||
// claudeRequest 表示 Anthropic Messages API 的请求体。
|
// claudeRequest 表示 Anthropic Messages API 的请求体。
|
||||||
type claudeRequest struct {
|
type claudeRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
System string `json:"system,omitempty"`
|
System string `json:"system,omitempty"`
|
||||||
Messages []claudeMessage `json:"messages"`
|
Messages []claudeMessage `json:"messages"`
|
||||||
Tools []claudeTool `json:"tools,omitempty"`
|
Tools []claudeTool `json:"tools,omitempty"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||||
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
||||||
@@ -316,10 +316,73 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
|
|||||||
req.OutputConfig = json.RawMessage(raw)
|
req.OutputConfig = json.RawMessage(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := validateClaudeToolPairs(req.Messages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return req, nil
|
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)
|
// 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) {
|
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
|
||||||
claudeBody := []byte(`{
|
claudeBody := []byte(`{
|
||||||
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
|
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package security
|
package security
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,14 +19,21 @@ var (
|
|||||||
|
|
||||||
// Session represents an authenticated user session.
|
// Session represents an authenticated user session.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string
|
Token string
|
||||||
ExpiresAt time.Time
|
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.
|
// AuthManager manages password-based authentication and session lifecycle.
|
||||||
type AuthManager struct {
|
type AuthManager struct {
|
||||||
password string
|
password string
|
||||||
sessionDuration time.Duration
|
sessionDuration time.Duration
|
||||||
|
db *database.DB
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[string]Session
|
sessions map[string]Session
|
||||||
@@ -46,23 +56,94 @@ func NewAuthManager(password string, sessionDurationHours int) (*AuthManager, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate validates the password and creates a new session.
|
// AttachRBACStore enables multi-user RBAC authentication and bootstraps the
|
||||||
func (a *AuthManager) Authenticate(password string) (string, time.Time, error) {
|
// built-in admin account from the legacy auth.password value.
|
||||||
if password != a.password {
|
func (a *AuthManager) AttachRBACStore(db *database.DB) error {
|
||||||
return "", time.Time{}, ErrInvalidPassword
|
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()
|
token := uuid.NewString()
|
||||||
expiresAt := time.Now().Add(a.sessionDuration)
|
expiresAt := time.Now().Add(a.sessionDuration)
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.RLock()
|
||||||
a.sessions[token] = Session{
|
db := a.db
|
||||||
Token: token,
|
legacyPassword := a.password
|
||||||
ExpiresAt: expiresAt,
|
a.mu.RUnlock()
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
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.
|
// 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.
|
// CheckPassword verifies whether the provided password matches the current password.
|
||||||
func (a *AuthManager) CheckPassword(password string) bool {
|
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()
|
a.mu.RLock()
|
||||||
defer a.mu.RUnlock()
|
db := a.db
|
||||||
return password == a.password
|
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.
|
// RevokeToken invalidates the specified token.
|
||||||
@@ -106,6 +229,26 @@ func (a *AuthManager) RevokeToken(token string) {
|
|||||||
a.mu.Unlock()
|
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.
|
// SessionDurationHours returns the configured session duration in hours.
|
||||||
func (a *AuthManager) SessionDurationHours() int {
|
func (a *AuthManager) SessionDurationHours() int {
|
||||||
return int(a.sessionDuration / time.Hour)
|
return int(a.sessionDuration / time.Hour)
|
||||||
@@ -122,11 +265,34 @@ func (a *AuthManager) UpdateConfig(password string, sessionDurationHours int) er
|
|||||||
sessionDurationHours = 12
|
sessionDurationHours = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
hash := ""
|
||||||
defer a.mu.Unlock()
|
if a.db != nil {
|
||||||
|
var err error
|
||||||
|
hash, err = HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
a.password = password
|
a.password = password
|
||||||
a.sessionDuration = time.Duration(sessionDurationHours) * time.Hour
|
a.sessionDuration = time.Duration(sessionDurationHours) * time.Hour
|
||||||
a.sessions = make(map[string]Session)
|
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
|
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"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ContextAuthTokenKey = "authToken"
|
ContextAuthTokenKey = "authToken"
|
||||||
ContextSessionExpiry = "authSessionExpiry"
|
ContextSessionExpiry = "authSessionExpiry"
|
||||||
|
ContextUserIDKey = "authUserID"
|
||||||
|
ContextUsernameKey = "authUsername"
|
||||||
|
ContextUserScopeKey = "authUserScope"
|
||||||
|
ContextSessionKey = "authSession"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthMiddleware enforces authentication on protected routes.
|
// AuthMiddleware enforces authentication on protected routes.
|
||||||
@@ -26,10 +32,94 @@ func AuthMiddleware(manager *AuthManager) gin.HandlerFunc {
|
|||||||
|
|
||||||
c.Set(ContextAuthTokenKey, session.Token)
|
c.Set(ContextAuthTokenKey, session.Token)
|
||||||
c.Set(ContextSessionExpiry, session.ExpiresAt)
|
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()
|
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 {
|
func extractTokenFromRequest(c *gin.Context) string {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
if authHeader != "" {
|
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