6fdf9a9d54
Roles: - Add GET /api/v1/roles (built-in SuperAdmin/Admin/Operator/Viewer) and wire role assignment into user create/update (UserRepository.SetRoles / GetRoleNames / ListRoles). User responses now include roles. - User dialog gains a roles multi-select; the users list shows role chips. OIDC / SSO config UI: - Default role is now a dropdown populated from /roles. - Issuer URL shows real provider examples (Entra/Okta/Google). - Replace the confusing manual "Redirect URL" field with a read-only, auto-derived callback URL (from the browser origin / public URL) plus a copy button — the exact value to register at the IdP. The backend still auto-derives the callback and honors an env override. Verified: /roles lists the four roles; creating/updating a user with roles round-trips through GET. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
268 lines
8.7 KiB
Go
268 lines
8.7 KiB
Go
// Package api - Users handlers
|
|
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// UsersHandler handles user administration API endpoints
|
|
type UsersHandler struct {
|
|
repo *repository.UserRepository
|
|
auditService *audit.Service
|
|
logger *logging.Logger
|
|
}
|
|
|
|
// NewUsersHandler creates a new UsersHandler
|
|
func NewUsersHandler(repo *repository.UserRepository, auditService *audit.Service, logger *logging.Logger) *UsersHandler {
|
|
return &UsersHandler{repo: repo, auditService: auditService, logger: logger}
|
|
}
|
|
|
|
// UserRequest represents a user create/update request. Roles, when present,
|
|
// replace the user's role assignments.
|
|
type UserRequest struct {
|
|
Username string `json:"username"`
|
|
Email *string `json:"email,omitempty"`
|
|
DisplayName *string `json:"displayName,omitempty"`
|
|
Password *string `json:"password,omitempty"`
|
|
IsActive *bool `json:"isActive,omitempty"`
|
|
Roles *[]string `json:"roles,omitempty"`
|
|
}
|
|
|
|
// UserResponse represents a user in responses (no password material)
|
|
type UserResponse struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email *string `json:"email,omitempty"`
|
|
DisplayName *string `json:"displayName,omitempty"`
|
|
IsActive bool `json:"isActive"`
|
|
IsOIDCUser bool `json:"isOidcUser"`
|
|
Roles []string `json:"roles"`
|
|
LastLoginUTC *string `json:"lastLoginUtc,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// RoleResponse is a role offered for assignment.
|
|
type RoleResponse struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// ListRoles handles GET /api/v1/roles — the assignable roles.
|
|
func (h *UsersHandler) ListRoles(w http.ResponseWriter, r *http.Request) {
|
|
roles, err := h.repo.ListRoles()
|
|
if err != nil {
|
|
h.logger.Error("UsersHandler", "ListRoles failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list roles")
|
|
return
|
|
}
|
|
resp := make([]RoleResponse, 0, len(roles))
|
|
for _, ro := range roles {
|
|
rr := RoleResponse{Name: ro.Name}
|
|
if ro.Description != nil {
|
|
rr.Description = *ro.Description
|
|
}
|
|
resp = append(resp, rr)
|
|
}
|
|
WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// List handles GET /api/v1/users
|
|
func (h *UsersHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
p := ParsePagination(r)
|
|
offset := (p.Page - 1) * p.PageSize
|
|
users, total, err := h.repo.List(offset, p.PageSize)
|
|
if err != nil {
|
|
h.logger.Error("UsersHandler", "List failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list users")
|
|
return
|
|
}
|
|
resp := make([]UserResponse, 0, len(users))
|
|
for i := range users {
|
|
ur := userToResponse(&users[i])
|
|
if names, err := h.repo.GetRoleNames(users[i].ID); err == nil {
|
|
ur.Roles = names
|
|
}
|
|
resp = append(resp, ur)
|
|
}
|
|
WriteList(w, resp, p.Page, p.PageSize, total)
|
|
}
|
|
|
|
// Get handles GET /api/v1/users/{id}
|
|
func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
user, err := h.repo.GetByID(id)
|
|
if err != nil {
|
|
h.logger.Error("UsersHandler", "Get failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to get user")
|
|
return
|
|
}
|
|
if user == nil {
|
|
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "User not found")
|
|
return
|
|
}
|
|
resp := userToResponse(user)
|
|
if names, err := h.repo.GetRoleNames(user.ID); err == nil {
|
|
resp.Roles = names
|
|
}
|
|
WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// Create handles POST /api/v1/users
|
|
func (h *UsersHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
var req UserRequest
|
|
if err := DecodeJSON(r, &req); err != nil {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
|
|
return
|
|
}
|
|
if req.Username == "" || req.Password == nil || *req.Password == "" {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "username and password are required")
|
|
return
|
|
}
|
|
|
|
existing, err := h.repo.GetByUsername(req.Username)
|
|
if err != nil {
|
|
h.logger.Error("UsersHandler", "GetByUsername failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to check username")
|
|
return
|
|
}
|
|
if existing != nil {
|
|
WriteError(w, http.StatusConflict, ErrCodeValidation, "Username already exists")
|
|
return
|
|
}
|
|
|
|
hash, err := crypto.HashPassword(*req.Password)
|
|
if err != nil {
|
|
h.logger.Error("UsersHandler", "HashPassword failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to hash password")
|
|
return
|
|
}
|
|
|
|
isActive := true
|
|
if req.IsActive != nil {
|
|
isActive = *req.IsActive
|
|
}
|
|
user := &models.User{
|
|
Username: req.Username,
|
|
Email: req.Email,
|
|
DisplayName: req.DisplayName,
|
|
PasswordHash: &hash,
|
|
IsActive: isActive,
|
|
}
|
|
if err := h.repo.Create(user); err != nil {
|
|
h.logger.Error("UsersHandler", "Create failed: %v", err)
|
|
emitAudit(h.auditService, r, audit.EventCreate, "User", "", "Create", false,
|
|
map[string]any{"username": req.Username}, err.Error())
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to create user")
|
|
return
|
|
}
|
|
if req.Roles != nil {
|
|
if err := h.repo.SetRoles(user.ID, *req.Roles); err != nil {
|
|
h.logger.Warn("UsersHandler", "SetRoles failed for %s: %v", user.ID, err)
|
|
}
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventCreate, "User", user.ID, "Create", true,
|
|
map[string]any{"username": user.Username}, "")
|
|
resp := userToResponse(user)
|
|
if names, err := h.repo.GetRoleNames(user.ID); err == nil {
|
|
resp.Roles = names
|
|
}
|
|
WriteJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// Update handles PUT /api/v1/users/{id}
|
|
func (h *UsersHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
existing, err := h.repo.GetByID(id)
|
|
if err != nil {
|
|
h.logger.Error("UsersHandler", "GetByID failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to load user")
|
|
return
|
|
}
|
|
if existing == nil {
|
|
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "User not found")
|
|
return
|
|
}
|
|
var req UserRequest
|
|
if err := DecodeJSON(r, &req); err != nil {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Username != "" {
|
|
existing.Username = req.Username
|
|
}
|
|
existing.Email = req.Email
|
|
existing.DisplayName = req.DisplayName
|
|
if req.IsActive != nil {
|
|
existing.IsActive = *req.IsActive
|
|
}
|
|
if req.Password != nil && *req.Password != "" {
|
|
hash, err := crypto.HashPassword(*req.Password)
|
|
if err != nil {
|
|
h.logger.Error("UsersHandler", "HashPassword failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to hash password")
|
|
return
|
|
}
|
|
existing.PasswordHash = &hash
|
|
}
|
|
|
|
if err := h.repo.Update(existing); err != nil {
|
|
h.logger.Error("UsersHandler", "Update failed: %v", err)
|
|
emitAudit(h.auditService, r, audit.EventUpdate, "User", id, "Update", false, nil, err.Error())
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to update user")
|
|
return
|
|
}
|
|
if req.Roles != nil {
|
|
if err := h.repo.SetRoles(existing.ID, *req.Roles); err != nil {
|
|
h.logger.Warn("UsersHandler", "SetRoles failed for %s: %v", existing.ID, err)
|
|
}
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventUpdate, "User", existing.ID, "Update", true,
|
|
map[string]any{"username": existing.Username}, "")
|
|
resp := userToResponse(existing)
|
|
if names, err := h.repo.GetRoleNames(existing.ID); err == nil {
|
|
resp.Roles = names
|
|
}
|
|
WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// Delete handles DELETE /api/v1/users/{id}
|
|
func (h *UsersHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.repo.SoftDelete(id); err != nil {
|
|
h.logger.Error("UsersHandler", "Delete failed: %v", err)
|
|
emitAudit(h.auditService, r, audit.EventDelete, "User", id, "Delete", false, nil, err.Error())
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to delete user")
|
|
return
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventDelete, "User", id, "Delete", true, nil, "")
|
|
WriteJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
func userToResponse(u *models.User) UserResponse {
|
|
resp := UserResponse{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
DisplayName: u.DisplayName,
|
|
IsActive: u.IsActive,
|
|
IsOIDCUser: u.IsOIDCUser,
|
|
CreatedAt: u.CreatedUTC.Format(time.RFC3339),
|
|
UpdatedAt: u.UpdatedUTC.Format(time.RFC3339),
|
|
}
|
|
if u.LastLoginUTC != nil {
|
|
v := u.LastLoginUTC.Format(time.RFC3339)
|
|
resp.LastLoginUTC = &v
|
|
}
|
|
return resp
|
|
}
|