feat: add RBAC with permission model and management APIs
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 <noreply@anthropic.com>
This commit is contained in:
243
backend/internal/auth/rbac.go
Normal file
243
backend/internal/auth/rbac.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
242
backend/internal/auth/roles_api.go
Normal file
242
backend/internal/auth/roles_api.go
Normal file
@@ -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
|
||||
}
|
||||
342
backend/internal/auth/users_api.go
Normal file
342
backend/internal/auth/users_api.go
Normal file
@@ -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"})
|
||||
}
|
||||
Reference in New Issue
Block a user