Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Platform staff roles (users.staff_role). Orthogonal to company membership roles.
|
||||
const (
|
||||
StaffRoleAdmin = "admin"
|
||||
StaffRoleDeveloper = "developer"
|
||||
StaffRoleSupportStaff = "support_staff"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStaffRole = errors.New("invalid staff_role")
|
||||
ErrStaffUserNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// StaffAccess is the resolved capability set for a platform staff user.
|
||||
type StaffAccess struct {
|
||||
Role string `json:"staff_role,omitempty"`
|
||||
FullAdmin bool `json:"full_admin"`
|
||||
SupportDesk bool `json:"support_desk"`
|
||||
IsSupportOnly bool `json:"is_support_only"`
|
||||
}
|
||||
|
||||
// NormalizeStaffRole returns a known staff role or empty string.
|
||||
func NormalizeStaffRole(raw string) (string, error) {
|
||||
role := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch role {
|
||||
case "", StaffRoleAdmin, StaffRoleDeveloper, StaffRoleSupportStaff:
|
||||
return role, nil
|
||||
default:
|
||||
return "", ErrInvalidStaffRole
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveStaffRole returns the effective staff role per contract 04:
|
||||
// staff_role if set; else admin when is_platform_admin; else empty.
|
||||
func ResolveStaffRole(isPlatformAdmin bool, staffRole string) string {
|
||||
role, _ := NormalizeStaffRole(staffRole)
|
||||
if role != "" {
|
||||
return role
|
||||
}
|
||||
if isPlatformAdmin {
|
||||
return StaffRoleAdmin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveStaffAccess maps DB flags to capabilities.
|
||||
//
|
||||
// Rules (fail closed):
|
||||
// - staff_role=support_staff → support desk only (never full admin), even if is_platform_admin.
|
||||
// - staff_role=admin|developer → full admin + support desk.
|
||||
// - staff_role empty + is_platform_admin → legacy full admin (backward compatible).
|
||||
// - otherwise → no staff access.
|
||||
func ResolveStaffAccess(isPlatformAdmin bool, staffRole string) StaffAccess {
|
||||
role := ResolveStaffRole(isPlatformAdmin, staffRole)
|
||||
switch role {
|
||||
case StaffRoleSupportStaff:
|
||||
return StaffAccess{
|
||||
Role: StaffRoleSupportStaff,
|
||||
FullAdmin: false,
|
||||
SupportDesk: true,
|
||||
IsSupportOnly: true,
|
||||
}
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return StaffAccess{
|
||||
Role: role,
|
||||
FullAdmin: true,
|
||||
SupportDesk: true,
|
||||
}
|
||||
default:
|
||||
return StaffAccess{}
|
||||
}
|
||||
}
|
||||
|
||||
// StaffCapabilities lists platform capability keys for a resolved staff role.
|
||||
func StaffCapabilities(role string) []string {
|
||||
switch role {
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return []string{
|
||||
"staff.admin_shell",
|
||||
"staff.support.queue",
|
||||
"staff.support.reply",
|
||||
"staff.support.assign",
|
||||
"staff.users.read",
|
||||
"staff.users.write",
|
||||
"staff.analytics",
|
||||
"staff.billing",
|
||||
"staff.plans_features",
|
||||
"staff.feature_gates",
|
||||
"staff.settings",
|
||||
"staff.jobs_stuck",
|
||||
"staff.impersonate",
|
||||
"staff.dev_password",
|
||||
}
|
||||
case StaffRoleSupportStaff:
|
||||
return []string{
|
||||
"staff.admin_shell",
|
||||
"staff.support.queue",
|
||||
"staff.support.reply",
|
||||
"staff.support.assign",
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetStaffAccess loads is_platform_admin + staff_role for an active user.
|
||||
// Missing staff_role column (pre-migration) falls back to boolean-only admin.
|
||||
func (s *Service) GetStaffAccess(ctx context.Context, userID uuid.UUID) (StaffAccess, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return StaffAccess{}, errors.New("auth service unavailable")
|
||||
}
|
||||
var isAdmin bool
|
||||
var staffRole *string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_platform_admin, staff_role
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true`, userID,
|
||||
).Scan(&isAdmin, &staffRole)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return StaffAccess{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
ok, err2 := s.platformAdminFlag(ctx, userID)
|
||||
if err2 != nil {
|
||||
return StaffAccess{}, err2
|
||||
}
|
||||
return ResolveStaffAccess(ok, ""), nil
|
||||
}
|
||||
return StaffAccess{}, err
|
||||
}
|
||||
role := ""
|
||||
if staffRole != nil {
|
||||
role = *staffRole
|
||||
}
|
||||
return ResolveStaffAccess(isAdmin, role), nil
|
||||
}
|
||||
|
||||
// platformAdminFlag reads users.is_platform_admin without staff_role resolution.
|
||||
func (s *Service) platformAdminFlag(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_platform_admin FROM users WHERE id = $1 AND is_active = true`, userID).Scan(&ok)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// StaffUser is a platform staff directory row.
|
||||
type StaffUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
IsPlatformAdmin bool `json:"is_platform_admin"`
|
||||
StaffRole *string `json:"staff_role,omitempty"`
|
||||
ResolvedRole string `json:"resolved_role"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ListStaffUsers returns active users with any platform staff access.
|
||||
func (s *Service) ListStaffUsers(ctx context.Context, limit, offset int) ([]StaffUser, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, email, name, is_platform_admin, staff_role, is_active
|
||||
FROM users
|
||||
WHERE is_active = true
|
||||
AND (is_platform_admin = true OR staff_role IS NOT NULL)
|
||||
ORDER BY coalesce(staff_role, ''), email
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]StaffUser, 0)
|
||||
for rows.Next() {
|
||||
var u StaffUser
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := ""
|
||||
if u.StaffRole != nil {
|
||||
stored = *u.StaffRole
|
||||
}
|
||||
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, stored)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetStaffRole assigns or clears a platform staff role.
|
||||
// Non-empty role sets is_platform_admin=true (contract invariant).
|
||||
// Empty role clears staff_role and is_platform_admin.
|
||||
func (s *Service) SetStaffRole(ctx context.Context, userID uuid.UUID, staffRole string) (StaffUser, error) {
|
||||
role, err := NormalizeStaffRole(staffRole)
|
||||
if err != nil {
|
||||
return StaffUser{}, err
|
||||
}
|
||||
var (
|
||||
u StaffUser
|
||||
stored *string
|
||||
)
|
||||
if role == "" {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET staff_role = NULL, is_platform_admin = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true
|
||||
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
|
||||
} else {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET staff_role = $2, is_platform_admin = true, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true
|
||||
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID, role,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return StaffUser{}, ErrStaffUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return StaffUser{}, err
|
||||
}
|
||||
u.StaffRole = stored
|
||||
storedRole := ""
|
||||
if stored != nil {
|
||||
storedRole = *stored
|
||||
}
|
||||
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, storedRole)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// IsAssignableSupportStaff reports whether userID may be set as a ticket assignee.
|
||||
func (s *Service) IsAssignableSupportStaff(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
access, err := s.GetStaffAccess(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return access.SupportDesk, nil
|
||||
}
|
||||
|
||||
func isUndefinedColumn(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "42703"
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user