init: portal-common (db + middleware + portal client)

This commit is contained in:
Grendgi
2026-05-20 14:00:06 +03:00
commit db1660a390
6 changed files with 604 additions and 0 deletions

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# portal-common
Shared Go-библиотека для микросервисов портала.
## Состав
- `db/` — pgxpool init + slow-query tracer. Заменяет идентичный код в 9 сервисах.
- `middleware/``InternalAuth` (X-Internal-Key + X-User-*) + хелперы для кастомных заголовков.
- `portal/` — HTTP-клиент portal-сервиса: directory с кэшем+stale fallback, notifications, deactivate user.
## Использование
В каждом сервисе:
```go
import (
"gitea.estateliga.work/admin/portal-common/db"
"gitea.estateliga.work/admin/portal-common/middleware"
"gitea.estateliga.work/admin/portal-common/portal"
)
pool, _ := db.ConnectURL(cfg.DatabaseURL)
r := chi.NewRouter()
r.Use(middleware.InternalAuth(cfg.InternalAPIKey))
portalCli := portal.New(cfg.PortalBaseURL, cfg.PortalAPIKey)
```
## Dev-режим (без push в Gitea)
В `go.mod` сервиса добавить `replace`:
```
replace gitea.estateliga.work/admin/portal-common => ../portal-common
```
Когда библиотека стабилизируется, заменить на pinned тег:
```
require gitea.estateliga.work/admin/portal-common v0.1.0
```
## Зачем
До этого 9 сервисов копировали один в один: pgxpool init, slow-query tracer (500ms threshold), InternalAuth middleware. Tweak'ать tuning централизованно было невозможно. Сейчас изменения идут в одном репо, сервисы пересобираются.

140
db/pool.go Normal file
View File

