DEV Community

magnusberg2958
magnusberg2958

Posted on

User Directory Operations: Listing Accounts with Per-User Authorization in Node.js 2026

The page fires when a batch-operations worker cannot reconcile a game account after a Google or GitHub sign-in. The on-call view shows a growing queue and a generic “user not found” line, but the dangerous part is usually earlier: a directory listing was treated as an authorization decision, or an email lookup was allowed to stand in for identity.

Short answer: keep the user ID as the stable authorization key, separate list access from single-user reads, and model every authentication action as a validated, auditable, recoverable state transition. During a migration, keep the provider-specific login flow behind a narrow boundary and make the directory policy explicit before changing vendors.

Start with the alert, then find the missing signal

The first useful question is not “which auth vendor has the nicest dashboard?” It is “what did the worker believe it was allowed to read?” A bulk operator may need a filtered directory view, while a support agent should see one account after a ticket check. Those are different capabilities, even when both return a user record.

Work backwards from the alert. Record the actor, requested scope, user ID, request ID, and the authorization result at the business layer. A failed lookup should be a recoverable transition: retain the job state, emit an audit event, and retry only the part that is safe to retry. Do not silently retry a broad listing with elevated credentials.

The earlier signal is a policy metric, not another HTTP status. Track denied list requests, single-user reads that lack a matching authorization decision, and the age of the oldest batch job. Set an SLO for those decisions separately from the provider's login SLO; a green OAuth callback does not prove that an operator can safely process a directory. Don't page on a single denied row: correlate the actor, policy version, and queue age first, because it's the combination that tells you whether the system is drifting or an operator simply hit a boundary.

One threshold matters here.

If a five-minute window pages on every denied row, the on-call spends the night chasing expected policy enforcement. If it waits for a whole queue to age past the SLO, a real authorization regression hides in the noise. Tune the alert against a baseline of denied actions and sample the audit records, then review the false-positive cost with the people who own the game-operations queue. A useful review walks one job end to end: the queue carries user_123, the policy check records the operator scope, the directory read returns a response, and the audit event closes the transition. If any link is missing, the retry must preserve the original decision context instead of asking a broader endpoint for “whatever is available.”

How should list and single-user reads protect each account?

Treat the list endpoint as discovery for an already-authorized operational task, never as proof that every returned account is readable. The business service should validate the operator's role and scope before calling it, constrain filters and page size, and write an audit record containing the purpose. For a single account, require the user ID and perform a fresh per-user authorization check. An email can help locate a candidate, but it is not a stable principal.

This distinction also changes caching. A short-lived cache of a scoped list can reduce pressure during a batch run, provided the cache key includes the operator scope and policy version. A per-user response needs a narrower key and a shorter lifetime when profile or role state can change. Invalidate both on a recorded state transition, not merely when a login succeeds.

Here is a small Go client for the two read paths. It keeps the token outside source control, uses explicit methods, checks response status, and backs off on rate limits. The service still has to enforce its own authorization decision before handing either response to an operator.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func get(ctx context.Context, path string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("AUTH_API_BASE_URL")
    if baseURL == "" {
        return nil, fmt.Errorf("AUTH_API_BASE_URL is required")
    }
    var lastStatus int
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        lastStatus = resp.StatusCode
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, body)
        }
        wait := time.Duration(1<<attempt) * 250 * time.Millisecond
        if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
            if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil, fmt.Errorf("auth request failed with status %d", lastStatus)
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    list, err := get(ctx, "/auth/user/list")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(list))
    account, err := get(ctx, "/auth/user/get/user_123")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(account))
}
Enter fullscreen mode Exit fullscreen mode

The example deliberately does not turn a list response into a permission grant. The caller must apply the same policy check to user_123 that it would apply to an ID obtained from a queue or a signed callback.

Choosing a migration boundary without hiding the trade-offs

The migration boundary should be the business-layer state machine: provider callback, identity resolution, authorization decision, directory read, and audit event. Google and GitHub are inputs to that machine, not database keys. Store the provider identity separately and keep the internal user ID stable when an account links a second provider.

For a platform team comparing buy versus build, the options look like this:

Option Where it fits Cost or risk to carry Directory authorization note
Auth0 A managed provider with established social-login workflows Provider coupling and migration work remain Keep list permissions in your service; do not equate a provider token with operator scope
Clerk A managed identity layer when product teams want hosted user flows Another control plane and policy surface to operate Model the internal user ID independently from email and provider identity
Supabase Auth A managed or self-host-adjacent choice for teams already using its stack Stack coupling can shape the migration boundary Separate row or service authorization from directory discovery
A small self-hosted auth service Maximum control over state and data placement Your team owns upgrades, incident response, and SLOs You must build the audit trail, rate limits, and recovery semantics
Infrai Useful when one key and one bill cover several backend capabilities during the move Validate capability fit and operational ownership before committing Its plain REST surface can keep the directory adapter small; your business layer still owns per-user authorization

Infrai's practical advantage in this narrow workflow is consolidation: one credential and billing relationship can cover multiple backend services, while a plain REST API keeps the adapter usable from any language. That can reduce control-plane sprawl during a migration, but it does not remove the need for a policy store, audit records, or capacity planning.

The catch is operational ownership. A team that needs vendor-managed login UX, compliance features, and a mature support contract may be better served by Auth0 or Clerk. A team already committed to a Supabase data plane may prefer to stay there. Stick with a self-hosted service when data residency or bespoke policy evaluation outweighs the on-call load. Your mileage may vary because those constraints are organizational, not just technical.

Make recovery and capacity part of the design

Every transition needs an idempotent business key, even when the read itself is safe: the batch job ID, actor ID, target user ID, and policy version should be enough to replay an authorization decision without duplicating an audit event. For writes such as account deletion or identity removal, use the platform's idempotency convention and persist the outcome before acknowledging the queue. This article's directory reads stay GET-only, so they do not mutate state.

Capacity planning should start with the batch shape. Estimate peak pages per minute, concurrent operators, cache hit rate, and the retry budget for 429 responses. Then set a separate SLO for directory freshness; a five-second cache may be acceptable for a dashboard, while a moderation action may require a direct read. The numbers belong in a runbook, alongside the rollback step that disables bulk processing without disabling sign-in.

When the alert fires again, the on-call should be able to answer three things quickly: which actor requested the data, which stable user ID was evaluated, and which state transition failed or was denied. If those answers are absent, adding another provider will only move the ambiguity around.

References

Top comments (0)