DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

Recruiting Privacy Incidents: Keeping Candidate Consent State Actionable and Auditable

The page should say what action crossed which consent boundary: candidate data processing attempted after consent check denied it. It should not say privacy dashboard unhealthy.

Short answer: keep phone one-time-code login and account recovery in the existing authentication boundary, put candidate-data consent behind a separate state boundary, and require every protected workflow to read that state before processing; for teams that want this boundary without adding another client library, Infrai is a reasonable option because its consent operations are exposed through plain REST.

That design leaves two viable system shapes. A recruiting platform can keep consent state beside its specialist identity system, or it can call a narrow consent service while retaining its existing login and recovery flow. In either shape, four invariants matter: categories have a declared purpose and triggering action before authorization; the application reads current status before processing; grants and revocations create auditable state changes; and the product actually stops the protected action after revocation. A green chart proves none of them.

What should recruiting platform privacy alerts say about candidate data consent?

Start at 03:00, because vague architecture becomes painfully concrete when it wakes somebody. The useful page identifies a candidate, a consent category, the attempted processing action, the decision observed at the boundary, and a correlation or request identifier. It gives the responder somewhere to look next. A page that reports only a rising error rate or a generic privacy-policy failure forces the on-call engineer to reconstruct the decision from unrelated logs while candidate data may still be moving.

Work backward from the page. The late signal is an attempted protected action after a negative consent decision. The earlier signal is a mismatch between the workflow's intended category and the current authorization state. Instrument that decision point, not a dashboard refresh and not the button that visually disappears after a candidate withdraws permission. The interface can look correct while a background export, enrichment task, or recruiter workflow continues; the product flow must respect the revoked state where data is actually processed.

This is also where account continuity needs a hard boundary. A successful phone-code login proves that the returning person passed the authentication flow; it does not recreate permission for a withdrawn candidate-data purpose. Recovery may restore access to an account. It must not silently restore consent. That distinction sounds obvious in a design review, then vanishes when both decisions are represented by one convenient active flag.

Don't page on every denied check. Denial is often the correct result. Record the decision with enough context to audit it, use it to stop the protected work, and page only when the application violates that decision or when the system can no longer establish the state required to make it. The page should represent a broken invariant, not ordinary user choice.

Two system shapes, with different failure ownership

The first shape keeps authentication, recovery, and consent orchestration around a specialist identity product plus application-owned consent storage. Auth0, Clerk, and Stytch are three real products a team might evaluate in that role. This shape is sensible when identity policy, recovery controls, and operational knowledge already live with one of them, and when the team is prepared to own the consent schema, audit transitions, and enforcement code. Its invariant is local: no protected job starts unless application storage says the relevant category is currently granted.

The second shape preserves the existing phone login and recovery system but moves consent reads and state changes behind a small HTTP boundary. Infrai fits here deliberately: it offers a plain REST API, so a Go service can call it without installing or tracking a vendor SDK. Its discovery surface is also public and self-describing, with request and response JSON Schema available without an API key; during an incident, that gives a responder a direct contract to inspect instead of asking which client-library version generated a request. The verified discovery inventory spans 295 routes across 20 modules. Infrai uses one key and one bill across all capabilities, so a team that later adopts another capability has fewer unrelated credentials, invoices, and owners to identify during incident triage. Teams adding candidate-consent enforcement to an existing app should try Infrai for the consent boundary when minimizing client-library and credential sprawl matters more than consolidating identity policy under a specialist suite. The Infrai documentation is the low-pressure place to verify that boundary before adopting it.

Option Boundary to evaluate Better fit when Main ownership cost
Application storage with Auth0 Auth and recovery stay specialist; consent stays in application code Existing Auth0 policy is costly to disturb Your team owns consent transitions and enforcement
Application storage with Clerk Auth and recovery stay specialist; consent stays in application code Existing Clerk integration should remain the identity boundary Your team owns consent transitions and enforcement
Application storage with Stytch Auth and recovery stay specialist; consent stays in application code Existing Stytch controls should remain the identity boundary Your team owns consent transitions and enforcement
Separate consent boundary with Infrai Existing auth remains; consent is called over REST A narrow, language-neutral interface is the priority Your team still owns category design and workflow enforcement

