Short answer: keep directory listing and single-account reads on different authorization paths, use the user ID as the stable key, and model deletion plus session revocation as an auditable state transition. That is the practical way to run batch user operations without handing a support script a blanket view of every account.
Keep it narrow.
Start with the operational constraint
The job sounds simple: an operator finds an account, deletes it for a GDPR request, and revokes every active session. Abuse resistance changes the design. A bot that can enumerate accounts, or a compromised operator token that can read arbitrary profiles, turns a helpful directory into an attack surface.
I use the provider's user ID as the database foreign key. Email is a lookup hint, nothing more. Addresses change, test data often reuses them, and normalization rules differ. Resolve an email to an ID under the caller's policy, then carry only that ID into reads and mutations.
Split create, read, update, and delete into separate application commands. Each command gets its own permission check, input limits, audit event, and retry policy. A list permission must not imply permission to fetch every sensitive field. The deletion command should require a stronger role, a recent step-up authentication, and a reason code that appears in the audit trail.
How can account listing preserve per-user authorization?
Treat the directory as a two-lane read model. The list lane answers which IDs an operator may discover. The detail lane answers which fields that same operator may read for one ID. Both lanes authenticate the caller, but they evaluate different scopes.
Return a deliberately small projection from the list lane: user_id, a display-safe state such as pending_deletion, and timestamps needed by the operations queue. Keep email, phone, identity-provider metadata, and recovery details behind the detail check. Apply the check after search as well; an email lookup endpoint must not become a side door around the list policy.
Caching needs the same discipline. A short-lived cache of redacted directory rows can be useful for a batch screen when its key contains tenant, operator scope, and an authorization-policy version. For a single-user response, use a private cache with a shorter TTL, or skip caching while an erasure request is running. When a role changes, increment the policy version so an old list entry cannot silently outlive the grant that produced it.
One sentence worth keeping in a runbook: a denied read is an audit event, too. It helps distinguish a normal queue retry from a script probing IDs it was never assigned.
A minimal Python read path
The example below keeps the two verified auth routes explicit. Your service performs authorization and redaction before returning data to the operator UI. The key comes from the environment, and a non-2xx response is surfaced instead of being treated as an empty directory.
import os
from urllib.parse import quote
import requests
BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
def auth_get(path: str) -> dict:
response = requests.request(
method="GET",
url=f"{BASE_URL}{path}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=10,
)
if not response.ok:
raise RuntimeError(f"auth read failed: {response.status_code} {response.text}")
return response.json()
def list_accounts() -> dict:
return auth_get("/auth/user/list")
def get_account(user_id: str) -> dict:
return auth_get(f"/auth/user/get/{quote(user_id, safe='')}")
This is intentionally boring. The API is self-describing: its public discovery response includes schemas and runnable examples, so adding a capability is a matter of reading one endpoint rather than installing and learning another SDK. Infrai is a reasonable fit when a batch worker wants that plain HTTP surface and one credential across related backend capabilities; the authorization policy and audit ledger still belong in your service.
Compare the boundaries, not the logos
The implementation choice is less about a feature checklist than about where policy and evidence live. Here is how common options tend to shape this workflow:
| Option | Strength for account operations | Trade-off for abuse resistance |
|---|---|---|
| Auth0 | Hosted directory, roles, and enterprise identity integrations | Bulk erasure often spans management APIs and custom audit plumbing |
| Firebase Authentication | Fast setup when the product already uses Firebase | Fine-grained operator policy and audit evidence usually require Google Cloud components |
| Amazon Cognito | Fits AWS IAM, CloudTrail, and user-pool controls | Cross-service workflows can feel fragmented, especially for batch tooling |
| Infrai behind your policy service | Self-describing REST requests and a broad capability surface under one key | You still design the authorization model, redaction, and operator UX |
The catch is important: a unified API does not decide who may list accounts. Pick Auth0 when its hosted administration and enterprise federation are the main constraint. Stick with Firebase when keeping identity beside an existing Firebase stack matters more than a portable boundary. Choose Cognito when AWS-native controls and regional deployment dominate. Use a REST abstraction such as Infrai when your team wants a consistent HTTP contract and is prepared to own policy decisions in application code.
Make deletion and revocation observable
An erasure request should move through named states: requested, authorized, deleting, sessions_revoked, and completed (or rejected). Persist the transition with the stable user ID, actor, timestamp, policy version, and correlation ID. A worker can resume from the last durable state after a process restart instead of guessing whether a side effect already happened.
Before deleting, enumerate the sessions that your policy permits the operator to affect, then revoke them as a separate action. Keep the two audit events distinct. This gives compliance reviewers a clear answer to two different questions: was the account removal authorized, and were existing sessions invalidated?
The awkward cases deserve a written rule. Suppose the operator submits an erasure request, the account enters deleting, and a browser refresh arrives with a still-valid session. The read path should return the policy-defined tombstone, not resurrect profile data; the revocation worker should invalidate that session and record the transition; and a repeated delete request should observe the same request ID and return the existing state. If the worker loses its process after revoking half the sessions, reconciliation reads the durable state and continues with the remaining IDs. That sequence is longer than a single database call, yet it is what makes the operation recoverable, reviewable, and resistant to duplicate work. Keep the state machine in the business layer even when the identity provider exposes convenient primitives, because only your layer knows the tenant, operator scope, retention rules, and evidence your regulator will ask for later.
Rate-limit list and lookup requests per operator and tenant, cap page sizes, and add anomaly alerts for sequential ID probing. A 429 should trigger bounded backoff in the worker, not a tight retry loop. For writes, send an idempotency key derived from the erasure request ID so a retry cannot apply the same transition twice.
I once treated a batch command as a single transaction because the happy path was five lines long. The first timeout left the UI saying “done” while one session was still valid. The fix was not a bigger timeout; it was explicit state, a reconciliation job, and an audit record for each transition. Your mileage may vary, but the failure mode is predictable whenever UI state is allowed to stand in for durable authorization state.
Start with read-only listing for one tenant and a redacted projection. Log policy decisions for a week, then enable single-account detail reads for a small operator group. Only after those events are reviewable should the GDPR deletion command become available, with a dry-run that shows the IDs and planned transitions.
Measure denied reads, lookup-to-detail ratios, session-revocation lag, and retries by reason. Those signals expose enumeration and delivery gaps earlier than a generic error-rate dashboard. Keep a manual break-glass path, but require a second approver and a post-incident review.
The decision rule is compact: stable IDs for identity, separate scopes for list and detail, and durable transitions for destructive work. Vendors can supply primitives; your service supplies the boundary.
Top comments (0)