// Package roles manages the roles table (see migrations/004_roles.sql) — // the full permission-matrix replacement for the fixed owner/manager/master // trio. AllPermissions is the single source of truth for valid permission // keys; keep it in sync with production's RequirePermission call sites. package roles import ( "context" "unicode/utf8" "service-center/internal/auth" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5/pgxpool" ) // AllPermissions — 1:1 with production's gated nav sections/routes. A // permission not in this list is rejected on Create/Update so a typo in a // custom role's matrix fails loudly instead of silently granting nothing. var AllPermissions = map[string]bool{ "cash": true, "analytics": true, "staff": true, "modules": true, "order_fields": true, "catalogs": true, "document_templates": true, "settings": true, "services": true, // unscoped isn't a nav section — it's production's authz.CanAccessAssigned // switch (see migrations/007_unscoped_permission.sql): without it, a role // (custom or 'master') is restricted to orders/batches assigned to that // staff member or unassigned; with it, every record is visible/writable // regardless of assignment, same reach owner/manager have always had. "unscoped": true, // delete_orders gates production's DELETE /api/orders/:id (soft-delete - // see its migrations/049_order_soft_delete.sql doc comment for why not a // hard delete). Deliberately not folded into "unscoped" or any existing // key - being able to see every order and being able to erase one are // different-enough capabilities that a role should opt into each // separately. "delete_orders": true, // approve_discounts gates production's PATCH /orders/:id/discount-approval // (see its internal/settings' discount_approval_enabled/threshold_percent // and order.Update's own doc comment). Missing here was a real bug, not // just an inconvenience - the RolesPage/StaffPermissionsEditor checkboxes // for "Согласование скидок" rendered fine (the label list lives in // production, this map only validates), but every attempt to actually // grant it via a role's permission matrix or a per-staff override // silently 400'd with "unknown permission: approve_discounts" until now. "approve_discounts": true, } const maxNameLen = 100 type Handler struct { db *pgxpool.Pool } func NewHandler(db *pgxpool.Pool) *Handler { return &Handler{db: db} } type roleRow struct { ID string `json:"id"` Name string `json:"name"` IsSystem bool `json:"is_system"` Permissions []string `json:"permissions"` } // List is any staff role — StaffPage's role picker (any staff viewing the // page, though only "staff" permission can actually create/edit staff) // needs the full role list to render names/labels either way. func (h *Handler) List(c *fiber.Ctx) error { rows, err := h.db.Query(context.Background(), `SELECT id, name, is_system, permissions FROM roles ORDER BY is_system DESC, name`) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } defer rows.Close() out := []roleRow{} for rows.Next() { var r roleRow if err := rows.Scan(&r.ID, &r.Name, &r.IsSystem, &r.Permissions); err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } out = append(out, r) } return c.JSON(out) } // ValidatePermissions is exported for internal/staff's permission_grants/ // permission_revokes columns — same key namespace as a role's own // permissions list, so the same allowlist+duplicate check applies. func ValidatePermissions(perms []string) string { return validatePermissions(perms) } func validatePermissions(perms []string) string { seen := make(map[string]bool, len(perms)) for _, p := range perms { if !AllPermissions[p] { return "unknown permission: " + p } if seen[p] { return "duplicate permission: " + p } seen[p] = true } return "" } // Create defines a brand-new role's permission set — gated to the literal // owner role, not just staffPerm (see staff.Update's own doc comment for // the exact escalation this closes): a "staff"-permission-only account // could otherwise create a role holding every permission in the system and // assign a new or existing staff account to it, achieving the same result // as being granted every permission directly. func (h *Handler) Create(c *fiber.Ctx) error { if auth.StaffRole(c) != "owner" { return c.Status(403).JSON(fiber.Map{"error": "only the owner role can create roles"}) } var body struct { Name string `json:"name"` Permissions []string `json:"permissions"` } if err := c.BodyParser(&body); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) } if utf8.RuneCountInString(body.Name) == 0 || utf8.RuneCountInString(body.Name) > maxNameLen { return c.Status(400).JSON(fiber.Map{"error": "name is required and must be under 100 characters"}) } if msg := validatePermissions(body.Permissions); msg != "" { return c.Status(400).JSON(fiber.Map{"error": msg}) } if body.Permissions == nil { body.Permissions = []string{} } var id string err := h.db.QueryRow(context.Background(), `INSERT INTO roles (name, is_system, permissions) VALUES ($1, false, $2) RETURNING id`, body.Name, body.Permissions, ).Scan(&id) if err != nil { if isUniqueViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "role name already exists"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } return c.Status(201).JSON(fiber.Map{"id": id, "name": body.Name, "is_system": false, "permissions": body.Permissions}) } // Update edits a custom role's permissions (and, for a custom role, its // name). System roles' permissions and names are both immutable — 'owner' // staying literally named 'owner' with a fixed permission set is what makes // it a safe, un-lockout-able anchor; a custom role that happened to be // granted every permission is still not "the owner" for last-owner // protection purposes (see staff.Update). Owner-only for the same reason // Create is — expanding an existing role's permissions is exactly as much // of an escalation path as creating a fresh maxed-out one. func (h *Handler) Update(c *fiber.Ctx) error { if auth.StaffRole(c) != "owner" { return c.Status(403).JSON(fiber.Map{"error": "only the owner role can edit role permissions"}) } id := c.Params("id") var body struct { Name *string `json:"name"` Permissions []string `json:"permissions"` } if err := c.BodyParser(&body); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) } if body.Permissions != nil { if msg := validatePermissions(body.Permissions); msg != "" { return c.Status(400).JSON(fiber.Map{"error": msg}) } } if body.Name != nil && (utf8.RuneCountInString(*body.Name) == 0 || utf8.RuneCountInString(*body.Name) > maxNameLen) { return c.Status(400).JSON(fiber.Map{"error": "name must be under 100 characters"}) } ctx := context.Background() var isSystem bool if err := h.db.QueryRow(ctx, `SELECT is_system FROM roles WHERE id = $1::uuid`, id).Scan(&isSystem); err != nil { return c.Status(404).JSON(fiber.Map{"error": "role not found"}) } if isSystem { return c.Status(403).JSON(fiber.Map{"error": "built-in roles cannot be edited"}) } tag, err := h.db.Exec(ctx, `UPDATE roles SET name = COALESCE($1, name), permissions = COALESCE($2, permissions) WHERE id = $3::uuid`, body.Name, body.Permissions, id) if err != nil { if isUniqueViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "role name already exists"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if tag.RowsAffected() == 0 { return c.Status(404).JSON(fiber.Map{"error": "role not found"}) } return c.JSON(fiber.Map{"ok": true}) } func (h *Handler) Delete(c *fiber.Ctx) error { id := c.Params("id") ctx := context.Background() var isSystem bool if err := h.db.QueryRow(ctx, `SELECT is_system FROM roles WHERE id = $1::uuid`, id).Scan(&isSystem); err != nil { return c.Status(404).JSON(fiber.Map{"error": "role not found"}) } if isSystem { return c.Status(403).JSON(fiber.Map{"error": "built-in roles cannot be deleted"}) } tag, err := h.db.Exec(ctx, `DELETE FROM roles WHERE id = $1::uuid`, id) if err != nil { if isFKViolation(err) { return c.Status(409).JSON(fiber.Map{"error": "role is assigned to existing staff"}) } return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if tag.RowsAffected() == 0 { return c.Status(404).JSON(fiber.Map{"error": "role 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 }