Short answer: keep account listing as a narrowly scoped, audited operation, and make every returned row pass a per-user authorization check before it reaches a batch-operations screen. For a media service scoring login risk from device fingerprints, recovery policy is the deciding constraint: an operator may need to find many accounts, but that doesn't grant access to every account's recovery factors.
The experiment note: broad search versus bounded disclosure
The tempting implementation is a directory endpoint that accepts a substring and returns full account objects. It's easy to wire into a notebook, and it looks productive in a first demo. It also turns one missing filter into a bulk disclosure event.
The safer design separates discovery from disclosure. Search returns opaque account identifiers and a small set of operational fields. A second, policy-checked operation retrieves the minimum fields needed for a specific action. The directory query has a tenant, role, purpose, and page-size limit attached to it; those values are authorization inputs, not UI decoration.
An eval-driven check helps here. A fixture contains a support operator, a fraud analyst, and two customers whose device fingerprints overlap. The expected result isn't merely “the right rows.” It includes which columns are visible, whether a recovery action is allowed, and whether the audit event contains a reason. A query that returns 100 permitted IDs but one unauthorized email still fails the eval.
That constraint changes the shape of the code. The listing function should accept a caller and a policy context, then project fields explicitly:
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class Caller:
subject: str
tenant: str
roles: frozenset[str]
def list_accounts(caller: Caller, query: str, accounts: Iterable[dict]) -> list[dict]:
if "directory:read" not in caller.roles:
raise PermissionError("directory read is not allowed")
matches = []
for account in accounts:
if account["tenant"] != caller.tenant:
continue
if query.casefold() not in account["display_name"].casefold():
continue
matches.append({
"account_id": account["account_id"],
"display_name": account["display_name"],
"risk_band": account["risk_band"],
})
return matches[:50]
The projection deliberately omits recovery email, phone, reset tokens, and raw fingerprint material. Those values belong behind separate checks. Short code. Big boundary.
No exceptions.
Before copying this pattern, measure authorization-denial rates, fields exposed per request, page depth, and the percentage of recovery actions that have a matching audit reason. Your mileage may vary if your directory is partitioned differently; the measurements tell you where the policy actually needs to move.
How should user directory operations list accounts without weakening per-user authorization?
Treat listing as an authorization problem at two levels. First, authorize the collection query: tenant, role, purpose, rate, and maximum result count. Second, authorize each account selected for a follow-up action. A caller can be allowed to list suspicious accounts while still being denied a recovery-factor change for one of them.
This distinction matters for device fingerprints. A fingerprint is a risk signal, not proof of ownership. Two household members may share a browser profile, and a newsroom may route many people through one egress address. Use the signal to prioritize review, then require account-specific evidence before changing recovery paths.
Keep policy decisions server-side. Don't trust a hidden column, a disabled button, or a role value posted by the client. Bind the policy input to the authenticated subject and tenant, and record the policy version with the decision. When a batch job runs, give it a short-lived credential scoped to one operation rather than reusing an interactive administrator session.
Data shape, pagination, and privacy controls
Cursor pagination is preferable to offset pagination for a directory that changes while an operator is working. The cursor should be opaque, signed, scoped to the caller and query, and short-lived. Reject a cursor when its tenant, policy scope, or expiry no longer matches the current request.
Return stable identifiers, not mutable usernames, as the action key. Normalize search input, cap the result size, and apply a server-side timeout. Exact-match and prefix search can be safer than arbitrary substring search because they reduce accidental broad scans, but the right choice depends on the operator workflow and the index available.
Privacy is part of the schema. Mask contact values by default, separate sensitive fields into a different permission, and avoid returning raw device fingerprints. If analysts need correlation, provide a one-way, rotating representation whose scope is documented; a permanent hash can become an unintended tracking identifier.
A useful review table looks like this:
| Operation | Default result | Extra authorization | Audit requirement |
|---|---|---|---|
| Search directory | ID, display name, risk band | Directory-read role and tenant match | Query, count, policy version |
| Open account summary | Recovery status, recent risk decision | Account-read decision for that ID | Subject, reason, fields |
| Start recovery | Recovery channel choices, no secret values | Recovery-operation role plus step-up authentication | Approval, target, expiry |
| Change recovery path | Newly enrolled factor metadata | Independent account policy and confirmation | Before/after state, approver |
The table is a contract for the UI and the API. It also gives an eval harness concrete assertions instead of vague “admin can manage users” tests.
Failure modes that appear only in batch user operations
The most common mistake is checking authorization once, before a loop, and assuming every row has the same policy. Consider a 50-account page returned to a support queue: one account may be in a tenant with a delegated support contract, another may be under a legal hold, and a third may have been suspended after the cursor was issued. If the worker trusts the page-level decision, it can apply a recovery change to the wrong row even though the initial search was allowed. The worker should load the current policy snapshot, verify the account and requested action together, record a deny reason when they don't match, and continue without exposing the protected fields. Re-check the action at execution time, and make the batch idempotent so a retry can't enroll a second recovery factor. A dry-run mode that emits only account IDs and decision codes is useful for reviewing a batch before it has side effects.
Another trap is logging too much. A debug statement that includes the full request body can copy recovery addresses and fingerprint details into a less protected log store. Log identifiers, decision outcomes, and reason codes; keep sensitive values out of ordinary traces.
A simple “risk above threshold” rule isn't enough for recovery triage. A useful eval separates “new device, known account” from “known device, unusual recovery request,” then tests both against the same listing permissions. The second case needs stronger evidence even though its device score may be lower.
Use explicit failure semantics: deny by default when policy data is missing, return a generic authorization response to the client, and attach a correlation ID for operators. HTTP 403 means the server understood a request but refused to fulfill it; it isn't a cue to reveal which protected field or policy rule caused the denial. Don't turn a timeout from a policy dependency into an allow decision. A small amount of friction is preferable to silently widening per-user access.
Choosing an implementation boundary
A single service can own search, policy, and audit when the team is small and the data model is simple. Split those responsibilities when independent teams need to change policy or when audit retention has different controls. Either way, keep the authorization decision close to the protected data and make the interface boring: typed inputs, explicit projections, bounded pagination, and predictable denial.
The catch is operational: strict per-row checks add latency and can make a large batch feel slow. That approach is not suitable when an offline export genuinely needs every field; use a separately approved export workflow with its own dataset, retention, and review. Stick with a narrower directory when the goal is live support or fraud triage. Don't weaken user authorization to make a table load faster.
Finally, watch token and query cost in the eval harness. Cache only non-sensitive policy metadata, keep prompts and explanations compact, and sample full traces rather than storing every raw input. Notebook-to-prod workflows stay healthy when the same authorization fixtures run in CI, staging, and the batch worker.













