Files

241 lines
8.9 KiB
Go

// 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
}