Files

265 lines
7.4 KiB
Go
Raw Permalink Normal View History

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
}