241 lines
8.9 KiB
Go
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
|
|
}
|