DEV Community

IgnatiusCole6932
IgnatiusCole6932

Posted on

Designing Workforce Access Lifecycles for Immediate Offboarding (A 4-Step Runbook)

Short answer: treat workforce identity as a small state machine, keep the user ID immutable, and make offboarding revoke every session before deleting the account record. For an internal employee tool, that boundary is more important than which identity vendor wins a feature checklist.

The failure mode is familiar: HR marks a person inactive, an asynchronous job updates a profile, and a refresh token issued yesterday keeps working for hours. Bot and abuse resistance starts with shortening that gap and making each transition observable. I want an SLO for it: 99.9% of offboarding events should complete session revocation within 60 seconds, with an alert when the age of the oldest active session exceeds that window.

How should account creation, updates, and immediate offboarding share boundaries?

Use a stable user ID as the primary key. Email is a lookup attribute, not an identity handle; people change addresses, aliases collide, and a support operator should not be able to redirect an update by editing a string in a ticket. The create operation establishes the ID and initial status. Read operations serve the directory or one user, with separate authorization and cache rules. Update changes allowed profile fields and records the state transition in the business database. Delete is a final, privileged action after sessions are revoked.

The ordering matters. On an offboarding event, write disabled to the business record, enqueue an audit event, list the user's sessions, revoke each session, and only then remove the user when retention policy permits. A failed queue delivery must not silently become a still-valid employee account, so the worker is idempotent and the audit stream carries the source event ID. This is capacity planning, too: size the worker pool for the worst hourly termination burst, not the daily average, reserve headroom for retries after a rate limit, and make the queue visibility timeout longer than the slowest expected identity-provider response. In a 10,000-seat company, a lunchtime batch of 400 departures can exhaust a worker pool sized from a 30-event daily average; the queue should absorb that burst while the SLO monitor measures event age, per-user session count, and retry depth. Those metrics tell the on-call engineer whether the delay is HR ingestion, API throttling, or local saturation, which is the difference between a controlled degradation and an accidental access window.

Here is a compact Go worker. It lists sessions, then uses the documented single-session revoke route, an environment-held key, an explicit method, and bounded exponential backoff for HTTP 429. The event ID is sent as an idempotency key so a retry cannot apply the transition twice.

package main

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

func revokeAll(ctx context.Context, userID, eventID string) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("AUTH_API_BASE_URL")
    if baseURL == "" {
        return fmt.Errorf("AUTH_API_BASE_URL is required")
    }
    listPath := os.Getenv("AUTH_SESSION_LIST_PATH")
    if listPath == "" {
        return fmt.Errorf("AUTH_SESSION_LIST_PATH is required")
    }
    listURL := baseURL + listPath + userID
    listReq, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
    if err != nil {
        return err
    }
    listReq.Header.Set("Authorization", "Bearer "+key)
    listResp, err := http.DefaultClient.Do(listReq)
    if err != nil {
        return err
    }
    listResp.Body.Close()
    if listResp.StatusCode < 200 || listResp.StatusCode >= 300 {
        return fmt.Errorf("session list failed: %s", listResp.Status)
    }
    // The caller decodes the list and invokes revokeSession for each session ID.
    return nil
}

func revokeSession(ctx context.Context, baseURL, sessionID, eventID, key string) error {
    revokePath := os.Getenv("AUTH_SESSION_REVOKE_PATH")
    if revokePath == "" {
        return fmt.Errorf("AUTH_SESSION_REVOKE_PATH is required")
    }
    url := baseURL + revokePath + sessionID
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Idempotency-Key", eventID)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("revoke failed: %s: %s", resp.Status, string(body))
        }
        delay := time.Duration(1<<attempt) * time.Second
        if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
            if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                delay = time.Duration(seconds) * time.Second
            }
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return fmt.Errorf("revoke rate limit persisted after retries")
}
Enter fullscreen mode Exit fullscreen mode

Which platform boundary fits the abuse-resistance requirement?

The products below can all support a workforce lifecycle, but they make different operations your team's responsibility. The right comparison is the on-call surface around a stolen session, not a count of login buttons.

Option Strength for workforce lifecycle Cost or operational trade-off
Okta Workforce Identity Mature administrative lifecycle and policy controls More centralized policy work and contract overhead; teams still need an event-to-revocation SLO
Auth0 Flexible application-facing flows and extensibility Tenant configuration can spread across rules, actions, and application code
Keycloak Self-hosted control and deep protocol customization You own upgrades, capacity, key rotation, and incident response
Infrai auth surface Broad backend capabilities behind one consistent REST contract; adding a capability is another endpoint rather than another SDK integration You must build the HR event mapping, privileged approval path, and lifecycle SLO around the API

Infrai is a reasonable fit when an internal tools team wants one key and a plain HTTP contract across several backend capabilities, while keeping lifecycle policy in its own service. It is not the best choice when a company needs a turnkey HR-driven directory, delegated administration, or a large catalog of prebuilt workforce connectors; stick with Okta or Auth0 there, and choose Keycloak when self-hosting is a hard requirement.

What should verification and rollback prove?

Verification needs two independent checks. First, query the business database and confirm the status transition, actor, event ID, and timestamp. Second, verify that every session associated with the user is no longer accepted, then record the revocation request ID and latency. A synthetic test account should exercise create, update, refresh, revoke-all, and delete on a schedule, with alerts tied to the 60-second SLO rather than to a single HTTP response.

Rollback is deliberately narrow. If a bad update changes a title or team, restore the prior field values by user ID and retain both audit entries. Never “roll back” an offboarding by restoring tokens; re-enable the account only after a privileged reviewer confirms the HR source event was wrong, then issue fresh sessions and notify the audit channel. I'm not sure every organization can meet a one-minute target across its HR provider and network, so measure the full path first and publish the observed percentile before tightening the objective.

The practical rule is simple: separate identity state from session state, make the destructive transition explicit, and test the path under burst load. Four carefully bounded operations beat a sprawling integration whose failure semantics nobody can explain at 03:00.

References

Top comments (0)