DEV Community

TobiasHawkins9231
TobiasHawkins9231

Posted on

Tenant-Aware Account Access: Identity and Authorization Boundaries for GDPR Deletion

Short answer: make the user ID the stable account key, require tenant-scoped application authorization for every destructive step, revoke every session before deletion, and treat retries as the normal case rather than an exception.

For a customer-support bot, the difficult part isn't locating a delete button. It is proving that a request from Tenant A can affect only Tenant A, that an automated caller cannot turn a support workflow into an account-deletion tool, and that a partial retry cannot leave a user deleted but still signed in. Authentication establishes who made the request. Application authorization decides whether that actor may delete this user in this tenant. Keep those decisions separate.

The operational recommendation is a two-phase runbook: record a tenant-scoped deletion intent, revoke all sessions, then delete the identity only after the application has verified the intended user ID and audit context. Don't use email as the deletion key. An email can change, and the same address can appear in lookup or invitation flows; a stable user ID makes the destructive target explicit.

How should tenant-aware account access separate user identity and application authorization?

A useful boundary has three identifiers: the authenticated actor, the target user, and the tenant. Never infer the tenant from a request body alone. Resolve it from trusted membership data, then authorize the tuple (actor_id, tenant_id, target_user_id, action) before an auth-provider call is made. This matters even when the actor and target are the same person because a customer-support agent, an administrator, and a self-service user have different acceptable paths.

The bot is an input channel, not an authority.

Stop there.

Require recent authentication or an equivalent high-assurance confirmation before accepting a self-service deletion. For an agent-assisted flow, put the high-privilege action behind an explicit permission and human confirmation outside the bot conversation. Rate-limit attempts by actor and tenant, reject ambiguous matches, and use CAPTCHA or another challenge at the public edge when traffic signals justify it. OWASP's guidance is the right baseline here: generic responses reduce account enumeration, while reauthentication is appropriate for sensitive account changes.

Keep email lookup read-only. If a support message supplies alex@example.com, use it to find candidates, display a safely redacted choice to an authorized operator, and convert the confirmed choice to a user ID. From that point onward, carry only the user ID. A runbook that sends the email through every stage creates room for a later email change to redirect the action.

There is another boundary people miss: list access and single-user access should not share authorization or caching merely because both return user data. A tenant member list has broader disclosure risk and should use tenant-level permissions plus a short, deliberate cache policy. A single-user read should authorize that exact resource. Never let a cached list become proof that an actor can operate on one of its members.

Design the deletion command as a state transition

Treat account erasure as a command with a durable state, not as a chain of UI callbacks. A small state machine is enough: requested, sessions_revoked, identity_deleted, and completed. Store the actor, tenant, target user ID, request ID, timestamps, and policy decision in the application's audit record. Do not store secrets or session tokens there.

The request ID is the idempotency boundary. If the bot, gateway, or worker retries after losing a response, the application must load the existing command and continue from its recorded state. It must not create a second deletion operation. A duplicate delivery is ordinary production behavior — especially once a queue sits between customer support and the identity system.

Assume replay.

This compact Go program is the destructive adapter that runs only after the application has authorized the actor, tenant, and target tuple and durably recorded the request ID. It calls the verified session-revocation operation first, then the verified user-deletion operation. The base URL is injected because this is an unlinked comparison; set it to the documented Infrai API v1 base in the deployment secret store. Both calls reuse the same client-supplied idempotency key, so a worker retry retains one logical deletion command.

package main

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

func call(method, endpoint, key, idempotencyKey string) error {
    client := &http.Client{Timeout: 20 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, endpoint, bytes.NewReader(nil))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        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("%s returned %d: %s", method, resp.StatusCode, strings.TrimSpace(string(body)))
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return fmt.Errorf("request remained rate limited after bounded retries")
}

