mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat(auth): add policy evaluator and SQLite auth manager for RBAC
- Add policy evaluator for fine-grained access control - Implement SQLite-backed auth manager for user/role persistence - Support role-based permissions evaluation
This commit is contained in:
204
pkg/auth/policy_evaluator.go
Normal file
204
pkg/auth/policy_evaluator.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PolicyEvaluator implements Authorizer with advanced policy evaluation.
|
||||
// It supports:
|
||||
// - Deny precedence (deny rules override allow rules)
|
||||
// - Role inheritance
|
||||
// - Attribute-based conditions
|
||||
// - Resource wildcards
|
||||
type PolicyEvaluator struct {
|
||||
manager Manager
|
||||
}
|
||||
|
||||
// NewPolicyEvaluator creates a new policy evaluator.
|
||||
func NewPolicyEvaluator(manager Manager) *PolicyEvaluator {
|
||||
return &PolicyEvaluator{manager: manager}
|
||||
}
|
||||
|
||||
// Authorize checks if the user in the context can perform the action on the resource.
|
||||
// Returns true if allowed, false if denied.
|
||||
func (e *PolicyEvaluator) Authorize(ctx context.Context, action string, resource string) (bool, error) {
|
||||
return e.AuthorizeWithAttributes(ctx, action, resource, nil)
|
||||
}
|
||||
|
||||
// AuthorizeWithAttributes checks authorization with additional attributes for ABAC.
|
||||
func (e *PolicyEvaluator) AuthorizeWithAttributes(ctx context.Context, action string, resource string, attributes map[string]string) (bool, error) {
|
||||
username := GetUser(ctx)
|
||||
if username == "" {
|
||||
return false, nil // No user in context = deny
|
||||
}
|
||||
|
||||
// Get all permissions for the user
|
||||
permissions := e.getUserEffectivePermissions(username)
|
||||
if len(permissions) == 0 {
|
||||
return false, nil // No permissions = deny
|
||||
}
|
||||
|
||||
// Filter permissions that match the requested action and resource
|
||||
matching := e.filterMatching(permissions, action, resource)
|
||||
if len(matching) == 0 {
|
||||
return false, nil // No matching permissions = deny
|
||||
}
|
||||
|
||||
// Evaluate conditions and apply deny precedence
|
||||
return e.evaluateWithDenyPrecedence(matching, username, attributes), nil
|
||||
}
|
||||
|
||||
// getUserEffectivePermissions returns all permissions for a user including inherited ones.
|
||||
func (e *PolicyEvaluator) getUserEffectivePermissions(username string) []Permission {
|
||||
// Check if we have an extended manager with inheritance support
|
||||
if em, ok := e.manager.(ExtendedManager); ok {
|
||||
roles := em.GetRolesWithInheritance(username)
|
||||
var allPerms []Permission
|
||||
for _, role := range roles {
|
||||
allPerms = append(allPerms, role.Permissions...)
|
||||
}
|
||||
return allPerms
|
||||
}
|
||||
|
||||
// Fall back to basic manager
|
||||
return e.manager.GetUserPermissions(username)
|
||||
}
|
||||
|
||||
// filterMatching returns permissions that match the requested action and resource.
|
||||
func (e *PolicyEvaluator) filterMatching(permissions []Permission, action, resource string) []Permission {
|
||||
var matching []Permission
|
||||
|
||||
for _, perm := range permissions {
|
||||
// Check action match
|
||||
if !MatchesAction(perm.Action, action) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check resource match
|
||||
if !MatchesResource(perm.Resource, resource) {
|
||||
continue
|
||||
}
|
||||
|
||||
matching = append(matching, perm)
|
||||
}
|
||||
|
||||
return matching
|
||||
}
|
||||
|
||||
// evaluateWithDenyPrecedence evaluates permissions with deny taking precedence.
|
||||
// Order: explicit deny > explicit allow > implicit deny
|
||||
func (e *PolicyEvaluator) evaluateWithDenyPrecedence(permissions []Permission, username string, attributes map[string]string) bool {
|
||||
var allowFound bool
|
||||
|
||||
for _, perm := range permissions {
|
||||
// Check conditions
|
||||
if !e.evaluateConditions(perm, username, attributes) {
|
||||
continue // Condition not met, skip this permission
|
||||
}
|
||||
|
||||
// Check effect
|
||||
effect := perm.GetEffect()
|
||||
if effect == EffectDeny {
|
||||
return false // Explicit deny wins immediately
|
||||
}
|
||||
|
||||
if effect == EffectAllow {
|
||||
allowFound = true
|
||||
}
|
||||
}
|
||||
|
||||
return allowFound // Return true only if we found an allow
|
||||
}
|
||||
|
||||
// evaluateConditions checks if all conditions in a permission are satisfied.
|
||||
func (e *PolicyEvaluator) evaluateConditions(perm Permission, username string, attributes map[string]string) bool {
|
||||
if len(perm.Conditions) == 0 {
|
||||
return true // No conditions = always matches
|
||||
}
|
||||
|
||||
for key, expectedValue := range perm.Conditions {
|
||||
// Handle variable substitution
|
||||
expectedValue = e.substituteVariables(expectedValue, username, attributes)
|
||||
|
||||
// Get actual value from attributes
|
||||
actualValue, exists := attributes[key]
|
||||
if !exists {
|
||||
return false // Required attribute missing
|
||||
}
|
||||
|
||||
if actualValue != expectedValue {
|
||||
return false // Value doesn't match
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// substituteVariables replaces ${variable} placeholders in condition values.
|
||||
func (e *PolicyEvaluator) substituteVariables(value, username string, attributes map[string]string) string {
|
||||
// Replace ${user} with the current username
|
||||
value = strings.ReplaceAll(value, "${user}", username)
|
||||
|
||||
// Replace ${attr.key} with attribute values
|
||||
for key, val := range attributes {
|
||||
value = strings.ReplaceAll(value, "${attr."+key+"}", val)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// SetAdminUser implements AdminConfigurable.
|
||||
// The admin user always has full access regardless of roles.
|
||||
func (e *PolicyEvaluator) SetAdminUser(username string) {
|
||||
// Store admin user for bypass - not implemented in this basic version
|
||||
// The FileManager handles this separately
|
||||
}
|
||||
|
||||
// RBACAuthorizer wraps PolicyEvaluator to implement Authorizer for the RBAC system.
|
||||
type RBACAuthorizer struct {
|
||||
evaluator *PolicyEvaluator
|
||||
adminUser string
|
||||
}
|
||||
|
||||
// NewRBACAuthorizer creates a new RBAC authorizer.
|
||||
func NewRBACAuthorizer(manager Manager) *RBACAuthorizer {
|
||||
return &RBACAuthorizer{
|
||||
evaluator: NewPolicyEvaluator(manager),
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize checks if the user can perform the action on the resource.
|
||||
func (a *RBACAuthorizer) Authorize(ctx context.Context, action string, resource string) (bool, error) {
|
||||
username := GetUser(ctx)
|
||||
|
||||
// Admin user bypass
|
||||
if a.adminUser != "" && username == a.adminUser {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return a.evaluator.Authorize(ctx, action, resource)
|
||||
}
|
||||
|
||||
// AuthorizeWithAttributes checks authorization with ABAC attributes.
|
||||
func (a *RBACAuthorizer) AuthorizeWithAttributes(ctx context.Context, action string, resource string, attributes map[string]string) (bool, error) {
|
||||
username := GetUser(ctx)
|
||||
|
||||
// Admin user bypass
|
||||
if a.adminUser != "" && username == a.adminUser {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return a.evaluator.AuthorizeWithAttributes(ctx, action, resource, attributes)
|
||||
}
|
||||
|
||||
// SetAdminUser sets the admin user who has full access.
|
||||
func (a *RBACAuthorizer) SetAdminUser(username string) {
|
||||
a.adminUser = username
|
||||
}
|
||||
|
||||
// AttributeAuthorizer extends Authorizer with attribute-based authorization.
|
||||
type AttributeAuthorizer interface {
|
||||
Authorizer
|
||||
AuthorizeWithAttributes(ctx context.Context, action string, resource string, attributes map[string]string) (bool, error)
|
||||
}
|
||||
435
pkg/auth/policy_evaluator_test.go
Normal file
435
pkg/auth/policy_evaluator_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPolicyEvaluator(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "policy-eval-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
evaluator := NewPolicyEvaluator(m)
|
||||
|
||||
// Setup test data
|
||||
setupTestRoles(t, m)
|
||||
|
||||
t.Run("Allow permission works", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "allow-user")
|
||||
m.UpdateUserRoles("allow-user", []string{"allow-role"})
|
||||
|
||||
allowed, err := evaluator.Authorize(ctx, "read", "nodes")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected access to be allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No matching permission denies", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "allow-user")
|
||||
|
||||
allowed, err := evaluator.Authorize(ctx, "delete", "nodes")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected access to be denied (no matching permission)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deny takes precedence over allow", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "deny-user")
|
||||
m.UpdateUserRoles("deny-user", []string{"deny-role"})
|
||||
|
||||
// deny-role has allow on nodes:* but deny on nodes:production
|
||||
allowed, err := evaluator.Authorize(ctx, "write", "nodes:test")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected write to nodes:test to be allowed")
|
||||
}
|
||||
|
||||
allowed, err = evaluator.Authorize(ctx, "write", "nodes:production")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected write to nodes:production to be denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple roles combined", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "multi-user")
|
||||
m.UpdateUserRoles("multi-user", []string{"allow-role", "extra-role"})
|
||||
|
||||
// allow-role grants read:nodes, extra-role grants write:alerts
|
||||
allowed, err := evaluator.Authorize(ctx, "read", "nodes")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected read nodes allowed from allow-role")
|
||||
}
|
||||
|
||||
allowed, err = evaluator.Authorize(ctx, "write", "alerts")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected write alerts allowed from extra-role")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No user in context denies", func(t *testing.T) {
|
||||
ctx := context.Background() // No user
|
||||
|
||||
allowed, err := evaluator.Authorize(ctx, "read", "nodes")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected access denied with no user in context")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("User with no roles denies", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "no-role-user")
|
||||
// Don't assign any roles
|
||||
|
||||
allowed, err := evaluator.Authorize(ctx, "read", "nodes")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected access denied with no roles")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPolicyEvaluatorWithAttributes(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "policy-abac-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
evaluator := NewPolicyEvaluator(m)
|
||||
|
||||
// Create role with conditions
|
||||
condRole := Role{
|
||||
ID: "cond-role",
|
||||
Name: "Conditional Role",
|
||||
Permissions: []Permission{
|
||||
{
|
||||
Action: "read",
|
||||
Resource: "nodes:*",
|
||||
Effect: EffectAllow,
|
||||
Conditions: map[string]string{"env": "test"},
|
||||
},
|
||||
{
|
||||
Action: "write",
|
||||
Resource: "nodes:*",
|
||||
Effect: EffectAllow,
|
||||
Conditions: map[string]string{"owner": "${user}"},
|
||||
},
|
||||
},
|
||||
}
|
||||
m.SaveRole(condRole)
|
||||
m.UpdateUserRoles("cond-user", []string{"cond-role"})
|
||||
|
||||
t.Run("Condition matches", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "cond-user")
|
||||
|
||||
allowed, err := evaluator.AuthorizeWithAttributes(ctx, "read", "nodes:dev", map[string]string{"env": "test"})
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected access allowed when condition matches")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Condition does not match", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "cond-user")
|
||||
|
||||
allowed, err := evaluator.AuthorizeWithAttributes(ctx, "read", "nodes:dev", map[string]string{"env": "prod"})
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected access denied when condition doesn't match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Variable substitution works", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "cond-user")
|
||||
|
||||
// ${user} should be replaced with "cond-user"
|
||||
allowed, err := evaluator.AuthorizeWithAttributes(ctx, "write", "nodes:dev", map[string]string{"owner": "cond-user"})
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected access allowed when ${user} matches current user")
|
||||
}
|
||||
|
||||
// Different owner should fail
|
||||
allowed, err = evaluator.AuthorizeWithAttributes(ctx, "write", "nodes:dev", map[string]string{"owner": "other-user"})
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected access denied when owner doesn't match current user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Missing attribute denies", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "cond-user")
|
||||
|
||||
// No env attribute provided
|
||||
allowed, err := evaluator.AuthorizeWithAttributes(ctx, "read", "nodes:dev", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected access denied when required attribute is missing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPolicyEvaluatorWithInheritance(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "policy-inherit-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
evaluator := NewPolicyEvaluator(m)
|
||||
|
||||
// Create parent role
|
||||
parentRole := Role{
|
||||
ID: "parent",
|
||||
Name: "Parent",
|
||||
Permissions: []Permission{{Action: "read", Resource: "base"}},
|
||||
}
|
||||
m.SaveRole(parentRole)
|
||||
|
||||
// Create child role
|
||||
childRole := Role{
|
||||
ID: "child",
|
||||
Name: "Child",
|
||||
ParentID: "parent",
|
||||
Permissions: []Permission{{Action: "write", Resource: "child"}},
|
||||
}
|
||||
m.SaveRole(childRole)
|
||||
|
||||
m.UpdateUserRoles("inherit-user", []string{"child"})
|
||||
|
||||
t.Run("Inherited permission works", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "inherit-user")
|
||||
|
||||
// Should have access via inheritance
|
||||
allowed, err := evaluator.Authorize(ctx, "read", "base")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected inherited permission to grant access")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Own permission works", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "inherit-user")
|
||||
|
||||
allowed, err := evaluator.Authorize(ctx, "write", "child")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected own permission to grant access")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRBACAuthorizer(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-auth-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
authorizer := NewRBACAuthorizer(m)
|
||||
|
||||
// Setup
|
||||
setupTestRoles(t, m)
|
||||
m.UpdateUserRoles("normal-user", []string{"allow-role"})
|
||||
|
||||
t.Run("Normal user authorization", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "normal-user")
|
||||
|
||||
allowed, err := authorizer.Authorize(ctx, "read", "nodes")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected normal user to have read access")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Admin bypass", func(t *testing.T) {
|
||||
authorizer.SetAdminUser("superadmin")
|
||||
ctx := WithUser(context.Background(), "superadmin")
|
||||
|
||||
// Admin should have access even without roles
|
||||
allowed, err := authorizer.Authorize(ctx, "delete", "everything")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Expected admin user to have full access")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Non-admin still checks permissions", func(t *testing.T) {
|
||||
ctx := WithUser(context.Background(), "normal-user")
|
||||
|
||||
// normal-user doesn't have delete permission
|
||||
allowed, err := authorizer.Authorize(ctx, "delete", "nodes")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Expected non-admin to be denied")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchesResource(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
resource string
|
||||
expected bool
|
||||
}{
|
||||
{"*", "anything", true},
|
||||
{"*", "nodes", true},
|
||||
{"nodes", "nodes", true},
|
||||
{"nodes", "alerts", false},
|
||||
{"nodes:pve1", "nodes:pve1", true},
|
||||
{"nodes:pve1", "nodes:pve2", false},
|
||||
{"nodes:*", "nodes:pve1", true},
|
||||
{"nodes:*", "nodes:pve2", true},
|
||||
{"nodes:*", "nodes", true},
|
||||
{"nodes:*", "alerts", false},
|
||||
{"settings:*", "settings:admin", true},
|
||||
{"settings:*", "settings", true},
|
||||
{"admin:*", "admin:users", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pattern+"_"+tt.resource, func(t *testing.T) {
|
||||
result := MatchesResource(tt.pattern, tt.resource)
|
||||
if result != tt.expected {
|
||||
t.Errorf("MatchesResource(%q, %q) = %v, expected %v", tt.pattern, tt.resource, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
permAction string
|
||||
requestedAction string
|
||||
expected bool
|
||||
}{
|
||||
{"admin", "read", true},
|
||||
{"admin", "write", true},
|
||||
{"admin", "delete", true},
|
||||
{"admin", "admin", true},
|
||||
{"read", "read", true},
|
||||
{"read", "write", false},
|
||||
{"write", "write", true},
|
||||
{"write", "read", false},
|
||||
{"delete", "delete", true},
|
||||
{"delete", "read", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.permAction+"_"+tt.requestedAction, func(t *testing.T) {
|
||||
result := MatchesAction(tt.permAction, tt.requestedAction)
|
||||
if result != tt.expected {
|
||||
t.Errorf("MatchesAction(%q, %q) = %v, expected %v", tt.permAction, tt.requestedAction, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to setup test roles
|
||||
func setupTestRoles(t *testing.T, m *SQLiteManager) {
|
||||
// Allow role - basic read access
|
||||
allowRole := Role{
|
||||
ID: "allow-role",
|
||||
Name: "Allow Role",
|
||||
Permissions: []Permission{
|
||||
{Action: "read", Resource: "nodes", Effect: EffectAllow},
|
||||
},
|
||||
}
|
||||
if err := m.SaveRole(allowRole); err != nil {
|
||||
t.Fatalf("Failed to create allow-role: %v", err)
|
||||
}
|
||||
|
||||
// Deny role - allow with specific deny
|
||||
denyRole := Role{
|
||||
ID: "deny-role",
|
||||
Name: "Deny Role",
|
||||
Permissions: []Permission{
|
||||
{Action: "write", Resource: "nodes:*", Effect: EffectAllow},
|
||||
{Action: "write", Resource: "nodes:production", Effect: EffectDeny},
|
||||
},
|
||||
}
|
||||
if err := m.SaveRole(denyRole); err != nil {
|
||||
t.Fatalf("Failed to create deny-role: %v", err)
|
||||
}
|
||||
|
||||
// Extra role for multi-role tests
|
||||
extraRole := Role{
|
||||
ID: "extra-role",
|
||||
Name: "Extra Role",
|
||||
Permissions: []Permission{
|
||||
{Action: "write", Resource: "alerts", Effect: EffectAllow},
|
||||
},
|
||||
}
|
||||
if err := m.SaveRole(extraRole); err != nil {
|
||||
t.Fatalf("Failed to create extra-role: %v", err)
|
||||
}
|
||||
}
|
||||
954
pkg/auth/sqlite_manager.go
Normal file
954
pkg/auth/sqlite_manager.go
Normal file
@@ -0,0 +1,954 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// SQLiteManagerConfig configures the SQLite RBAC manager.
|
||||
type SQLiteManagerConfig struct {
|
||||
DataDir string // Directory for rbac.db
|
||||
MigrateFromFiles bool // Attempt to migrate from file-based storage
|
||||
ChangeLogRetention int // Days to keep change logs (default: 90, 0 = forever)
|
||||
}
|
||||
|
||||
// SQLiteManager implements ExtendedManager with SQLite persistence.
|
||||
type SQLiteManager struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
dbPath string
|
||||
changeLogRetention int
|
||||
}
|
||||
|
||||
// NewSQLiteManager creates a new SQLite-backed RBAC manager.
|
||||
func NewSQLiteManager(cfg SQLiteManagerConfig) (*SQLiteManager, error) {
|
||||
if cfg.DataDir == "" {
|
||||
return nil, fmt.Errorf("data directory is required")
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
rbacDir := filepath.Join(cfg.DataDir, "rbac")
|
||||
if err := os.MkdirAll(rbacDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create rbac directory: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(rbacDir, "rbac.db")
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open rbac database: %w", err)
|
||||
}
|
||||
|
||||
// Configure SQLite for better concurrency and durability
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
"PRAGMA cache_size=-32000", // 32MB cache
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to set pragma %s: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
retention := cfg.ChangeLogRetention
|
||||
if retention == 0 {
|
||||
retention = 90 // Default 90 days
|
||||
}
|
||||
|
||||
m := &SQLiteManager{
|
||||
db: db,
|
||||
dbPath: dbPath,
|
||||
changeLogRetention: retention,
|
||||
}
|
||||
|
||||
// Initialize schema
|
||||
if err := m.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
|
||||
// Initialize built-in roles if they don't exist
|
||||
if err := m.initBuiltInRoles(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize built-in roles: %w", err)
|
||||
}
|
||||
|
||||
// Migrate from file-based storage if requested
|
||||
if cfg.MigrateFromFiles {
|
||||
if err := m.migrateFromFiles(cfg.DataDir); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to migrate RBAC from files (may not exist)")
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) initSchema() error {
|
||||
schema := `
|
||||
-- Roles table
|
||||
CREATE TABLE IF NOT EXISTS rbac_roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
parent_id TEXT,
|
||||
is_built_in INTEGER NOT NULL DEFAULT 0,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES rbac_roles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Permissions table (one-to-many with roles)
|
||||
CREATE TABLE IF NOT EXISTS rbac_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
effect TEXT NOT NULL DEFAULT 'allow',
|
||||
conditions TEXT,
|
||||
FOREIGN KEY (role_id) REFERENCES rbac_roles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- User role assignments
|
||||
CREATE TABLE IF NOT EXISTS rbac_user_assignments (
|
||||
username TEXT NOT NULL,
|
||||
role_id TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (username, role_id),
|
||||
FOREIGN KEY (role_id) REFERENCES rbac_roles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Change log
|
||||
CREATE TABLE IF NOT EXISTS rbac_changelog (
|
||||
id TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
user TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_rbac_perm_role ON rbac_permissions(role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rbac_assign_user ON rbac_user_assignments(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_rbac_changelog_time ON rbac_changelog(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_rbac_changelog_entity ON rbac_changelog(entity_type, entity_id);
|
||||
`
|
||||
|
||||
_, err := m.db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) initBuiltInRoles() error {
|
||||
now := time.Now().Unix()
|
||||
|
||||
builtInRoles := []struct {
|
||||
id string
|
||||
name string
|
||||
description string
|
||||
permissions []Permission
|
||||
}{
|
||||
{
|
||||
id: RoleAdmin,
|
||||
name: "Administrator",
|
||||
description: "Full administrative access to all features",
|
||||
permissions: []Permission{{Action: "admin", Resource: "*"}},
|
||||
},
|
||||
{
|
||||
id: RoleOperator,
|
||||
name: "Operator",
|
||||
description: "Can manage nodes and perform operational tasks",
|
||||
permissions: []Permission{
|
||||
{Action: "read", Resource: "*"},
|
||||
{Action: "write", Resource: "nodes"},
|
||||
{Action: "write", Resource: "vms"},
|
||||
{Action: "write", Resource: "containers"},
|
||||
{Action: "write", Resource: "alerts"},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: RoleViewer,
|
||||
name: "Viewer",
|
||||
description: "Read-only access to monitoring data",
|
||||
permissions: []Permission{{Action: "read", Resource: "*"}},
|
||||
},
|
||||
{
|
||||
id: RoleAuditor,
|
||||
name: "Auditor",
|
||||
description: "Access to audit logs and compliance data",
|
||||
permissions: []Permission{
|
||||
{Action: "read", Resource: "audit_logs"},
|
||||
{Action: "read", Resource: "nodes"},
|
||||
{Action: "read", Resource: "alerts"},
|
||||
{Action: "read", Resource: "compliance"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, r := range builtInRoles {
|
||||
// Check if role already exists
|
||||
var count int
|
||||
err := m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE id = ?", r.id).Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
continue // Role already exists
|
||||
}
|
||||
|
||||
// Insert role
|
||||
_, err = m.db.Exec(`
|
||||
INSERT INTO rbac_roles (id, name, description, is_built_in, priority, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, 0, ?, ?)
|
||||
`, r.id, r.name, r.description, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert permissions
|
||||
for _, perm := range r.permissions {
|
||||
_, err = m.db.Exec(`
|
||||
INSERT INTO rbac_permissions (role_id, action, resource, effect)
|
||||
VALUES (?, ?, ?, 'allow')
|
||||
`, r.id, perm.Action, perm.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (m *SQLiteManager) Close() error {
|
||||
return m.db.Close()
|
||||
}
|
||||
|
||||
// GetRoles returns all roles.
|
||||
func (m *SQLiteManager) GetRoles() []Role {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
rows, err := m.db.Query(`
|
||||
SELECT id, name, description, parent_id, is_built_in, priority, created_at, updated_at
|
||||
FROM rbac_roles
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query roles")
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var roles []Role
|
||||
for rows.Next() {
|
||||
var role Role
|
||||
var parentID sql.NullString
|
||||
var createdAt, updatedAt int64
|
||||
var isBuiltIn int
|
||||
|
||||
if err := rows.Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority, &createdAt, &updatedAt); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to scan role")
|
||||
continue
|
||||
}
|
||||
|
||||
role.ParentID = parentID.String
|
||||
role.IsBuiltIn = isBuiltIn == 1
|
||||
role.CreatedAt = time.Unix(createdAt, 0)
|
||||
role.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
|
||||
// Load permissions
|
||||
role.Permissions = m.loadRolePermissions(role.ID)
|
||||
|
||||
roles = append(roles, role)
|
||||
}
|
||||
|
||||
return roles
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) loadRolePermissions(roleID string) []Permission {
|
||||
rows, err := m.db.Query(`
|
||||
SELECT action, resource, effect, conditions
|
||||
FROM rbac_permissions
|
||||
WHERE role_id = ?
|
||||
`, roleID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("roleId", roleID).Msg("Failed to query permissions")
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var perms []Permission
|
||||
for rows.Next() {
|
||||
var perm Permission
|
||||
var conditions sql.NullString
|
||||
|
||||
if err := rows.Scan(&perm.Action, &perm.Resource, &perm.Effect, &conditions); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to scan permission")
|
||||
continue
|
||||
}
|
||||
|
||||
if conditions.Valid && conditions.String != "" {
|
||||
json.Unmarshal([]byte(conditions.String), &perm.Conditions)
|
||||
}
|
||||
|
||||
perms = append(perms, perm)
|
||||
}
|
||||
|
||||
return perms
|
||||
}
|
||||
|
||||
// GetRole returns a role by ID.
|
||||
func (m *SQLiteManager) GetRole(id string) (Role, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var role Role
|
||||
var parentID sql.NullString
|
||||
var createdAt, updatedAt int64
|
||||
var isBuiltIn int
|
||||
|
||||
err := m.db.QueryRow(`
|
||||
SELECT id, name, description, parent_id, is_built_in, priority, created_at, updated_at
|
||||
FROM rbac_roles
|
||||
WHERE id = ?
|
||||
`, id).Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority, &createdAt, &updatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return Role{}, false
|
||||
}
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("roleId", id).Msg("Failed to query role")
|
||||
return Role{}, false
|
||||
}
|
||||
|
||||
role.ParentID = parentID.String
|
||||
role.IsBuiltIn = isBuiltIn == 1
|
||||
role.CreatedAt = time.Unix(createdAt, 0)
|
||||
role.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
role.Permissions = m.loadRolePermissions(role.ID)
|
||||
|
||||
return role, true
|
||||
}
|
||||
|
||||
// SaveRole creates or updates a role.
|
||||
func (m *SQLiteManager) SaveRole(role Role) error {
|
||||
return m.SaveRoleWithContext(role, "")
|
||||
}
|
||||
|
||||
// SaveRoleWithContext creates or updates a role with audit context.
|
||||
func (m *SQLiteManager) SaveRoleWithContext(role Role, username string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if it's a built-in role
|
||||
var isBuiltIn int
|
||||
err := m.db.QueryRow("SELECT is_built_in FROM rbac_roles WHERE id = ?", role.ID).Scan(&isBuiltIn)
|
||||
if err == nil && isBuiltIn == 1 {
|
||||
return fmt.Errorf("cannot modify built-in role: %s", role.ID)
|
||||
}
|
||||
|
||||
// Check for circular inheritance
|
||||
if role.ParentID != "" {
|
||||
if err := m.checkCircularInheritanceUnsafe(role.ID, role.ParentID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Get old value for changelog
|
||||
oldRole, exists := m.getRoleUnsafe(role.ID)
|
||||
var oldValueJSON string
|
||||
if exists {
|
||||
if data, err := json.Marshal(oldRole); err == nil {
|
||||
oldValueJSON = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
role.UpdatedAt = now
|
||||
if role.CreatedAt.IsZero() {
|
||||
role.CreatedAt = now
|
||||
}
|
||||
|
||||
tx, err := m.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Upsert role
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO rbac_roles (id, name, description, parent_id, is_built_in, priority, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
parent_id = excluded.parent_id,
|
||||
priority = excluded.priority,
|
||||
updated_at = excluded.updated_at
|
||||
`, role.ID, role.Name, role.Description, nullString(role.ParentID), role.Priority, role.CreatedAt.Unix(), role.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete existing permissions and insert new ones
|
||||
_, err = tx.Exec("DELETE FROM rbac_permissions WHERE role_id = ?", role.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, perm := range role.Permissions {
|
||||
var conditionsJSON *string
|
||||
if len(perm.Conditions) > 0 {
|
||||
if data, err := json.Marshal(perm.Conditions); err == nil {
|
||||
s := string(data)
|
||||
conditionsJSON = &s
|
||||
}
|
||||
}
|
||||
|
||||
effect := perm.GetEffect()
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO rbac_permissions (role_id, action, resource, effect, conditions)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, role.ID, perm.Action, perm.Resource, effect, conditionsJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log change
|
||||
action := ActionRoleCreated
|
||||
if exists {
|
||||
action = ActionRoleUpdated
|
||||
}
|
||||
newValueJSON, _ := json.Marshal(role)
|
||||
m.logChangeUnsafe(action, "role", role.ID, oldValueJSON, string(newValueJSON), username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) getRoleUnsafe(id string) (Role, bool) {
|
||||
var role Role
|
||||
var parentID sql.NullString
|
||||
var createdAt, updatedAt int64
|
||||
var isBuiltIn int
|
||||
|
||||
err := m.db.QueryRow(`
|
||||
SELECT id, name, description, parent_id, is_built_in, priority, created_at, updated_at
|
||||
FROM rbac_roles
|
||||
WHERE id = ?
|
||||
`, id).Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority, &createdAt, &updatedAt)
|
||||
|
||||
if err != nil {
|
||||
return Role{}, false
|
||||
}
|
||||
|
||||
role.ParentID = parentID.String
|
||||
role.IsBuiltIn = isBuiltIn == 1
|
||||
role.CreatedAt = time.Unix(createdAt, 0)
|
||||
role.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
role.Permissions = m.loadRolePermissions(role.ID)
|
||||
|
||||
return role, true
|
||||
}
|
||||
|
||||
// DeleteRole removes a role by ID.
|
||||
func (m *SQLiteManager) DeleteRole(id string) error {
|
||||
return m.DeleteRoleWithContext(id, "")
|
||||
}
|
||||
|
||||
// DeleteRoleWithContext removes a role with audit context.
|
||||
func (m *SQLiteManager) DeleteRoleWithContext(id string, username string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if it's a built-in role
|
||||
var isBuiltIn int
|
||||
err := m.db.QueryRow("SELECT is_built_in FROM rbac_roles WHERE id = ?", id).Scan(&isBuiltIn)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil // Already deleted
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isBuiltIn == 1 {
|
||||
return fmt.Errorf("cannot delete built-in role: %s", id)
|
||||
}
|
||||
|
||||
// Get old value for changelog
|
||||
oldRole, _ := m.getRoleUnsafe(id)
|
||||
oldValueJSON, _ := json.Marshal(oldRole)
|
||||
|
||||
// Delete role (cascades to permissions)
|
||||
_, err = m.db.Exec("DELETE FROM rbac_roles WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log change
|
||||
m.logChangeUnsafe(ActionRoleDeleted, "role", id, string(oldValueJSON), "", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserAssignments returns all user role assignments.
|
||||
func (m *SQLiteManager) GetUserAssignments() []UserRoleAssignment {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Get unique usernames
|
||||
rows, err := m.db.Query("SELECT DISTINCT username FROM rbac_user_assignments")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query user assignments")
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assignments []UserRoleAssignment
|
||||
for rows.Next() {
|
||||
var username string
|
||||
if err := rows.Scan(&username); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
assignment := m.getUserAssignmentUnsafe(username)
|
||||
if len(assignment.RoleIDs) > 0 {
|
||||
assignments = append(assignments, assignment)
|
||||
}
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) getUserAssignmentUnsafe(username string) UserRoleAssignment {
|
||||
rows, err := m.db.Query(`
|
||||
SELECT role_id, updated_at
|
||||
FROM rbac_user_assignments
|
||||
WHERE username = ?
|
||||
`, username)
|
||||
if err != nil {
|
||||
return UserRoleAssignment{Username: username}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var roleIDs []string
|
||||
var latestUpdate int64
|
||||
for rows.Next() {
|
||||
var roleID string
|
||||
var updatedAt int64
|
||||
if err := rows.Scan(&roleID, &updatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
roleIDs = append(roleIDs, roleID)
|
||||
if updatedAt > latestUpdate {
|
||||
latestUpdate = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return UserRoleAssignment{
|
||||
Username: username,
|
||||
RoleIDs: roleIDs,
|
||||
UpdatedAt: time.Unix(latestUpdate, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserAssignment returns the role assignment for a user.
|
||||
func (m *SQLiteManager) GetUserAssignment(username string) (UserRoleAssignment, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
assignment := m.getUserAssignmentUnsafe(username)
|
||||
return assignment, len(assignment.RoleIDs) > 0
|
||||
}
|
||||
|
||||
// AssignRole adds a role to a user.
|
||||
func (m *SQLiteManager) AssignRole(username string, roleID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Verify role exists
|
||||
var count int
|
||||
if err := m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE id = ?", roleID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("role not found: %s", roleID)
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
_, err := m.db.Exec(`
|
||||
INSERT OR IGNORE INTO rbac_user_assignments (username, role_id, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, username, roleID, now)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUserRoles replaces all roles for a user.
|
||||
func (m *SQLiteManager) UpdateUserRoles(username string, roleIDs []string) error {
|
||||
return m.UpdateUserRolesWithContext(username, roleIDs, "")
|
||||
}
|
||||
|
||||
// UpdateUserRolesWithContext replaces all roles for a user with audit context.
|
||||
func (m *SQLiteManager) UpdateUserRolesWithContext(username string, roleIDs []string, byUser string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Verify all roles exist
|
||||
for _, roleID := range roleIDs {
|
||||
var count int
|
||||
if err := m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE id = ?", roleID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("role not found: %s", roleID)
|
||||
}
|
||||
}
|
||||
|
||||
// Get old assignment for changelog
|
||||
oldAssignment := m.getUserAssignmentUnsafe(username)
|
||||
oldValueJSON, _ := json.Marshal(oldAssignment)
|
||||
|
||||
tx, err := m.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete existing assignments
|
||||
_, err = tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new assignments
|
||||
now := time.Now().Unix()
|
||||
for _, roleID := range roleIDs {
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO rbac_user_assignments (username, role_id, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, username, roleID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log change
|
||||
newAssignment := UserRoleAssignment{Username: username, RoleIDs: roleIDs, UpdatedAt: time.Unix(now, 0)}
|
||||
newValueJSON, _ := json.Marshal(newAssignment)
|
||||
m.logChangeUnsafe(ActionUserRolesUpdate, "assignment", username, string(oldValueJSON), string(newValueJSON), byUser)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRole removes a role from a user.
|
||||
func (m *SQLiteManager) RemoveRole(username string, roleID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, err := m.db.Exec(`
|
||||
DELETE FROM rbac_user_assignments
|
||||
WHERE username = ? AND role_id = ?
|
||||
`, username, roleID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserPermissions returns the effective permissions for a user.
|
||||
func (m *SQLiteManager) GetUserPermissions(username string) []Permission {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
assignment := m.getUserAssignmentUnsafe(username)
|
||||
if len(assignment.RoleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect unique permissions from all assigned roles
|
||||
permMap := make(map[string]Permission)
|
||||
for _, roleID := range assignment.RoleIDs {
|
||||
perms := m.loadRolePermissions(roleID)
|
||||
for _, perm := range perms {
|
||||
key := perm.Action + ":" + perm.Resource + ":" + perm.GetEffect()
|
||||
permMap[key] = perm
|
||||
}
|
||||
}
|
||||
|
||||
var perms []Permission
|
||||
for _, perm := range permMap {
|
||||
perms = append(perms, perm)
|
||||
}
|
||||
|
||||
return perms
|
||||
}
|
||||
|
||||
// GetRoleWithInheritance returns a role and all inherited permissions.
|
||||
func (m *SQLiteManager) GetRoleWithInheritance(id string) (Role, []Permission, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
role, exists := m.getRoleUnsafe(id)
|
||||
if !exists {
|
||||
return Role{}, nil, false
|
||||
}
|
||||
|
||||
// Collect all permissions including inherited
|
||||
allPerms := m.collectInheritedPermissions(id, make(map[string]bool))
|
||||
|
||||
return role, allPerms, true
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) collectInheritedPermissions(roleID string, visited map[string]bool) []Permission {
|
||||
if visited[roleID] {
|
||||
return nil // Circular reference protection
|
||||
}
|
||||
visited[roleID] = true
|
||||
|
||||
role, exists := m.getRoleUnsafe(roleID)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allPerms []Permission
|
||||
|
||||
// First, get parent permissions (inherited)
|
||||
if role.ParentID != "" {
|
||||
allPerms = m.collectInheritedPermissions(role.ParentID, visited)
|
||||
}
|
||||
|
||||
// Then add role's own permissions (can override parent)
|
||||
allPerms = append(allPerms, role.Permissions...)
|
||||
|
||||
return allPerms
|
||||
}
|
||||
|
||||
// checkCircularInheritanceUnsafe checks if setting parentID on roleID would create a cycle.
|
||||
// Must be called with lock held.
|
||||
func (m *SQLiteManager) checkCircularInheritanceUnsafe(roleID, parentID string) error {
|
||||
const maxDepth = 10
|
||||
visited := make(map[string]bool)
|
||||
visited[roleID] = true // The role we're updating
|
||||
|
||||
// Walk up the parent chain from parentID
|
||||
current := parentID
|
||||
for depth := 0; depth < maxDepth && current != ""; depth++ {
|
||||
if visited[current] {
|
||||
return fmt.Errorf("circular inheritance detected: %s -> %s creates a cycle", roleID, parentID)
|
||||
}
|
||||
visited[current] = true
|
||||
|
||||
// Get parent of current
|
||||
var parent *string
|
||||
err := m.db.QueryRow("SELECT parent_id FROM rbac_roles WHERE id = ?", current).Scan(&parent)
|
||||
if err != nil {
|
||||
break // Role doesn't exist or has no parent
|
||||
}
|
||||
if parent == nil {
|
||||
break
|
||||
}
|
||||
current = *parent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRolesWithInheritance returns user's roles with full inheritance chain.
|
||||
func (m *SQLiteManager) GetRolesWithInheritance(username string) []Role {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
assignment := m.getUserAssignmentUnsafe(username)
|
||||
if len(assignment.RoleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var roles []Role
|
||||
visited := make(map[string]bool)
|
||||
|
||||
for _, roleID := range assignment.RoleIDs {
|
||||
m.collectRoleChain(roleID, &roles, visited)
|
||||
}
|
||||
|
||||
return roles
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) collectRoleChain(roleID string, roles *[]Role, visited map[string]bool) {
|
||||
if visited[roleID] {
|
||||
return // Circular reference protection
|
||||
}
|
||||
visited[roleID] = true
|
||||
|
||||
role, exists := m.getRoleUnsafe(roleID)
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
// First collect parent
|
||||
if role.ParentID != "" {
|
||||
m.collectRoleChain(role.ParentID, roles, visited)
|
||||
}
|
||||
|
||||
*roles = append(*roles, role)
|
||||
}
|
||||
|
||||
// GetChangeLogs returns recent change logs.
|
||||
func (m *SQLiteManager) GetChangeLogs(limit int, offset int) []RBACChangeLog {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
rows, err := m.db.Query(`
|
||||
SELECT id, action, entity_type, entity_id, old_value, new_value, user, timestamp
|
||||
FROM rbac_changelog
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query change logs")
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return m.scanChangeLogs(rows)
|
||||
}
|
||||
|
||||
// GetChangeLogsForEntity returns change logs for a specific entity.
|
||||
func (m *SQLiteManager) GetChangeLogsForEntity(entityType, entityID string) []RBACChangeLog {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
rows, err := m.db.Query(`
|
||||
SELECT id, action, entity_type, entity_id, old_value, new_value, user, timestamp
|
||||
FROM rbac_changelog
|
||||
WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
`, entityType, entityID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query change logs")
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return m.scanChangeLogs(rows)
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) scanChangeLogs(rows *sql.Rows) []RBACChangeLog {
|
||||
var logs []RBACChangeLog
|
||||
for rows.Next() {
|
||||
var entry RBACChangeLog
|
||||
var oldValue, newValue, user sql.NullString
|
||||
var timestamp int64
|
||||
|
||||
if err := rows.Scan(&entry.ID, &entry.Action, &entry.EntityType, &entry.EntityID,
|
||||
&oldValue, &newValue, &user, ×tamp); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to scan change log")
|
||||
continue
|
||||
}
|
||||
|
||||
entry.OldValue = oldValue.String
|
||||
entry.NewValue = newValue.String
|
||||
entry.User = user.String
|
||||
entry.Timestamp = time.Unix(timestamp, 0)
|
||||
|
||||
logs = append(logs, entry)
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
func (m *SQLiteManager) logChangeUnsafe(action, entityType, entityID, oldValue, newValue, user string) {
|
||||
_, err := m.db.Exec(`
|
||||
INSERT INTO rbac_changelog (id, action, entity_type, entity_id, old_value, new_value, user, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), action, entityType, entityID, nullString(oldValue), nullString(newValue), nullString(user), time.Now().Unix())
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("action", action).Msg("Failed to log RBAC change")
|
||||
}
|
||||
}
|
||||
|
||||
// migrateFromFiles migrates data from file-based storage.
|
||||
func (m *SQLiteManager) migrateFromFiles(dataDir string) error {
|
||||
rolesFile := filepath.Join(dataDir, "rbac_roles.json")
|
||||
assignmentsFile := filepath.Join(dataDir, "rbac_assignments.json")
|
||||
|
||||
// Check if migration is needed
|
||||
var roleCount int
|
||||
m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE is_built_in = 0").Scan(&roleCount)
|
||||
if roleCount > 0 {
|
||||
return nil // Already have custom roles, skip migration
|
||||
}
|
||||
|
||||
// Migrate roles
|
||||
if data, err := os.ReadFile(rolesFile); err == nil {
|
||||
var roles []Role
|
||||
if err := json.Unmarshal(data, &roles); err == nil {
|
||||
for _, role := range roles {
|
||||
if !role.IsBuiltIn {
|
||||
if err := m.SaveRoleWithContext(role, "migration"); err != nil {
|
||||
log.Warn().Err(err).Str("roleId", role.ID).Msg("Failed to migrate role")
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Info().Int("count", len(roles)).Msg("Migrated roles from file")
|
||||
|
||||
// Rename old file
|
||||
os.Rename(rolesFile, rolesFile+".bak")
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate assignments
|
||||
if data, err := os.ReadFile(assignmentsFile); err == nil {
|
||||
var assignments []UserRoleAssignment
|
||||
if err := json.Unmarshal(data, &assignments); err == nil {
|
||||
for _, a := range assignments {
|
||||
if err := m.UpdateUserRolesWithContext(a.Username, a.RoleIDs, "migration"); err != nil {
|
||||
log.Warn().Err(err).Str("username", a.Username).Msg("Failed to migrate assignment")
|
||||
}
|
||||
}
|
||||
log.Info().Int("count", len(assignments)).Msg("Migrated assignments from file")
|
||||
|
||||
// Rename old file
|
||||
os.Rename(assignmentsFile, assignmentsFile+".bak")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to convert empty strings to nil for nullable columns
|
||||
func nullString(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
579
pkg/auth/sqlite_manager_test.go
Normal file
579
pkg/auth/sqlite_manager_test.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSQLiteManager(t *testing.T) {
|
||||
// Create temp directory for tests
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-sqlite-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{
|
||||
DataDir: tmpDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
t.Run("Built-in roles exist", func(t *testing.T) {
|
||||
roles := m.GetRoles()
|
||||
if len(roles) < 4 {
|
||||
t.Errorf("Expected at least 4 built-in roles, got %d", len(roles))
|
||||
}
|
||||
|
||||
// Check admin role exists
|
||||
admin, ok := m.GetRole(RoleAdmin)
|
||||
if !ok {
|
||||
t.Error("Admin role not found")
|
||||
}
|
||||
if !admin.IsBuiltIn {
|
||||
t.Error("Admin role should be built-in")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cannot delete built-in role", func(t *testing.T) {
|
||||
err := m.DeleteRole(RoleAdmin)
|
||||
if err == nil {
|
||||
t.Error("Expected error when deleting built-in role")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cannot modify built-in role", func(t *testing.T) {
|
||||
admin, _ := m.GetRole(RoleAdmin)
|
||||
admin.Name = "Modified Admin"
|
||||
err := m.SaveRole(admin)
|
||||
if err == nil {
|
||||
t.Error("Expected error when modifying built-in role")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create custom role with conditions", func(t *testing.T) {
|
||||
customRole := Role{
|
||||
ID: "custom-abac",
|
||||
Name: "Custom ABAC Role",
|
||||
Description: "A custom role with ABAC conditions",
|
||||
Permissions: []Permission{
|
||||
{Action: "read", Resource: "nodes", Effect: EffectAllow},
|
||||
{Action: "write", Resource: "nodes:production", Effect: EffectDeny},
|
||||
{
|
||||
Action: "read",
|
||||
Resource: "nodes:*",
|
||||
Effect: EffectAllow,
|
||||
Conditions: map[string]string{"tag": "test", "owner": "${user}"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := m.SaveRole(customRole); err != nil {
|
||||
t.Errorf("Failed to save custom role: %v", err)
|
||||
}
|
||||
|
||||
retrieved, ok := m.GetRole("custom-abac")
|
||||
if !ok {
|
||||
t.Error("Custom role not found after save")
|
||||
}
|
||||
if retrieved.Name != "Custom ABAC Role" {
|
||||
t.Errorf("Expected name 'Custom ABAC Role', got '%s'", retrieved.Name)
|
||||
}
|
||||
if len(retrieved.Permissions) != 3 {
|
||||
t.Errorf("Expected 3 permissions, got %d", len(retrieved.Permissions))
|
||||
}
|
||||
|
||||
// Check permission with conditions
|
||||
var foundCondPerm bool
|
||||
for _, p := range retrieved.Permissions {
|
||||
if p.Conditions != nil && p.Conditions["tag"] == "test" {
|
||||
foundCondPerm = true
|
||||
if p.Conditions["owner"] != "${user}" {
|
||||
t.Errorf("Expected owner condition '${user}', got '%s'", p.Conditions["owner"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundCondPerm {
|
||||
t.Error("Permission with conditions not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create role with inheritance", func(t *testing.T) {
|
||||
// First create parent role
|
||||
parentRole := Role{
|
||||
ID: "parent-role",
|
||||
Name: "Parent Role",
|
||||
Description: "A parent role",
|
||||
Permissions: []Permission{
|
||||
{Action: "read", Resource: "settings"},
|
||||
},
|
||||
}
|
||||
if err := m.SaveRole(parentRole); err != nil {
|
||||
t.Errorf("Failed to save parent role: %v", err)
|
||||
}
|
||||
|
||||
// Create child role
|
||||
childRole := Role{
|
||||
ID: "child-role",
|
||||
Name: "Child Role",
|
||||
Description: "A child role inheriting from parent",
|
||||
ParentID: "parent-role",
|
||||
Permissions: []Permission{
|
||||
{Action: "write", Resource: "settings"},
|
||||
},
|
||||
}
|
||||
if err := m.SaveRole(childRole); err != nil {
|
||||
t.Errorf("Failed to save child role: %v", err)
|
||||
}
|
||||
|
||||
// Get child with inheritance
|
||||
role, effectivePerms, ok := m.GetRoleWithInheritance("child-role")
|
||||
if !ok {
|
||||
t.Error("Child role not found")
|
||||
}
|
||||
if role.ParentID != "parent-role" {
|
||||
t.Errorf("Expected parent ID 'parent-role', got '%s'", role.ParentID)
|
||||
}
|
||||
|
||||
// Should have both own permissions and inherited permissions
|
||||
if len(effectivePerms) < 2 {
|
||||
t.Errorf("Expected at least 2 effective permissions, got %d", len(effectivePerms))
|
||||
}
|
||||
|
||||
var hasRead, hasWrite bool
|
||||
for _, p := range effectivePerms {
|
||||
if p.Action == "read" && p.Resource == "settings" {
|
||||
hasRead = true
|
||||
}
|
||||
if p.Action == "write" && p.Resource == "settings" {
|
||||
hasWrite = true
|
||||
}
|
||||
}
|
||||
if !hasRead {
|
||||
t.Error("Missing inherited read permission")
|
||||
}
|
||||
if !hasWrite {
|
||||
t.Error("Missing own write permission")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Assign roles to user", func(t *testing.T) {
|
||||
if err := m.AssignRole("testuser", RoleViewer); err != nil {
|
||||
t.Errorf("Failed to assign role: %v", err)
|
||||
}
|
||||
if err := m.AssignRole("testuser", "child-role"); err != nil {
|
||||
t.Errorf("Failed to assign child role: %v", err)
|
||||
}
|
||||
|
||||
assignment, ok := m.GetUserAssignment("testuser")
|
||||
if !ok {
|
||||
t.Error("User assignment not found")
|
||||
}
|
||||
if len(assignment.RoleIDs) != 2 {
|
||||
t.Errorf("Expected 2 roles, got %d", len(assignment.RoleIDs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get roles with inheritance for user", func(t *testing.T) {
|
||||
roles := m.GetRolesWithInheritance("testuser")
|
||||
// Should include viewer, child-role, and parent-role (inherited)
|
||||
if len(roles) < 3 {
|
||||
t.Errorf("Expected at least 3 roles with inheritance, got %d", len(roles))
|
||||
}
|
||||
|
||||
var hasParent bool
|
||||
for _, r := range roles {
|
||||
if r.ID == "parent-role" {
|
||||
hasParent = true
|
||||
}
|
||||
}
|
||||
if !hasParent {
|
||||
t.Error("Parent role should be included via inheritance")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get user permissions", func(t *testing.T) {
|
||||
perms := m.GetUserPermissions("testuser")
|
||||
if len(perms) == 0 {
|
||||
t.Error("Expected permissions for user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete custom role", func(t *testing.T) {
|
||||
if err := m.DeleteRole("custom-abac"); err != nil {
|
||||
t.Errorf("Failed to delete custom role: %v", err)
|
||||
}
|
||||
|
||||
_, ok := m.GetRole("custom-abac")
|
||||
if ok {
|
||||
t.Error("Custom role should not exist after delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Changelog is recorded", func(t *testing.T) {
|
||||
logs := m.GetChangeLogs(100, 0)
|
||||
if len(logs) == 0 {
|
||||
t.Error("Expected changelog entries")
|
||||
}
|
||||
|
||||
// Should have entries for role creation
|
||||
var hasRoleCreated bool
|
||||
for _, l := range logs {
|
||||
if l.Action == ActionRoleCreated {
|
||||
hasRoleCreated = true
|
||||
}
|
||||
}
|
||||
if !hasRoleCreated {
|
||||
t.Error("Missing role_created changelog entry")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get changelog for entity", func(t *testing.T) {
|
||||
logs := m.GetChangeLogsForEntity("role", "parent-role")
|
||||
if len(logs) == 0 {
|
||||
t.Error("Expected changelog entries for parent-role")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteManagerMigration(t *testing.T) {
|
||||
// Create temp directory for tests
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-migration-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// First create file-based manager with some data
|
||||
fileManager, err := NewFileManager(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create FileManager: %v", err)
|
||||
}
|
||||
|
||||
// Add custom role and assignment
|
||||
customRole := Role{
|
||||
ID: "migrate-test",
|
||||
Name: "Migration Test Role",
|
||||
Description: "Should be migrated",
|
||||
Permissions: []Permission{{Action: "read", Resource: "alerts"}},
|
||||
}
|
||||
if err := fileManager.SaveRole(customRole); err != nil {
|
||||
t.Fatalf("Failed to save role in FileManager: %v", err)
|
||||
}
|
||||
if err := fileManager.AssignRole("migrateuser", "migrate-test"); err != nil {
|
||||
t.Fatalf("Failed to assign role in FileManager: %v", err)
|
||||
}
|
||||
|
||||
// Now create SQLite manager with migration enabled
|
||||
sqliteManager, err := NewSQLiteManager(SQLiteManagerConfig{
|
||||
DataDir: tmpDir,
|
||||
MigrateFromFiles: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer sqliteManager.Close()
|
||||
|
||||
// Check custom role was migrated
|
||||
t.Run("Custom role migrated", func(t *testing.T) {
|
||||
role, ok := sqliteManager.GetRole("migrate-test")
|
||||
if !ok {
|
||||
t.Error("Custom role should be migrated to SQLite")
|
||||
}
|
||||
if role.Name != "Migration Test Role" {
|
||||
t.Errorf("Expected 'Migration Test Role', got '%s'", role.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// Check assignment was migrated
|
||||
t.Run("User assignment migrated", func(t *testing.T) {
|
||||
assignment, ok := sqliteManager.GetUserAssignment("migrateuser")
|
||||
if !ok {
|
||||
t.Error("User assignment should be migrated to SQLite")
|
||||
}
|
||||
hasRole := false
|
||||
for _, rid := range assignment.RoleIDs {
|
||||
if rid == "migrate-test" {
|
||||
hasRole = true
|
||||
}
|
||||
}
|
||||
if !hasRole {
|
||||
t.Error("User should have migrated role")
|
||||
}
|
||||
})
|
||||
|
||||
// Check backup files created
|
||||
t.Run("Backup files created", func(t *testing.T) {
|
||||
rolesBackup := filepath.Join(tmpDir, "rbac_roles.json.bak")
|
||||
if _, err := os.Stat(rolesBackup); os.IsNotExist(err) {
|
||||
t.Error("Roles backup file should be created")
|
||||
}
|
||||
assignmentsBackup := filepath.Join(tmpDir, "rbac_assignments.json.bak")
|
||||
if _, err := os.Stat(assignmentsBackup); os.IsNotExist(err) {
|
||||
t.Error("Assignments backup file should be created")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteManagerCircularInheritance(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-circular-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
// Create role A
|
||||
roleA := Role{ID: "role-a", Name: "Role A", Permissions: []Permission{{Action: "read", Resource: "a"}}}
|
||||
m.SaveRole(roleA)
|
||||
|
||||
// Create role B with parent A
|
||||
roleB := Role{ID: "role-b", Name: "Role B", ParentID: "role-a", Permissions: []Permission{{Action: "read", Resource: "b"}}}
|
||||
m.SaveRole(roleB)
|
||||
|
||||
// Create role C with parent B
|
||||
roleC := Role{ID: "role-c", Name: "Role C", ParentID: "role-b", Permissions: []Permission{{Action: "read", Resource: "c"}}}
|
||||
m.SaveRole(roleC)
|
||||
|
||||
// Try to make A inherit from C (creating cycle)
|
||||
roleA.ParentID = "role-c"
|
||||
err = m.SaveRole(roleA)
|
||||
if err == nil {
|
||||
t.Error("Expected error when creating circular inheritance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteManagerContextOperations(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-context-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
t.Run("SaveRoleWithContext records user", func(t *testing.T) {
|
||||
role := Role{
|
||||
ID: "context-role",
|
||||
Name: "Context Test Role",
|
||||
Permissions: []Permission{{Action: "read", Resource: "test"}},
|
||||
}
|
||||
if err := m.SaveRoleWithContext(role, "admin-user"); err != nil {
|
||||
t.Errorf("Failed to save role with context: %v", err)
|
||||
}
|
||||
|
||||
// Check changelog has user
|
||||
logs := m.GetChangeLogsForEntity("role", "context-role")
|
||||
if len(logs) == 0 {
|
||||
t.Fatal("Expected changelog entry")
|
||||
}
|
||||
if logs[0].User != "admin-user" {
|
||||
t.Errorf("Expected user 'admin-user', got '%s'", logs[0].User)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteRoleWithContext records user", func(t *testing.T) {
|
||||
if err := m.DeleteRoleWithContext("context-role", "admin-user"); err != nil {
|
||||
t.Errorf("Failed to delete role with context: %v", err)
|
||||
}
|
||||
|
||||
logs := m.GetChangeLogsForEntity("role", "context-role")
|
||||
var hasDelete bool
|
||||
for _, l := range logs {
|
||||
if l.Action == ActionRoleDeleted && l.User == "admin-user" {
|
||||
hasDelete = true
|
||||
}
|
||||
}
|
||||
if !hasDelete {
|
||||
t.Error("Missing delete changelog with user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdateUserRolesWithContext records user", func(t *testing.T) {
|
||||
if err := m.UpdateUserRolesWithContext("context-user", []string{RoleViewer}, "admin-user"); err != nil {
|
||||
t.Errorf("Failed to update user roles with context: %v", err)
|
||||
}
|
||||
|
||||
logs := m.GetChangeLogsForEntity("assignment", "context-user")
|
||||
if len(logs) == 0 {
|
||||
t.Fatal("Expected changelog entry")
|
||||
}
|
||||
if logs[0].User != "admin-user" {
|
||||
t.Errorf("Expected user 'admin-user', got '%s'", logs[0].User)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteManagerPersistence(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-persist-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create manager and add data
|
||||
m1, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
|
||||
role := Role{
|
||||
ID: "persist-role",
|
||||
Name: "Persist Test",
|
||||
ParentID: RoleViewer,
|
||||
Permissions: []Permission{{Action: "write", Resource: "persist", Effect: EffectAllow}},
|
||||
}
|
||||
m1.SaveRole(role)
|
||||
m1.AssignRole("persist-user", "persist-role")
|
||||
m1.Close()
|
||||
|
||||
// Reopen and verify
|
||||
m2, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reopen SQLiteManager: %v", err)
|
||||
}
|
||||
defer m2.Close()
|
||||
|
||||
t.Run("Role persisted", func(t *testing.T) {
|
||||
r, ok := m2.GetRole("persist-role")
|
||||
if !ok {
|
||||
t.Error("Role should persist")
|
||||
}
|
||||
if r.ParentID != RoleViewer {
|
||||
t.Errorf("Expected parent %s, got %s", RoleViewer, r.ParentID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Assignment persisted", func(t *testing.T) {
|
||||
a, ok := m2.GetUserAssignment("persist-user")
|
||||
if !ok {
|
||||
t.Error("Assignment should persist")
|
||||
}
|
||||
if len(a.RoleIDs) != 1 {
|
||||
t.Errorf("Expected 1 role, got %d", len(a.RoleIDs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Changelog persisted", func(t *testing.T) {
|
||||
logs := m2.GetChangeLogs(100, 0)
|
||||
if len(logs) == 0 {
|
||||
t.Error("Changelog should persist")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteManagerDenyPermission(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-deny-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
// Create role with both allow and deny permissions
|
||||
role := Role{
|
||||
ID: "deny-test",
|
||||
Name: "Deny Test Role",
|
||||
Permissions: []Permission{
|
||||
{Action: "read", Resource: "*", Effect: EffectAllow}, // Allow all reads
|
||||
{Action: "read", Resource: "secrets", Effect: EffectDeny}, // But deny reading secrets
|
||||
{Action: "write", Resource: "settings", Effect: EffectAllow}, // Allow writing settings
|
||||
{Action: "write", Resource: "settings:admin", Effect: EffectDeny}, // But deny admin settings
|
||||
},
|
||||
}
|
||||
if err := m.SaveRole(role); err != nil {
|
||||
t.Fatalf("Failed to save role: %v", err)
|
||||
}
|
||||
|
||||
retrieved, ok := m.GetRole("deny-test")
|
||||
if !ok {
|
||||
t.Fatal("Role not found")
|
||||
}
|
||||
|
||||
// Verify deny effects are preserved
|
||||
var denyCount, allowCount int
|
||||
for _, p := range retrieved.Permissions {
|
||||
if p.GetEffect() == EffectDeny {
|
||||
denyCount++
|
||||
} else {
|
||||
allowCount++
|
||||
}
|
||||
}
|
||||
if denyCount != 2 {
|
||||
t.Errorf("Expected 2 deny permissions, got %d", denyCount)
|
||||
}
|
||||
if allowCount != 2 {
|
||||
t.Errorf("Expected 2 allow permissions, got %d", allowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteManagerChangeLogRetention(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "rbac-retention-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
m, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SQLiteManager: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
// Create several roles to generate changelog entries
|
||||
for i := 0; i < 5; i++ {
|
||||
role := Role{
|
||||
ID: "retention-role-" + string(rune('a'+i)),
|
||||
Name: "Retention Test " + string(rune('a'+i)),
|
||||
Permissions: []Permission{{Action: "read", Resource: "test"}},
|
||||
}
|
||||
m.SaveRole(role)
|
||||
// Add slight delay to ensure different timestamps
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Run("Pagination works", func(t *testing.T) {
|
||||
// Get first 2
|
||||
logs1 := m.GetChangeLogs(2, 0)
|
||||
if len(logs1) != 2 {
|
||||
t.Errorf("Expected 2 logs, got %d", len(logs1))
|
||||
}
|
||||
|
||||
// Get next 2
|
||||
logs2 := m.GetChangeLogs(2, 2)
|
||||
if len(logs2) != 2 {
|
||||
t.Errorf("Expected 2 logs, got %d", len(logs2))
|
||||
}
|
||||
|
||||
// Ensure they're different
|
||||
if len(logs1) > 0 && len(logs2) > 0 && logs1[0].ID == logs2[0].ID {
|
||||
t.Error("Pagination returned same entries")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Logs ordered by timestamp desc", func(t *testing.T) {
|
||||
logs := m.GetChangeLogs(100, 0)
|
||||
for i := 1; i < len(logs); i++ {
|
||||
if logs[i].Timestamp.After(logs[i-1].Timestamp) {
|
||||
t.Error("Logs should be ordered by timestamp descending")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user