@@ -0,0 +1,140 @@
// Package db — общий pgxpool init для микросервисов: единая конфигурация
// connection-pool'а, slow-query tracer, retry на startup.
//
// История: каждый сервис (booking, candidates, tasks, meet, leaders-reports,
// telephony, hhru, webhooks-apps, deals) копировал этот код один в один.
// Разъехавшиеся mode сложно tune'ить централизованно — теперь tweak'аем
// здесь, а сервисы пересобираются.
package db
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// PoolConfig — параметры пула. Дефолты идентичны тому, что было в каждом
// сервисе (MaxConns=10, MinConns=2). Slow-query threshold — 500ms.
//
// Если сервис хочет другие лимиты (например, candidates с большим
// объёмом read'ов), можно передать своё значение; nil/zero — берём
// default.
type PoolConfig struct {
DatabaseURL string
MaxConns int32
MinConns int32
MaxConnIdleTime time.Duration
MaxConnLifetime time.Duration
SlowQueryThreshold time.Duration
ConnectTimeout time.Duration
}
// Defaults возвращает PoolConfig с дефолтными значениями, выставлены
// только числовые лимиты — DatabaseURL caller подставляет сам.
func Defaults() PoolConfig {
return PoolConfig{
MaxConns: 10,
MinConns: 2,
MaxConnIdleTime: 5 * time.Minute,
MaxConnLifetime: 30 * time.Minute,
SlowQueryThreshold: 500 * time.Millisecond,
ConnectTimeout: 10 * time.Second,
}
}
// Connect открывает pgxpool с заданной конфигурацией и tracer'ом slow-queries.
// Возвращает live-pool после успешного Ping; на ошибке pool закрывается.
func Connect(cfg PoolConfig) (*pgxpool.Pool, error) {
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DatabaseURL is required")
}
def := Defaults()
if cfg.MaxConns == 0 {
cfg.MaxConns = def.MaxConns
}
if cfg.MinConns == 0 {
cfg.MinConns = def.MinConns
}
if cfg.MaxConnIdleTime == 0 {
cfg.MaxConnIdleTime = def.MaxConnIdleTime
}
if cfg.MaxConnLifetime == 0 {
cfg.MaxConnLifetime = def.MaxConnLifetime
}
if cfg.SlowQueryThreshold == 0 {
cfg.SlowQueryThreshold = def.SlowQueryThreshold
}
if cfg.ConnectTimeout == 0 {
cfg.ConnectTimeout = def.ConnectTimeout
}
poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("parse db config: %w", err)
}
poolCfg.MaxConns = cfg.MaxConns
poolCfg.MinConns = cfg.MinConns
poolCfg.MaxConnIdleTime = cfg.MaxConnIdleTime
poolCfg.MaxConnLifetime = cfg.MaxConnLifetime
poolCfg.ConnConfig.Tracer = &slowQueryTracer{threshold: cfg.SlowQueryThreshold}
ctx, cancel := context.WithTimeout(context.Background(), cfg.ConnectTimeout)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
return pool, nil
}
// ConnectURL — короткая форма Connect(PoolConfig{DatabaseURL: url}).
// Удобно для сервисов, которым не нужны кастомные лимиты.
func ConnectURL(url string) (*pgxpool.Pool, error) {
cfg := Defaults()
cfg.DatabaseURL = url
return Connect(cfg)
}
// slowQueryTracer пишет WARN в slog'е для запросов, длиннее threshold.
// Логируется duration_ms, обрезанный SQL и error (если был). Не префиксируем
// сервисом — это делает caller через slog.With() на старте процесса.
type slowQueryTracer struct{ threshold time.Duration }
type traceCtxKey struct{}
type traceState struct {
start time.Time
sql string
}
func (t *slowQueryTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context {
return context.WithValue(ctx, traceCtxKey{}, traceState{start: time.Now(), sql: data.SQL})
}
func (t *slowQueryTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) {
st, ok := ctx.Value(traceCtxKey{}).(traceState)
if !ok {
return
}
dur := time.Since(st.start)
if dur < t.threshold {
return
}
sql := st.sql
if len(sql) > 500 {
sql = sql[:500] + "…"
}
attrs := []any{"duration_ms", dur.Milliseconds(), "sql", sql}
if data.Err != nil {
attrs = append(attrs, "error", data.Err)
}
slog.Warn("slow query", attrs...)
}

21
go.mod Normal file
View File

@@ -0,0 +1,21 @@
// Shared библиотека для микросервисов портала.
// Содержит: db (pgxpool init + slow-query tracer), middleware (InternalAuth),
// portal (HTTP-клиент portal-сервиса), audit (общий event log).
//
// Используется из tasks/, candidates/, booking/, meet/, leaders-reports/,
// telephony/, hhru/, webhooks-apps/, deals/. Каждый сервис ссылается на
// `gitea.estateliga.work/admin/portal-common` через go.mod replace
// в dev-сборке (см. README.md в репозитории).
module gitea.estateliga.work/admin/portal-common
go 1.25.7
require github.com/jackc/pgx/v5 v5.9.1
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

26
go.sum Normal file
View File

@@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

136
middleware/internal_auth.go Normal file
View File

@@ -0,0 +1,136 @@
// Package middleware — общая InternalAuth для cross-service запросов
// через portal-proxy. Каждый сервис до этого имел свой клон файла; теперь
// один источник истины.
//
// Контракт:
// - portal-proxy пробрасывает X-Internal-Key (общий с сервисом секрет),
// X-User-Id (UUID логиниувшегося юзера), X-User-Name (для логов/UI),
// X-User-Is-Admin ('1' если у юзера системная админ-роль), плюс
// любые X-User-* кастомные заголовки (роли/scope/subordinates).
// - middleware кладёт всё это в context'е, чтобы handler'ы и нижестоящие
// middleware'ы читали без повторного парса заголовков.
//
// Если сервису нужны кастомные роли — ставим дополнительные middleware
// поверх (см. WithCustomHeaders в этом же пакете).
package middleware
import (
"context"
"crypto/subtle"
"net"
"net/http"
"strings"
)
// contextKey — typedef'нутый string чтобы не сталкиваться с другими пакетами,
// которые могут писать в r.Context() свои ключи.
type contextKey string
const (
UserIDKey contextKey = "user_id"
UserNameKey contextKey = "user_name"
IsAdminKey contextKey = "is_admin"
ClientIPKey contextKey = "client_ip"
)
// InternalAuth возвращает middleware, который:
// - сверяет X-Internal-Key с expectedKey (constant-time compare против
// timing-атак),
// - требует X-User-Id (без него запрос неавторизован),
// - кладёт user_id, user_name, is_admin, client_ip в r.Context().
//
// Если expectedKey пустой — сервис намеренно отключил internal API; все
// запросы летят с 503. Это удобный «kill switch» для dev без выкатки.
func InternalAuth(expectedKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if expectedKey == "" {
http.Error(w, `{"error":"internal api disabled"}`, http.StatusServiceUnavailable)
return
}
gotKey := r.Header.Get("X-Internal-Key")
if len(gotKey) != len(expectedKey) ||
subtle.ConstantTimeCompare([]byte(gotKey), []byte(expectedKey)) != 1 {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
userID := r.Header.Get("X-User-Id")
if userID == "" {
http.Error(w, `{"error":"missing X-User-Id header"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserNameKey, r.Header.Get("X-User-Name"))
ctx = context.WithValue(ctx, IsAdminKey, r.Header.Get("X-User-Is-Admin") == "1")
ctx = context.WithValue(ctx, ClientIPKey, extractClientIP(r))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// WithCustomHeader — фабрика для сервисов, которым нужны дополнительные
// header→context маппинги. Пример: candidates читает X-Candidates-Scope.
//
// Использование:
// r.Use(middleware.InternalAuth(key))
// r.Use(middleware.WithCustomHeader("X-Candidates-Scope", scopeKey))
//
// Хендлер потом достаёт через context.Value(scopeKey).
func WithCustomHeader(headerName string, ctxKey any) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v := r.Header.Get(headerName)
ctx := r.Context()
if v != "" {
ctx = context.WithValue(ctx, ctxKey, v)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok {
return v
}
return ""
}
func GetUserName(ctx context.Context) string {
if v, ok := ctx.Value(UserNameKey).(string); ok {
return v
}
return ""
}
func IsAdmin(ctx context.Context) bool {
if v, ok := ctx.Value(IsAdminKey).(bool); ok {
return v
}
return false
}
func GetClientIP(ctx context.Context) string {
if v, ok := ctx.Value(ClientIPKey).(string); ok {
return v
}
return ""
}
// extractClientIP читает X-Forwarded-For (берём левый — это исходный
// клиент по RFC 7239), fallback на RemoteAddr без порта.
func extractClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma >= 0 {
xff = xff[:comma]
}
if ip := strings.TrimSpace(xff); ip != "" {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

235
portal/client.go Normal file
View File

@@ -0,0 +1,235 @@
// Package portal — общий HTTP-клиент portal-сервиса для cross-service
// нужд: directory с кэшем+stale fallback'ом, notification fan-out,
// deactivate user.
//
// Раньше каждый сервис делал свой клон (booking/notifications,
// candidates/directory+deactivate, meet/notifications, leaders-reports/
// notifications). Теперь — один источник.
package portal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// User — DTO из /api/internal/users (см. portal handler.InternalUserDTO).
// Не все поля нужны каждому клиенту, но дублируем целиком, чтобы был
// единый shape (caller игнорирует лишнее).
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
MiddleName string `json:"middle_name"`
FullName string `json:"full_name"`
IsActive bool `json:"is_active"`
DepartmentID *string `json:"department_id"`
DepartmentName *string `json:"department_name"`
PositionID *string `json:"position_id"`
PositionName *string `json:"position_name"`
WorkGeoCode *string `json:"work_geo_code"`
}
type Client struct {
baseURL string
apiKey string
hc *http.Client
// Кэш directory. 5-мин TTL, при ошибке возвращаем stale — temporary
// portal outage не должен валить cross-service flow.
mu sync.Mutex
cache []User
cachedAt time.Time
cacheTTL time.Duration
}
// New строит клиент с дефолтами (timeout=10s, TTL=5m). Если baseURL или
// apiKey пустые — клиент Disabled, все методы вернут ошибку.
func New(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
hc: &http.Client{Timeout: 10 * time.Second},
cacheTTL: 5 * time.Minute,
}
}
// Enabled — true если конфигурация полная. Caller проверяет перед
// вызовом, чтобы решить «пытаться или skip».
func (c *Client) Enabled() bool {
return c != nil && c.baseURL != "" && c.apiKey != ""
}
// SetCacheTTL — переопределить TTL directory-кэша (для тестов или
// сервисов с особыми требованиями к свежести).
func (c *Client) SetCacheTTL(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.cacheTTL = d
}
// ListDirectory возвращает справочник юзеров (с кэшем). При ошибке сети —
// stale (если есть), иначе propagated error. Сетевой 5xx ретраится один
// раз (200ms backoff).
func (c *Client) ListDirectory(ctx context.Context) ([]User, error) {
if !c.Enabled() {
return nil, errors.New("portal client disabled")
}
c.mu.Lock()
if time.Since(c.cachedAt) < c.cacheTTL && c.cache != nil {
out := c.cache
c.mu.Unlock()
return out, nil
}
c.mu.Unlock()
users, err := c.fetchDirectory(ctx)
if err != nil {
c.mu.Lock()
stale := c.cache
c.mu.Unlock()
if stale != nil {
return stale, fmt.Errorf("fetch directory (using stale cache): %w", err)
}
return nil, err
}
c.mu.Lock()
c.cache = users
c.cachedAt = time.Now()
c.mu.Unlock()
return users, nil
}
// CreateNotificationRequest — DTO для /api/internal/notifications.
// Channels пока не enforce'ится сервером — задел на будущий fan-out.
type CreateNotificationRequest struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Type string `json:"type,omitempty"`
Link string `json:"link,omitempty"`
Channels []string `json:"channels,omitempty"`
}
func (c *Client) CreateNotification(ctx context.Context, req CreateNotificationRequest) error {
if !c.Enabled() {
return errors.New("portal client disabled")
}
return c.postJSON(ctx, "/api/internal/notifications", req)
}
// DeactivateUser помечает юзера is_active=false + invalidate'ит токены.
// 404 от portal'а — already-gone, для caller'а это успех.
func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
if !c.Enabled() {
return errors.New("portal client disabled")
}
if userID == "" {
return errors.New("user id required")
}
url := c.baseURL + "/api/users/" + userID + "/deactivate"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("X-Internal-Key", c.apiKey)
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body))
}
return nil
}
// postJSON — общий helper для POST /api/* с JSON-телом и X-Internal-Key.
func (c *Client) postJSON(ctx context.Context, path string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
url := c.baseURL + path
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-Internal-Key", c.apiKey)
resp, err := c.hc.Do(httpReq)
if err != nil {
return fmt.Errorf("post: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("portal %s returned %d: %s", path, resp.StatusCode, string(respBody))
}
return nil
}
// fetchDirectory — GET /api/internal/users с retry'ем на 5xx и парсингом
// обоих форматов ответа (массив или {users: []}).
func (c *Client) fetchDirectory(ctx context.Context) ([]User, error) {
url := c.baseURL + "/api/internal/users"
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if attempt > 0 {
time.Sleep(200 * time.Millisecond)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("X-Internal-Key", c.apiKey)
resp, lastErr = c.hc.Do(req)
if lastErr != nil {
continue
}
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
_ = resp.Body.Close()
lastErr = fmt.Errorf("portal 5xx: %d", resp.StatusCode)
continue
}
break
}
if lastErr != nil && resp == nil {
return nil, fmt.Errorf("get directory: %w", lastErr)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("portal returned %d: %s", resp.StatusCode, string(body))
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
var asArr []User
if err := json.Unmarshal(raw, &asArr); err == nil {
return asArr, nil
}
var asObj struct {
Users []User `json:"users"`
}
if err := json.Unmarshal(raw, &asObj); err != nil {
return nil, fmt.Errorf("parse directory: %w", err)
}
return asObj.Users, nil
}