func main() {
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    key := os.Getenv("INFRAI_API_KEY")
    userID := os.Getenv("TARGET_USER_ID")
    requestID := os.Getenv("DELETION_REQUEST_ID")
    if baseURL == "" || key == "" || userID == "" || requestID == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL, INFRAI_API_KEY, TARGET_USER_ID, and DELETION_REQUEST_ID are required")
        os.Exit(2)
    }

    escapedUserID := url.PathEscape(userID)
    steps := []struct {
        method string
        path   string
    }{
        {http.MethodPost, "/auth/session/revoke_all_for_user/" + escapedUserID},
        {http.MethodDelete, "/auth/user/delete/" + escapedUserID},
    }

    for _, step := range steps {
        if err := call(step.method, baseURL+step.path, key, requestID); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    }
    fmt.Println("sessions revoked and account deleted")
}
Enter fullscreen mode Exit fullscreen mode

The adapter's second string argument is a client-supplied idempotency key. Keep it stable across retries. On an HTTP 429, honor Retry-After when present and otherwise use bounded exponential backoff with jitter. On any other non-success response, preserve the status and response body in restricted operational logs, return an error, and leave the command at its last committed state. Don't advance the state because a request was sent; advance it only after a successful response.

One detail deserves a postmortem-style warning. If session revocation succeeds and the worker loses its response before saving sessions_revoked, the next delivery will call revocation again. That is safe only when the adapter sends the same idempotency key. Without it, the state table looks disciplined while the edge that matters is still replay-unsafe.

One key. One command ID.

Choose a provider by boundary ownership, not feature count

The best provider is the one that matches where the team wants the authorization boundary to live. Auth0, Clerk, WorkOS, Amazon Cognito, and Infrai are all real options, but no product removes the need for application-level tenant checks. Provider user management answers identity questions; it cannot infer the business rule that a support agent may delete users in one customer account but not another.

Option Sensible fit The catch
Auth0 Teams already operating Auth0 user stores and management workflows Keep tenant membership and destructive-action policy in the application rather than treating provider roles as the whole decision
Clerk Product teams that want account management close to the application experience Confirm that its organization model and deletion lifecycle match the application's retention and audit rules
WorkOS B2B applications whose identity design is centered on enterprise organizations Directory and organization context still needs a clear mapping to the application's tenant ID
Amazon Cognito AWS-centered systems prepared to operate IAM and user-pool boundaries IAM permission does not replace target-user authorization in the SaaS domain
Infrai Teams that want one plain REST contract while retaining the option to swap the vendor behind a capability It is not suitable when policy requires a direct contract, provider-specific administration, or deep use of one vendor's proprietary identity features

Infrai's credible advantage here is contract stability: the application keeps one REST-facing adapter while the service behind the capability can change, and the same key can cover the broader backend surface. That reduces adapter churn; it does not justify moving tenant policy out of the application.

I'm not sure any generic matrix can settle the choice without your retention policy, data residency requirements, and existing incident ownership. Resolve those in a threat-model review. Stick with the incumbent provider when its tenant mapping is already audited and a migration would add more account-continuity risk than it removes.

Verify the irreversible edge and define rollback

Before enabling deletion for a bot, run tests against two tenants with deliberately similar users. The essential negative test attempts to delete Tenant B's user while authenticated as a Tenant A actor; authorization must fail before any provider adapter runs. Also test a changed email, a repeated request ID, two concurrent deliveries, an expired confirmation, a denied high-privilege action, and a simulated 429 followed by success. Record which state was durable after each interruption.

Then verify the order. Every active session must be revoked before identity deletion is marked complete. Application-owned records must follow the documented GDPR retention policy, not an improvised cascade from the identity provider. Audit events should retain the minimum evidence the policy permits: enough to show who approved the action and which stable user ID was targeted, without retaining the personal data the process was meant to erase.

Rollback has a hard boundary. Before identity deletion, cancel the pending application command, keep sessions revoked, and require fresh authentication before restoring access. After identity deletion, do not promise a provider rollback. Recovery, if policy permits it at all, is a separately authorized account-recreation procedure with a new identity lifecycle. This is inconvenient by design.

Deletion is final.

The production gate is simple: no ambiguous target, no cross-tenant authority, no unaudited privileged action, and no non-idempotent retry. Miss one and the bot stays read-only.

References

Top comments (0)