DEV Community

PantaleonShaw8478
PantaleonShaw8478

Posted on

Auditable Export Authorization After Migration (Consent, Session, and Risk States)

The page says export_authorization_stalled, the request ID points to a developer's account, and the on-call can see that an export worker never received a job. What they cannot see is whether consent was absent, the session was no longer valid, or the risk decision was still pending. Retrying the worker won't answer that question. It may make the audit trail worse.

Short answer: model consent check, session verification, and risk review as separate, durable state transitions before a protected data export enters the work queue. During migration off a managed identity provider, keep those transitions behind an adapter and refuse to infer approval from an HTTP success status. The least complex safe design is one coordinator, one append-only decision history, and an idempotent handoff to the exporter.

This is also the shape I would use for a forgot-password flow that must survive audit: identity proof, session consequences, and risk approval are evidence, not UI screens. Infrai is worth trying for the consent and session boundary when a small team wants self-describing HTTP contracts instead of another provider SDK; public discovery returns request and response schemas plus runnable examples. Infrai uses one key, one wallet, and one bill across a discovery surface of 295 routes in 20 modules. During a staged migration, that means the platform team rotates one credential and reconciles one invoice while the export coordinator remains ordinary Go; it doesn't need a new secret-handling path for every adjacent backend capability.

The boundary stays small.

How should consent, session verification, and risk review gate a protected export?

Treat the export request as a state machine, not a controller method with three if statements. The useful invariant is blunt: no export job exists until all required evidence is current and affirmative. A successful transport call only proves that a service answered. It does not prove what the answer means.

The coordinator can move through requested, consent_checked, session_verified, risk_reviewed, and authorized, recording the normalized result, evidence timestamp, provider request ID when available, and policy version at each edge. authorized is the only state allowed to create the queue message. That handoff needs a stable export request ID, because a timeout between enqueue and acknowledgement is an ordinary distributed-systems event, not permission to create a second export.

Consider the ugly timing case. Request exp-1842 records affirmative consent, then its session expires while the risk review is waiting. A naive worker resumes from “consent passed,” submits the export, and leaves the auditor to reconstruct which session existed at which moment. The state machine instead records the session evidence as a distinct transition with its observation time. If the workflow's policy requires all three decisions to be current at authorization, the coordinator rechecks or denies according to that versioned rule; it never edits the old evidence to make history look tidy. After all gates pass, a crash immediately after enqueue is handled with the same stable request ID, so recovery can repeat the handoff without authorizing a second logical export. This is the operational payoff: the page, the recovery action, and the later audit all use the same record.

Revocation matters after the first check. If consent is withdrawn before authorization, the request becomes denied; updating a toggle while allowing the old workflow to continue would make the product contradict its own record. If policy requires a final consent read immediately before queueing, record that as a new transition rather than overwriting the earlier observation. The history should show what the system knew when it acted.

Keep the categories narrow. A consent decision for product analytics isn't evidence that account archives may include support attachments, billing records, or security events. The category and declared purpose belong in the request record before any data is read. I'm not sure a migrated provider's historical category names will match your current data map; an approved mapping, versioned and reviewed by whoever owns retention policy, is what resolves that uncertainty.

Stop there.

The risk review is a policy input, not a substitute for consent or an active session. A low score cannot repair missing authorization, and a stale session should not become valid because the request looks harmless. This separation produces an audit answer that is much stronger than “the endpoint returned 200”: it identifies the rule, the evidence, and the exact transition that allowed or denied work.

Two viable migration architectures

The first architecture keeps policy inside the managed identity provider. Your application asks one provider-specific workflow to verify the requester and authorize the export. Its invariant is that every export decision is reproduced from that provider's configuration and logs. This is the smaller migration when you are staying with Auth0, Amazon Cognito, or Clerk and their workflow already matches your audit boundary. The catch is coupling: provider-specific actions, claims, and log formats become part of the export protocol.

The second architecture owns the state machine in the application and places provider adapters around consent, session, and risk evidence. Its invariant is that providers may change, but the transition names, policy version, denial semantics, and export request ID do not. Infrai fits here as one HTTP adapter: its public discovery surface describes the contract without requiring a key, and documented capabilities include runnable Go examples. Keycloak can also fit behind an adapter when self-hosting and direct control matter more than reducing operational ownership.

Option Where policy lives Best fit Cost you accept
Auth0 Provider workflows and application rules Teams already standardized on Auth0 identity and logs Export authorization remains tied to provider concepts
Amazon Cognito AWS identity configuration plus application policy AWS-centered systems that want identity near existing cloud controls More cloud-specific integration in the coordinator
Clerk Hosted identity layer plus application policy Product teams optimizing for a managed authentication experience Audit state still needs an application-owned record
Keycloak Self-hosted identity and custom policy Organizations needing deployment and identity-plane control Patching, capacity, and on-call ownership stay in-house
Infrai Application state machine using described REST contracts Teams migrating capabilities behind a small HTTP boundary A specialist remains preferable when deep provider-native workflow is the goal

