Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
265 lines
7.4 KiB
Go
265 lines
7.4 KiB
Go
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
|
|
}
|