Files
aura-crm/core/backend/internal/staff/handler.go
T

321 lines
12 KiB
Go

package staff
import (
"context"
"time"
"service-center/internal/auth"
"service-center/internal/roles"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
type Handler struct {
db *pgxpool.Pool
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db}
}
// Create provisions a new staff account. Route-level gate is staffPerm (see
// Update's own doc comment for why that's deliberately broader than literal
// owner) — but minting a brand-new role="owner" account this way is the
// same privilege-escalation shape Update had, one step more direct even
// (no target account to already exist, no last-active-owner check to dodge):
// any "staff"-permission holder could otherwise create a fresh owner
// account with a password of their own choosing. Any other role stays
// staffPerm-only — onboarding a regular employee is legitimate HR work.
func (h *Handler) Create(c *fiber.Ctx) error {
var body struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.Name == "" || body.Email == "" || len(body.Password) < 8 || body.Role == "" {
return c.Status(400).JSON(fiber.Map{"error": "name, email, role required, password must be at least 8 characters"})
}
if body.Role == "owner" && auth.StaffRole(c) != "owner" {
return c.Status(403).JSON(fiber.Map{"error": "only the owner role can create another owner account"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 12)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
var id string
err = h.db.QueryRow(context.Background(),
`INSERT INTO staff_users (name, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
body.Name, body.Email, string(hash), body.Role,
).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return c.Status(409).JSON(fiber.Map{"error": "email already taken"})
}
// role now has a real FK to roles(name) (migrations/004_roles.sql) —
// an unknown role name (typo, or a role that was since deleted)
// surfaces here as a real FK violation instead of the old hardcoded
// switch statement, same pattern this app already uses for
// client_id/order_id FK checks elsewhere.
if isFKViolation(err) {
return c.Status(400).JSON(fiber.Map{"error": "unknown role"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.Status(201).JSON(fiber.Map{"id": id, "name": body.Name, "email": body.Email, "role": body.Role})
}
// List returns all staff accounts, each with its role's base permissions
// plus the effective set after this staff member's own grants/revokes are
// applied — the StaffPage permission editor needs both to show which
// checkboxes come from the role vs. are a point override. Owner-only.
func (h *Handler) List(c *fiber.Ctx) error {
rows, err := h.db.Query(context.Background(),
`SELECT su.id, su.name, su.email, su.role, su.is_active, su.created_at, su.last_login_at,
su.permission_grants, su.permission_revokes, r.permissions
FROM staff_users su JOIN roles r ON r.name = su.role
ORDER BY su.created_at`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
type staffRow struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt *time.Time `json:"last_login_at"`
PermissionGrants []string `json:"permission_grants"`
PermissionRevokes []string `json:"permission_revokes"`
RolePermissions []string `json:"role_permissions"`
EffectivePermissions []string `json:"effective_permissions"`
}
var out []staffRow
for rows.Next() {
var r staffRow
var rolePerms []string
if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Role, &r.IsActive, &r.CreatedAt, &r.LastLoginAt,
&r.PermissionGrants, &r.PermissionRevokes, &rolePerms); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
r.RolePermissions = rolePerms
r.EffectivePermissions = effectivePermissions(rolePerms, r.PermissionGrants, r.PermissionRevokes)
out = append(out, r)
}
return c.JSON(out)
}
// effectivePermissions is the same union-then-subtract rule Login uses to
// build the JWT claim (see internal/auth/handler.go) — kept here too so
// List can show StaffPage what a staff member's permissions actually
// resolve to without requiring a re-login to preview it.
func effectivePermissions(rolePerms, grants, revokes []string) []string {
revoked := make(map[string]bool, len(revokes))
for _, p := range revokes {
revoked[p] = true
}
seen := make(map[string]bool, len(rolePerms)+len(grants))
out := []string{}
for _, p := range append(append([]string{}, rolePerms...), grants...) {
if revoked[p] || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return out
}
// ListAssignable is the staff picker feed for modules (e.g. production's
// order/cartridge-batch master assignment) — any authenticated staff role
// can call it, unlike List, so it deliberately returns only id/name/role/
// is_active, never email or last_login_at.
func (h *Handler) ListAssignable(c *fiber.Ctx) error {
rows, err := h.db.Query(context.Background(),
`SELECT id, name, role FROM staff_users WHERE is_active = true ORDER BY name`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
type assignableRow struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
}
out := []assignableRow{}
for rows.Next() {
var r assignableRow
if err := rows.Scan(&r.ID, &r.Name, &r.Role); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
return c.JSON(out)
}
// Update changes a staff account's role, active status, and/or point
// permission overrides — never the password (see ResetPassword) or email
// (would need a re-verification flow this app doesn't have). Route-level
// gate is staffPerm (any role holding the "staff" permission, not just the
// literal owner — see main.go), which is deliberately broad enough for a
// custom "HR" role to create accounts/reset passwords/toggle is_active.
// Touching role or the permission overrides is a different tier: those two
// fields are the entire permission model, so only the literal owner role
// may set them — otherwise a custom role holding nothing but "staff" could
// grant itself (or anyone) every permission in the system, or promote
// itself straight to role=owner, via this same endpoint. This is a real
// privilege-escalation path that existed before this check: any account
// with just the "staff" permission could PATCH its own id with
// permission_grants=[every key] or role="owner" and there was nothing
// stopping it.
func (h *Handler) Update(c *fiber.Ctx) error {
id := c.Params("id")
var body struct {
Role *string `json:"role"`
IsActive *bool `json:"is_active"`
PermissionGrants []string `json:"permission_grants"`
PermissionRevokes []string `json:"permission_revokes"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.Role != nil && *body.Role == "" {
return c.Status(400).JSON(fiber.Map{"error": "role must not be empty"})
}
touchesPermissionModel := body.Role != nil || body.PermissionGrants != nil || body.PermissionRevokes != nil
if touchesPermissionModel && auth.StaffRole(c) != "owner" {
return c.Status(403).JSON(fiber.Map{"error": "only the owner role can change a staff member's role or permission overrides"})
}
// PermissionGrants/Revokes are only set (non-nil) when StaffPage's
// editor actually submitted an override change — Update's other two
// fields (role/is_active) are used independently elsewhere (the role
// dropdown, the enable/disable button) and must not implicitly wipe
// overrides just because they weren't part of that particular request.
var grantsParam, revokesParam []string
if body.PermissionGrants != nil || body.PermissionRevokes != nil {
grants, revokes := body.PermissionGrants, body.PermissionRevokes
if grants == nil {
grants = []string{}
}
if revokes == nil {
revokes = []string{}
}
if msg := roles.ValidatePermissions(grants); msg != "" {
return c.Status(400).JSON(fiber.Map{"error": "permission_grants: " + msg})
}
if msg := roles.ValidatePermissions(revokes); msg != "" {
return c.Status(400).JSON(fiber.Map{"error": "permission_revokes: " + msg})
}
revokeSet := make(map[string]bool, len(revokes))
for _, p := range revokes {
revokeSet[p] = true
}
for _, p := range grants {
if revokeSet[p] {
return c.Status(400).JSON(fiber.Map{"error": "permission cannot be both granted and revoked: " + p})
}
}
grantsParam, revokesParam = grants, revokes
}
ctx := context.Background()
// Demoting or disabling the last active owner would lock every staff
// member out of the parts of the app owner-only routes gate (staff
// management itself included) with no way back in short of a direct DB
// edit — reject it here instead. "Last" is evaluated against the target
// row itself, so an owner can always freely edit an account that isn't
// the sole remaining active owner.
if (body.Role != nil && *body.Role != "owner") || (body.IsActive != nil && !*body.IsActive) {
var isSoleActiveOwner bool
err := h.db.QueryRow(ctx,
`SELECT role = 'owner' AND is_active AND (
SELECT COUNT(*) FROM staff_users WHERE role = 'owner' AND is_active
) = 1 FROM staff_users WHERE id = $1::uuid`, id,
).Scan(&isSoleActiveOwner)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if isSoleActiveOwner {
return c.Status(409).JSON(fiber.Map{"error": "cannot demote or disable the last active owner"})
}
}
tag, err := h.db.Exec(ctx,
`UPDATE staff_users SET
role = COALESCE($1, role),
is_active = COALESCE($2, is_active),
permission_grants = COALESCE($3, permission_grants),
permission_revokes = COALESCE($4, permission_revokes)
WHERE id = $5::uuid`,
body.Role, body.IsActive, grantsParam, revokesParam, id)
if err != nil {
if isFKViolation(err) {
return c.Status(400).JSON(fiber.Map{"error": "unknown role"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if tag.RowsAffected() == 0 {
return c.Status(404).JSON(fiber.Map{"error": "staff not found"})
}
return c.JSON(fiber.Map{"ok": true})
}
// ResetPassword sets a new password directly — there's no email
// infrastructure in this stack for a self-service "forgot password" link
// (same reasoning as internal/notify's Telegram-only staff alerts), so an
// owner resets it and relays the new password to the staff member out of
// band. Owner-only.
func (h *Handler) ResetPassword(c *fiber.Ctx) error {
id := c.Params("id")
var body struct {
Password string `json:"password"`
}
if err := c.BodyParser(&body); err != nil || len(body.Password) < 8 {
return c.Status(400).JSON(fiber.Map{"error": "password must be at least 8 characters"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 12)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
tag, err := h.db.Exec(context.Background(),
`UPDATE staff_users SET password_hash = $1 WHERE id = $2::uuid`, string(hash), id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if tag.RowsAffected() == 0 {
return c.Status(404).JSON(fiber.Map{"error": "staff not found"})
}
return c.JSON(fiber.Map{"ok": true})
}
func isUniqueViolation(err error) bool {
return err != nil && contains(err.Error(), "duplicate key value")
}
func isFKViolation(err error) bool {
return err != nil && contains(err.Error(), "violates foreign key constraint")
}
func contains(s, substr string) bool {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}