Files
tyto/backend/internal/auth/users_api.go
vikingowl 7b746643c7 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>
2025-12-28 08:28:22 +01:00

343 lines
9.5 KiB
Go

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"})
}