mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
373 lines
11 KiB
Go
373 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type userDTO struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
IsAdmin bool `json:"isAdmin"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.QueryContext(r.Context(), `SELECT id, username, email, is_admin, created_at FROM users ORDER BY username`)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
byID := map[string]*userDTO{}
|
|
out := []userDTO{}
|
|
for rows.Next() {
|
|
var u userDTO
|
|
var isAdmin int
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &isAdmin, &u.CreatedAt); err != nil {
|
|
rows.Close()
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
u.IsAdmin = isAdmin == 1
|
|
out = append(out, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
rows.Close() // must release the single sqlite connection before issuing the role-assignment query below
|
|
|
|
for i := range out {
|
|
byID[out[i].ID] = &out[i]
|
|
}
|
|
|
|
roleRows, err := s.db.QueryContext(r.Context(), `
|
|
SELECT ur.user_id, rr.name FROM rbac_user_roles ur JOIN rbac_roles rr ON rr.id = ur.role_id`)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer roleRows.Close()
|
|
for roleRows.Next() {
|
|
var userID, roleName string
|
|
if err := roleRows.Scan(&userID, &roleName); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if u, ok := byID[userID]; ok {
|
|
u.Roles = append(u.Roles, roleName)
|
|
}
|
|
}
|
|
if err := roleRows.Err(); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
type createUserRequest struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
IsAdmin bool `json:"isAdmin"`
|
|
RoleID string `json:"roleId,omitempty"`
|
|
}
|
|
|
|
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
|
|
var req createUserRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" || len(req.Password) < 8 {
|
|
writeErrorMsg(w, http.StatusBadRequest, "username, email, and an 8+ character password are required")
|
|
return
|
|
}
|
|
|
|
var exists int
|
|
if err := s.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE username = ? OR email = ?`, req.Username, req.Email).Scan(&exists); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if exists > 0 {
|
|
writeErrorMsg(w, http.StatusConflict, "a user with that username or email already exists")
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
id := uuid.NewString()
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := s.db.ExecContext(r.Context(),
|
|
`INSERT INTO users (id, username, email, password_hash, is_admin, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
id, req.Username, req.Email, string(hash), boolToInt(req.IsAdmin), now, now,
|
|
); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
if req.RoleID != "" {
|
|
if _, err := s.db.ExecContext(r.Context(),
|
|
`INSERT INTO rbac_user_roles (id, user_id, role_id, scope_type, created_at) VALUES (?, ?, ?, 'global', ?)`,
|
|
uuid.NewString(), id, req.RoleID, now,
|
|
); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.audit(r, "users.create", "admin", req.Username)
|
|
slog.Info("user created", "id", id, "username", req.Username, "isAdmin", req.IsAdmin)
|
|
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
|
|
}
|
|
|
|
type updateUserRequest struct {
|
|
Email *string `json:"email,omitempty"`
|
|
Password *string `json:"password,omitempty"`
|
|
IsAdmin *bool `json:"isAdmin,omitempty"`
|
|
RoleID *string `json:"roleId,omitempty"`
|
|
}
|
|
|
|
// updateUser edits an existing account: email, password reset, admin flag,
|
|
// and role assignment. Only fields the caller sends are changed. Demoting
|
|
// the last remaining admin is refused so the instance can never lock
|
|
// itself out of administration.
|
|
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var req updateUserRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
var username string
|
|
var currentAdmin int
|
|
if err := s.db.QueryRowContext(r.Context(), `SELECT username, is_admin FROM users WHERE id = ?`, id).Scan(&username, ¤tAdmin); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeErrorMsg(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
if req.IsAdmin != nil && !*req.IsAdmin && currentAdmin == 1 {
|
|
var admins int
|
|
if err := s.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&admins); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if admins <= 1 {
|
|
writeErrorMsg(w, http.StatusConflict, "cannot demote the last admin")
|
|
return
|
|
}
|
|
}
|
|
|
|
sets := []string{}
|
|
args := []any{}
|
|
set := func(col string, val any) {
|
|
sets = append(sets, col+" = ?")
|
|
args = append(args, val)
|
|
}
|
|
|
|
if req.Email != nil {
|
|
if *req.Email == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "email cannot be empty")
|
|
return
|
|
}
|
|
var exists int
|
|
if err := s.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE email = ? AND id != ?`, *req.Email, id).Scan(&exists); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if exists > 0 {
|
|
writeErrorMsg(w, http.StatusConflict, "another user already uses that email")
|
|
return
|
|
}
|
|
set("email", *req.Email)
|
|
}
|
|
if req.Password != nil {
|
|
if len(*req.Password) < 8 {
|
|
writeErrorMsg(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
// A password reset revokes the account's existing sessions.
|
|
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = ?`, id); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
set("password_hash", string(hash))
|
|
}
|
|
if req.IsAdmin != nil {
|
|
set("is_admin", boolToInt(*req.IsAdmin))
|
|
}
|
|
if len(sets) == 0 && req.RoleID == nil {
|
|
writeErrorMsg(w, http.StatusBadRequest, "no fields to update")
|
|
return
|
|
}
|
|
if len(sets) > 0 {
|
|
set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
|
query := "UPDATE users SET "
|
|
for i, c := range sets {
|
|
if i > 0 {
|
|
query += ", "
|
|
}
|
|
query += c
|
|
}
|
|
query += " WHERE id = ?"
|
|
args = append(args, id)
|
|
if _, err := s.db.ExecContext(r.Context(), query, args...); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.RoleID != nil {
|
|
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM rbac_user_roles WHERE user_id = ?`, id); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if *req.RoleID != "" {
|
|
if _, err := s.db.ExecContext(r.Context(),
|
|
`INSERT INTO rbac_user_roles (id, user_id, role_id, scope_type, created_at) VALUES (?, ?, ?, 'global', ?)`,
|
|
uuid.NewString(), id, *req.RoleID, time.Now().UTC().Format(time.RFC3339),
|
|
); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
s.audit(r, "users.update", "admin", username)
|
|
slog.Info("user updated", "id", id, "username", username)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if u := userFromContext(r); u != nil && u.ID == id {
|
|
writeErrorMsg(w, http.StatusBadRequest, "cannot delete your own account")
|
|
return
|
|
}
|
|
|
|
var isAdmin int
|
|
if err := s.db.QueryRowContext(r.Context(), `SELECT is_admin FROM users WHERE id = ?`, id).Scan(&isAdmin); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeErrorMsg(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if isAdmin == 1 {
|
|
var admins int
|
|
if err := s.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&admins); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if admins <= 1 {
|
|
writeErrorMsg(w, http.StatusConflict, "cannot delete the last admin")
|
|
return
|
|
}
|
|
}
|
|
|
|
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM users WHERE id = ?`, id); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
s.audit(r, "users.delete", "admin", id)
|
|
slog.Warn("user deleted", "id", id)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type roleDTO struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
IsSystem bool `json:"isSystem"`
|
|
}
|
|
|
|
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name, COALESCE(description,''), COALESCE(color,''), is_system FROM rbac_roles ORDER BY name`)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []roleDTO{}
|
|
for rows.Next() {
|
|
var role roleDTO
|
|
var isSystem int
|
|
if err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.Color, &isSystem); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
role.IsSystem = isSystem == 1
|
|
out = append(out, role)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) auditLog(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.QueryContext(r.Context(), `
|
|
SELECT a.id, COALESCE(u.username, 'system'), a.action, a.category, COALESCE(a.target,''), a.ip, a.created_at
|
|
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
|
ORDER BY a.created_at DESC LIMIT 200`)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type entry struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Action string `json:"action"`
|
|
Category string `json:"category"`
|
|
Target string `json:"target,omitempty"`
|
|
IP string `json:"ip,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
out := []entry{}
|
|
for rows.Next() {
|
|
var e entry
|
|
if err := rows.Scan(&e.ID, &e.Username, &e.Action, &e.Category, &e.Target, &e.IP, &e.CreatedAt); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|