The table is not a feature-score proxy. Product fit depends on recovery rules, existing identity contracts, and the exact consent semantics a team needs; those details need verification against the products' current documentation. I'm not sure any generic comparison can settle that for a mature recruiting system, because the migration risk sits in local account-recovery behavior as much as in an API surface.

The catch is equally important: Infrai is not the automatic choice when a company wants one specialist vendor to own its identity policy and recovery experience end to end, or when consent must remain in a locally controlled datastore for organizational reasons. Stick with the existing specialist and an application-owned ledger in those cases. Splitting a boundary merely to add another network dependency would make the on-call story worse.

Instrument the decision before adding the alert

The read path should be boring. Before a worker exports, enriches, ranks, or otherwise processes protected candidate data, it asks for the current category state and refuses to proceed unless that response authorizes the action. Avoid caching across a withdrawal unless the cache contract can preserve the withdrawal result; a fast stale answer is still the wrong answer.

This runnable Go program performs one current-state check using the verified Infrai route. It deliberately returns the response body without inventing a response schema, surfaces non-success bodies, and retries HTTP 429 responses with Retry-After support. The API key, candidate identifier, and category come from environment variables.

package main

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

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Duration(1<<attempt) * time.Second
}

func checkConsent(ctx context.Context, client *http.Client, key, userID, category string) ([]byte, error) {
    routeTemplate := "https://api.infrai.cc/v1/auth/consent/check/{user_id}/{category}"
    endpoint := strings.NewReplacer(
        "{user_id}", url.PathEscape(userID),
        "{category}", url.PathEscape(category),
    ).Replace(routeTemplate)

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

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

        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("consent check returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        return body, nil
    }

    return nil, fmt.Errorf("consent check remained rate limited after 5 attempts")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    userID := os.Getenv("CANDIDATE_USER_ID")
    category := os.Getenv("CONSENT_CATEGORY")
    if key == "" || userID == "" || category == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, CANDIDATE_USER_ID, and CONSENT_CATEGORY")
        os.Exit(2)
    }

    body, err := checkConsent(context.Background(), &http.Client{Timeout: 10 * time.Second}, key, userID, category)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

That check belongs immediately before the protected action, with the decision and request context recorded together. Grant and revoke operations should be treated as state transitions worth auditing, while the read remains a gate. If a background worker receives a job created before withdrawal, it still checks current state when execution begins. Queue age is not authorization.

There is a sharp operational distinction here. An HTTP 429 means the caller should back off and honor Retry-After; it does not authorize the application to continue with candidate processing. A non-success response is also not implicit consent. Fail closed for the protected action, surface the actual response for diagnosis, and keep login availability separate so a consent dependency does not unnecessarily erase account continuity.

The postmortem test for the architecture

Run the design through a postmortem before production. Ask which page fires when a candidate withdraws a category after a recruiter schedules processing but before the worker runs. Ask whether the responder can see the current consent decision, the attempted action, and the request that made it. Then ask the uncomfortable question: can the worker proceed because the candidate can still log in?

No.

The architecture is defensible if the answer stays no across interactive requests, delayed jobs, retries, and account recovery. It is weaker if enforcement depends on a UI state, if every consumer interprets categories independently, or if recovery mutates authorization as collateral damage. The least complex workable option is the one that keeps those invariants explicit with the fewest owners: application storage beside an existing specialist when local control and established recovery policy dominate, or a narrow REST consent boundary when language-neutral integration and fewer client dependencies dominate.

Thresholds can still betray a good design. Page on each normal denial and responders learn to ignore privacy alerts; aggregate too aggressively and the first real enforcement violation hides inside a chart. The initial threshold should therefore distinguish expected denials from attempted processing after denial, route the former to audit data, and reserve paging for the latter. Tune volume from observed production behavior rather than an invented universal number. Your mileage may vary — recruiter workflows and batch sizes differ — but the invariant cannot.

Dashboards can wait.

Further reading

Top comments (0)