From 7b746643c7937c8b600f05a6efa5638e334d4e9f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 28 Dec 2025 08:28:22 +0100 Subject: [PATCH] feat: add RBAC with permission model and management APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RBAC System (internal/auth/): - Permission constants for all resources (dashboard, agents, alerts, etc.) - Wildcard permission support ("*" for admin, "category:*" for groups) - Authorizer service with role-based permission checking - RequirePermission middleware for route protection Role Management API: - GET /roles - List all roles - GET /roles/:id - Get role details - POST /roles - Create custom role - PUT /roles/:id - Update role (custom only) - DELETE /roles/:id - Delete role (custom only) User Management API (admin): - GET /users - List all users - GET /users/:id - Get user details - GET /users/:id/roles - Get user's roles - POST /users - Create new user - PUT /users/:id - Update user profile - DELETE /users/:id - Disable user account - POST /users/:id/enable - Re-enable user - POST /users/:id/reset-password - Reset password - PUT /users/:id/roles - Assign roles to user Built-in Roles (via database migrations): - admin: Full access (*) - operator: Agent and alert management - viewer: Read-only dashboard access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/internal/auth/rbac.go | 243 ++++++++++++++++++++ backend/internal/auth/roles_api.go | 242 ++++++++++++++++++++ backend/internal/auth/users_api.go | 342 +++++++++++++++++++++++++++++ 3 files changed, 827 insertions(+) create mode 100644 backend/internal/auth/rbac.go create mode 100644 backend/internal/auth/roles_api.go create mode 100644 backend/internal/auth/users_api.go diff --git a/backend/internal/auth/rbac.go b/backend/internal/auth/rbac.go new file mode 100644 index 0000000..97ef135 --- /dev/null +++ b/backend/internal/auth/rbac.go @@ -0,0 +1,243 @@ +package auth + +import ( + "context" + "net/http" + "strings" + + "tyto/internal/database" + + "github.com/gin-gonic/gin" +) + +// Permission represents a single permission. +type Permission string + +// Permission constants for Tyto resources. +const ( + // Dashboard permissions + PermDashboardView Permission = "dashboard:view" + + // Agent permissions + PermAgentsView Permission = "agents:view" + PermAgentsManage Permission = "agents:manage" // Add, remove, approve + + // Alert permissions + PermAlertsView Permission = "alerts:view" + PermAlertsAcknowledge Permission = "alerts:acknowledge" + PermAlertsConfigure Permission = "alerts:configure" + + // Metrics permissions + PermMetricsQuery Permission = "metrics:query" + PermMetricsExport Permission = "metrics:export" + + // User permissions + PermUsersView Permission = "users:view" + PermUsersManage Permission = "users:manage" // Create, update, disable + + // Role permissions + PermRolesView Permission = "roles:view" + PermRolesManage Permission = "roles:manage" + + // Settings permissions + PermSettingsView Permission = "settings:view" + PermSettingsManage Permission = "settings:manage" + + // PKI permissions + PermPKIManage Permission = "pki:manage" + + // Audit log permissions + PermAuditView Permission = "audit:view" +) + +// AllPermissions returns all defined permissions. +func AllPermissions() []Permission { + return []Permission{ + PermDashboardView, + PermAgentsView, + PermAgentsManage, + PermAlertsView, + PermAlertsAcknowledge, + PermAlertsConfigure, + PermMetricsQuery, + PermMetricsExport, + PermUsersView, + PermUsersManage, + PermRolesView, + PermRolesManage, + PermSettingsView, + PermSettingsManage, + PermPKIManage, + PermAuditView, + } +} + +// Authorizer provides authorization checking. +type Authorizer struct { + db database.Database +} + +// NewAuthorizer creates a new authorizer. +func NewAuthorizer(db database.Database) *Authorizer { + return &Authorizer{db: db} +} + +// HasPermission checks if a user has a specific permission. +func (a *Authorizer) HasPermission(ctx context.Context, userID string, perm Permission) bool { + roles, err := a.db.GetUserRoles(ctx, userID) + if err != nil { + return false + } + + return a.checkRolesPermission(roles, perm) +} + +// HasAnyPermission checks if a user has any of the specified permissions. +func (a *Authorizer) HasAnyPermission(ctx context.Context, userID string, perms ...Permission) bool { + roles, err := a.db.GetUserRoles(ctx, userID) + if err != nil { + return false + } + + for _, perm := range perms { + if a.checkRolesPermission(roles, perm) { + return true + } + } + return false +} + +// HasAllPermissions checks if a user has all specified permissions. +func (a *Authorizer) HasAllPermissions(ctx context.Context, userID string, perms ...Permission) bool { + roles, err := a.db.GetUserRoles(ctx, userID) + if err != nil { + return false + } + + for _, perm := range perms { + if !a.checkRolesPermission(roles, perm) { + return false + } + } + return true +} + +// GetUserPermissions returns all permissions for a user. +func (a *Authorizer) GetUserPermissions(ctx context.Context, userID string) []Permission { + roles, err := a.db.GetUserRoles(ctx, userID) + if err != nil { + return nil + } + + permSet := make(map[Permission]bool) + for _, role := range roles { + for _, p := range role.Permissions { + if p == "*" { + // Wildcard - return all permissions + return AllPermissions() + } + permSet[Permission(p)] = true + } + } + + perms := make([]Permission, 0, len(permSet)) + for p := range permSet { + perms = append(perms, p) + } + return perms +} + +// checkRolesPermission checks if any of the roles grant the permission. +func (a *Authorizer) checkRolesPermission(roles []*database.Role, perm Permission) bool { + permStr := string(perm) + + for _, role := range roles { + for _, p := range role.Permissions { + // Exact match + if p == permStr { + return true + } + + // Wildcard match: "*" matches everything + if p == "*" { + return true + } + + // Category wildcard: "agents:*" matches "agents:view", "agents:manage" + if strings.HasSuffix(p, ":*") { + prefix := strings.TrimSuffix(p, "*") + if strings.HasPrefix(permStr, prefix) { + return true + } + } + } + } + + return false +} + +// RequirePermission returns middleware that requires a specific permission. +func (a *Authorizer) RequirePermission(perm Permission) gin.HandlerFunc { + return func(c *gin.Context) { + user := GetUser(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "authentication required", + }) + return + } + + if !a.HasPermission(c.Request.Context(), user.ID, perm) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "insufficient permissions", + }) + return + } + + c.Next() + } +} + +// RequireAnyPermission returns middleware that requires any of the specified permissions. +func (a *Authorizer) RequireAnyPermission(perms ...Permission) gin.HandlerFunc { + return func(c *gin.Context) { + user := GetUser(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "authentication required", + }) + return + } + + if !a.HasAnyPermission(c.Request.Context(), user.ID, perms...) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "insufficient permissions", + }) + return + } + + c.Next() + } +} + +// RequireAllPermissions returns middleware that requires all specified permissions. +func (a *Authorizer) RequireAllPermissions(perms ...Permission) gin.HandlerFunc { + return func(c *gin.Context) { + user := GetUser(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "authentication required", + }) + return + } + + if !a.HasAllPermissions(c.Request.Context(), user.ID, perms...) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "insufficient permissions", + }) + return + } + + c.Next() + } +} diff --git a/backend/internal/auth/roles_api.go b/backend/internal/auth/roles_api.go new file mode 100644 index 0000000..489551b --- /dev/null +++ b/backend/internal/auth/roles_api.go @@ -0,0 +1,242 @@ +package auth + +import ( + "net/http" + "time" + + "tyto/internal/database" + + "github.com/gin-gonic/gin" +) + +// RolesAPI provides HTTP handlers for role management. +type RolesAPI struct { + db database.Database + auth *Authorizer +} + +// NewRolesAPI creates a new roles API handler. +func NewRolesAPI(db database.Database, auth *Authorizer) *RolesAPI { + return &RolesAPI{ + db: db, + auth: auth, + } +} + +// RegisterRoutes registers role management routes. +func (a *RolesAPI) RegisterRoutes(group *gin.RouterGroup, authMiddleware *Middleware) { + roles := group.Group("/roles") + roles.Use(authMiddleware.Required()) + { + // View roles (requires roles:view) + roles.GET("", a.auth.RequirePermission(PermRolesView), a.handleListRoles) + roles.GET("/:id", a.auth.RequirePermission(PermRolesView), a.handleGetRole) + + // Manage roles (requires roles:manage) + roles.POST("", a.auth.RequirePermission(PermRolesManage), a.handleCreateRole) + roles.PUT("/:id", a.auth.RequirePermission(PermRolesManage), a.handleUpdateRole) + roles.DELETE("/:id", a.auth.RequirePermission(PermRolesManage), a.handleDeleteRole) + } +} + +// RoleResponse represents a role in API responses. +type RoleResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Permissions []string `json:"permissions"` + IsSystem bool `json:"isSystem"` + CreatedAt string `json:"createdAt"` +} + +func roleToResponse(r *database.Role) RoleResponse { + return RoleResponse{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + Permissions: r.Permissions, + IsSystem: r.IsSystem, + CreatedAt: r.CreatedAt.Format(time.RFC3339), + } +} + +func (a *RolesAPI) handleListRoles(c *gin.Context) { + roles, err := a.db.ListRoles(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list roles"}) + return + } + + response := make([]RoleResponse, len(roles)) + for i, r := range roles { + response[i] = roleToResponse(r) + } + + c.JSON(http.StatusOK, response) +} + +func (a *RolesAPI) handleGetRole(c *gin.Context) { + id := c.Param("id") + + role, err := a.db.GetRole(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get role"}) + return + } + + if role == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "role not found"}) + return + } + + c.JSON(http.StatusOK, roleToResponse(role)) +} + +// CreateRoleRequest represents a role creation request. +type CreateRoleRequest struct { + Name string `json:"name" binding:"required,min=2,max=64"` + Description string `json:"description"` + Permissions []string `json:"permissions" binding:"required,min=1"` +} + +func (a *RolesAPI) handleCreateRole(c *gin.Context) { + var req CreateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Validate permissions + if !a.validatePermissions(req.Permissions) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid permissions"}) + return + } + + role := &database.Role{ + ID: generateID(), + Name: req.Name, + Description: req.Description, + Permissions: req.Permissions, + IsSystem: false, + CreatedAt: time.Now().UTC(), + } + + if err := a.db.CreateRole(c.Request.Context(), role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create role"}) + return + } + + c.JSON(http.StatusCreated, roleToResponse(role)) +} + +// UpdateRoleRequest represents a role update request. +type UpdateRoleRequest struct { + Name string `json:"name" binding:"required,min=2,max=64"` + Description string `json:"description"` + Permissions []string `json:"permissions" binding:"required,min=1"` +} + +func (a *RolesAPI) handleUpdateRole(c *gin.Context) { + id := c.Param("id") + + role, err := a.db.GetRole(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get role"}) + return + } + + if role == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "role not found"}) + return + } + + if role.IsSystem { + c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify system role"}) + return + } + + var req UpdateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Validate permissions + if !a.validatePermissions(req.Permissions) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid permissions"}) + return + } + + role.Name = req.Name + role.Description = req.Description + role.Permissions = req.Permissions + + if err := a.db.UpdateRole(c.Request.Context(), role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) + return + } + + c.JSON(http.StatusOK, roleToResponse(role)) +} + +func (a *RolesAPI) handleDeleteRole(c *gin.Context) { + id := c.Param("id") + + role, err := a.db.GetRole(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get role"}) + return + } + + if role == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "role not found"}) + return + } + + if role.IsSystem { + c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete system role"}) + return + } + + if err := a.db.DeleteRole(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete role"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "role deleted"}) +} + +// validatePermissions checks if all permissions are valid. +func (a *RolesAPI) validatePermissions(perms []string) bool { + validPerms := make(map[string]bool) + for _, p := range AllPermissions() { + validPerms[string(p)] = true + } + validPerms["*"] = true // Wildcard is valid + + for _, p := range perms { + // Check exact match + if validPerms[p] { + continue + } + + // Check category wildcard (e.g., "agents:*") + if len(p) > 2 && p[len(p)-2:] == ":*" { + prefix := p[:len(p)-1] + found := false + for valid := range validPerms { + if len(valid) > len(prefix) && valid[:len(prefix)] == prefix { + found = true + break + } + } + if found { + continue + } + } + + return false + } + + return true +} diff --git a/backend/internal/auth/users_api.go b/backend/internal/auth/users_api.go new file mode 100644 index 0000000..7d4f88d --- /dev/null +++ b/backend/internal/auth/users_api.go @@ -0,0 +1,342 @@ +package auth + +import ( + "context" + "net/http" + "time" + + "tyto/internal/database" + + "github.com/gin-gonic/gin" +) + +// UsersAPI provides HTTP handlers for user management (admin). +type UsersAPI struct { + db database.Database + auth *Service + authz *Authorizer +} + +// NewUsersAPI creates a new users API handler. +func NewUsersAPI(db database.Database, auth *Service, authz *Authorizer) *UsersAPI { + return &UsersAPI{ + db: db, + auth: auth, + authz: authz, + } +} + +// RegisterRoutes registers user management routes. +func (a *UsersAPI) RegisterRoutes(group *gin.RouterGroup, authMiddleware *Middleware) { + users := group.Group("/users") + users.Use(authMiddleware.Required()) + { + // View users (requires users:view) + users.GET("", a.authz.RequirePermission(PermUsersView), a.handleListUsers) + users.GET("/:id", a.authz.RequirePermission(PermUsersView), a.handleGetUser) + users.GET("/:id/roles", a.authz.RequirePermission(PermUsersView), a.handleGetUserRoles) + + // Manage users (requires users:manage) + users.POST("", a.authz.RequirePermission(PermUsersManage), a.handleCreateUser) + users.PUT("/:id", a.authz.RequirePermission(PermUsersManage), a.handleUpdateUser) + users.DELETE("/:id", a.authz.RequirePermission(PermUsersManage), a.handleDisableUser) + users.POST("/:id/enable", a.authz.RequirePermission(PermUsersManage), a.handleEnableUser) + users.POST("/:id/reset-password", a.authz.RequirePermission(PermUsersManage), a.handleResetPassword) + users.PUT("/:id/roles", a.authz.RequirePermission(PermUsersManage), a.handleSetUserRoles) + } +} + +// AdminUserResponse represents a user in admin API responses. +type AdminUserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + AuthProvider string `json:"authProvider"` + Roles []string `json:"roles,omitempty"` + Disabled bool `json:"disabled"` + CreatedAt string `json:"createdAt"` + LastLogin string `json:"lastLogin,omitempty"` +} + +func (a *UsersAPI) userToResponse(ctx context.Context, u *database.User) AdminUserResponse { + resp := AdminUserResponse{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + AuthProvider: string(u.AuthProvider), + Disabled: u.Disabled, + CreatedAt: u.CreatedAt.Format(time.RFC3339), + } + + if !u.LastLogin.IsZero() { + resp.LastLogin = u.LastLogin.Format(time.RFC3339) + } + + // Get roles + roles, _ := a.db.GetUserRoles(ctx, u.ID) + resp.Roles = make([]string, len(roles)) + for i, r := range roles { + resp.Roles[i] = r.ID + } + + return resp +} + +func (a *UsersAPI) handleListUsers(c *gin.Context) { + users, err := a.db.ListUsers(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) + return + } + + response := make([]AdminUserResponse, len(users)) + for i, u := range users { + response[i] = a.userToResponse(c.Request.Context(), u) + } + + c.JSON(http.StatusOK, response) +} + +func (a *UsersAPI) handleGetUser(c *gin.Context) { + id := c.Param("id") + + user, err := a.db.GetUser(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"}) + return + } + + if user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, a.userToResponse(c.Request.Context(), user)) +} + +func (a *UsersAPI) handleGetUserRoles(c *gin.Context) { + id := c.Param("id") + + roles, err := a.db.GetUserRoles(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user roles"}) + return + } + + response := make([]RoleResponse, len(roles)) + for i, r := range roles { + response[i] = roleToResponse(r) + } + + c.JSON(http.StatusOK, response) +} + +// CreateUserRequest represents a user creation request. +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=32"` + Password string `json:"password" binding:"omitempty,min=8"` + Email string `json:"email" binding:"omitempty,email"` + Roles []string `json:"roles"` + Disabled bool `json:"disabled"` +} + +func (a *UsersAPI) handleCreateUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + user, err := a.auth.CreateUser(c.Request.Context(), req.Username, req.Password, req.Email, req.Disabled) + if err != nil { + if err == ErrUsernameExists { + c.JSON(http.StatusConflict, gin.H{"error": "username already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) + return + } + + // Assign roles + for _, roleID := range req.Roles { + a.db.AssignRole(c.Request.Context(), user.ID, roleID) + } + + c.JSON(http.StatusCreated, a.userToResponse(c.Request.Context(), user)) +} + +// AdminUpdateUserRequest represents a user update request from admin. +type AdminUpdateUserRequest struct { + Email string `json:"email" binding:"omitempty,email"` + Disabled bool `json:"disabled"` +} + +func (a *UsersAPI) handleUpdateUser(c *gin.Context) { + id := c.Param("id") + + user, err := a.db.GetUser(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"}) + return + } + + if user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + var req AdminUpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + user.Email = req.Email + + // Handle disable state change + if req.Disabled && !user.Disabled { + // Disabling user - invalidate sessions + if err := a.auth.DisableUser(c.Request.Context(), user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable user"}) + return + } + } else if !req.Disabled && user.Disabled { + // Enabling user + if err := a.auth.EnableUser(c.Request.Context(), user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable user"}) + return + } + } else { + // Just update profile + if err := a.auth.UpdateUser(c.Request.Context(), user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"}) + return + } + } + + // Refresh user data + user, _ = a.db.GetUser(c.Request.Context(), id) + c.JSON(http.StatusOK, a.userToResponse(c.Request.Context(), user)) +} + +func (a *UsersAPI) handleDisableUser(c *gin.Context) { + id := c.Param("id") + + // Prevent self-disable + currentUser := GetUser(c) + if currentUser != nil && currentUser.ID == id { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot disable your own account"}) + return + } + + if err := a.auth.DisableUser(c.Request.Context(), id); err != nil { + if err == ErrUserNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "user disabled"}) +} + +func (a *UsersAPI) handleEnableUser(c *gin.Context) { + id := c.Param("id") + + if err := a.auth.EnableUser(c.Request.Context(), id); err != nil { + if err == ErrUserNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "user enabled"}) +} + +// ResetPasswordRequest represents a password reset request. +type ResetPasswordRequest struct { + NewPassword string `json:"newPassword" binding:"required,min=8"` +} + +func (a *UsersAPI) handleResetPassword(c *gin.Context) { + id := c.Param("id") + + var req ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + if err := a.auth.ResetPassword(c.Request.Context(), id, req.NewPassword); err != nil { + if err == ErrUserNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "password reset"}) +} + +// SetUserRolesRequest represents a role assignment request. +type SetUserRolesRequest struct { + Roles []string `json:"roles" binding:"required"` +} + +func (a *UsersAPI) handleSetUserRoles(c *gin.Context) { + id := c.Param("id") + + user, err := a.db.GetUser(c.Request.Context(), id) + if err != nil || user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + var req SetUserRolesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Validate roles exist + for _, roleID := range req.Roles { + role, err := a.db.GetRole(c.Request.Context(), roleID) + if err != nil || role == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role: " + roleID}) + return + } + } + + // Get current roles + currentRoles, _ := a.db.GetUserRoles(c.Request.Context(), id) + currentRoleIDs := make(map[string]bool) + for _, r := range currentRoles { + currentRoleIDs[r.ID] = true + } + + // Build target role set + targetRoleIDs := make(map[string]bool) + for _, roleID := range req.Roles { + targetRoleIDs[roleID] = true + } + + // Remove roles not in target + for roleID := range currentRoleIDs { + if !targetRoleIDs[roleID] { + a.db.RemoveRole(c.Request.Context(), id, roleID) + } + } + + // Add roles not in current + for roleID := range targetRoleIDs { + if !currentRoleIDs[roleID] { + a.db.AssignRole(c.Request.Context(), id, roleID) + } + } + + c.JSON(http.StatusOK, gin.H{"message": "roles updated"}) +}