Files
OrchestrAD/backend/internal/api/users_handlers.go
Alphaeus Mote 6fdf9a9d54 feat(users): assign RBAC roles to users; OIDC default-role dropdown + clearer SSO config
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>
2026-09-02 21:25:59 -04:00

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
}