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 }