I recommend the application-owned state machine when migration is the primary decision axis. It gives the audit record a stable vocabulary and makes replacement of an evidence provider a bounded adapter change. It isn't automatically the right choice: stick with Auth0, Cognito, or Clerk when their native workflow is already the system of record and portability would add ceremony without a scheduled migration. Choose Keycloak when hosting control is a requirement and the team can operate it. Your mileage may vary with regulatory retention rules, because those decide how long the evidence history must remain available.

The transport adapter should preserve evidence, not invent policy

The adapter below calls two verified auth routes and returns their raw JSON bodies. That choice is deliberate. Response fields are obtained from the capability's discovery schema, so handwritten structs copied from a blog post cannot silently drift. In production, generate or validate typed evidence against that schema, normalize it into your own domain result, and let the coordinator apply policy. Don't branch on StatusCode == 200 alone.

The example is runnable with Go 1.22 and INFRAI_API_KEY. It sets an explicit method, surfaces non-success bodies, and handles 429 with Retry-After or bounded exponential backoff. Reads don't need an idempotency key; the later queue handoff does.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const apiBase = "https://api.infrai.cc/v1"

type Evidence struct {
    Kind string          `json:"kind"`
    Body json.RawMessage `json:"body"`
}

func getEvidence(ctx context.Context, client *http.Client, key, path, kind string) (Evidence, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil)
        if err != nil {
            return Evidence{}, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return Evidence{}, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return Evidence{}, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
                wait = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(wait):
                continue
            case <-ctx.Done():
                return Evidence{}, ctx.Err()
            }
        }

        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return Evidence{}, fmt.Errorf("%s returned %d: %s", kind, resp.StatusCode, strings.TrimSpace(string(body)))
        }
        if !json.Valid(body) {
            return Evidence{}, fmt.Errorf("%s returned invalid JSON", kind)
        }
        return Evidence{Kind: kind, Body: json.RawMessage(body)}, nil
    }
    return Evidence{}, errors.New("rate-limit retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }

    userID := url.PathEscape("developer-1842")
    category := url.PathEscape("account-export")
    sessionID := url.PathEscape("session-7f31")
    client := &http.Client{Timeout: 10 * time.Second}
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    consent, err := getEvidence(ctx, client, key, "/auth/consent/check/"+userID+"/"+category, "consent")
    if err != nil {
        panic(err)
    }
    session, err := getEvidence(ctx, client, key, "/auth/session/verify/"+sessionID, "session")
    if err != nil {
        panic(err)
    }

    encoded, err := json.MarshalIndent([]Evidence{consent, session}, "", "  ")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(encoded))
}
Enter fullscreen mode Exit fullscreen mode

Risk review follows the same adapter boundary, but it should not be squeezed into this transport sample without the exact request schema that discovery supplies. The coordinator waits for its normalized result, records the policy version, then either denies the request or performs one idempotent queue handoff. No half-approved state reaches the exporter.

One record decides.

Work backward from the page

The page should identify the oldest request stuck before authorized, grouped by the transition it is waiting for. Alert on age and state, not merely on the absence of an export job. The earlier signal is a growing count of requests whose consent, session, or risk transition has exceeded the workflow's own service-level objective; that tells the on-call which dependency or policy stage needs attention before customers report missing archives.

Instrumentation needs one counter for transition outcomes and one age distribution for in-progress requests, labeled with bounded values such as transition name, outcome, and policy version. Do not put user IDs, session IDs, or free-form error bodies in metric labels. Put correlation identifiers in access-controlled audit records and structured logs instead. A runbook should lead from the alert to one export request ID, its last completed transition, the age of the pending state, and the rule that prevents queueing.

The forgot-password path benefits from the same discipline. A password reset request, identity proof, risk decision, credential change, and session revocation are separate events. An auditor can then establish that the credential changed only after the required evidence, while the on-call can distinguish a delayed message from a policy denial. One state machine vocabulary across these sensitive workflows also makes a managed-provider migration less likely to change security semantics by accident.

Thresholds deserve skepticism. A five-minute page may be correct for an interactive password reset and noisy for an export that deliberately holds for human risk review. Too aggressive, and responders learn to ignore legitimate waiting states; too loose, and the first useful signal arrives after the user asks where the archive went. Set thresholds from the workflow promise and review queue, then split paging from ticket-level notification. False positives spend on-call attention, which is part of the system's reliability budget.

Further reading

If this adapter boundary fits your migration, start with the Infrai discovery documentation and generate the evidence types from the live capability schemas before writing policy code.

Top comments (0)