Skip to content

Read users' emails and names from Auth0 for the admin user routes - #4283

Draft
anth-volk wants to merge 38 commits into
masterfrom
feat/v2-admin-user-auth0-profiles
Draft

anth-volk wants to merge 38 commits into
masterfrom
feat/v2-admin-user-auth0-profiles

Conversation

@anth-volk

Copy link
Copy Markdown
Collaborator

Fixes #4282

Summary

  • Adds an optional, read-only settings group for Auth0 user profiles: AUTH0_USER_PROFILES_DOMAIN, AUTH0_USER_PROFILES_CLIENT_ID, and AUTH0_USER_PROFILES_CLIENT_SECRET_RESOURCE or AUTH0_USER_PROFILES_CLIENT_SECRET.
    • It is validated like the account-deletion settings and also needs PUBLIC_API_AUTH_ISSUER.
    • It shares the Management API client's code and token cache with the deletion settings.
    • build_auth0_user_profile_reader returns a read-only UserProfileReader, with no deletion.
    • The AUTH0_MANAGEMENT_* deletion settings are unchanged and stay production-only: staging's live tests delete their own account through DELETE /v2/me.
  • Adds two profile reads to the client:
    • read_user_profiles: one GET /api/v2/users search per 50 user IDs (q=user_id:("…" OR "…"), search_engine=v3, sort=user_id:1, only the profile fields), with quotes and backslashes escaped.
    • search_user_ids_by_email: escapes every Lucene special character and whitespace, and uses email:*text* (or email:text* below three characters). It reads sorted pages of 50, counting each user once, and stops once more than the match limit match. A full last page counts as more than the limit.
    • Each operation sends all its requests, including the token request, through one HTTP client.
    • A 429 is retried once after the wait Auth0 gives (Retry-After first, else X-RateLimit-Reset minus now), when that wait fits in what's left of a RateLimitWaitBudget (policyengine_api.libs.rate_limit_wait_budget). Each admin request starts one 2-second budget, shared by all its Auth0 calls. A call given no budget gets its own, and a negative or non-finite wait is never taken.
    • Every failure becomes one "profiles unavailable" error that names a cause without personal data: http_<status>, timeout, connection_error, unreadable_answer, token_http_<status>, token_unreadable_answer or secret_unavailable.
  • The admin user routes take email, first_name and last_name (and the history's changed_by_email) from Auth0, for identities from the users' issuer. The most recently seen identity comes first, a verified email is preferred, and stored values fill any gaps. Nothing read from Auth0 is stored.
  • An email search lists users found through Auth0 together with users whose stored email matches, newest first, with the same offset, limit and has_more. It answers 422 for:
    • text with < or >, before the profile settings are read or Auth0 is called;
    • text that more than MAXIMUM_EMAIL_SEARCH_MATCHES (200) Auth0 users match ("More than 200 users match that email text; enter more of it"). Exactly 200 are served.
    • Without the profile settings, the search reads stored emails as before.
  • When Auth0 can't answer, the routes return stored values and record admin_user_profiles_unavailable, with the operation, the error type and a safe cause (unknown unless it is a short identifier). A type change still commits, and an email search answers 503.
  • Readiness, validate_cloud_run_deploy_env.sh, deploy_cloud_run_candidate.sh, and both deploy jobs in push.yml carry the new settings: variables for the domain and client ID, a secret for the secret resource. The Stage 12A design doc, the OpenAPI descriptions (whose limit comes from the constant), the testing skill and a changelog fragment are updated.
  • The new wait-budget module is added to mypy's files and to rule api.quality.mypy-v2's paths in rules/registry.toml, with docs/generated/rules.md re-rendered. This change to the rule machinery needs approval from a core developer other than the author.
  • Response shapes are unchanged. No migration contract changes, and no Flask routes are affected.

Operator steps, not in this PR:

  • Create a client with only read:users in each environment's users' tenant (policyengine.uk.auth0.com for production, policyengine-dev.uk.auth0.com for staging), on the canonical *.auth0.com domain.
  • Set AUTH0_USER_PROFILES_DOMAIN and AUTH0_USER_PROFILES_CLIENT_ID as GitHub environment variables, and AUTH0_USER_PROFILES_CLIENT_SECRET_RESOURCE as a GitHub environment secret, in staging and production.
  • Store each client secret in Secret Manager, and grant that environment's Cloud Run runtime service account roles/secretmanager.secretAccessor on it.

Until then, admins see stored values as today. Unverified until then: the GitHub variables and secret, the Secret Manager secrets and their access grants, and each client's read:users grant.

Shares files with #4269 and #4272 (the admin routes, the user-type service and, for #4272, the Auth0 Management module); whichever merges later resolves the conflicts.

Testing

  • Unit tests for the Auth0 client, using httpx.MockTransport and a recorded wait instead of sleeping; no network calls (tests/unit/libs/test_auth0_management.py). They cover:
    • the settings and the issuer requirement;
    • 50-ID groups, escaping, sorting, paging and deduplication;
    • the match limit: exactly the limit, duplicates reaching it, and a full last page;
    • < and >;
    • the 429 retry: header precedence, non-finite waits, the refreshed token, a shared budget, and a budget of its own for each call made without one;
    • one HTTP client per operation, and each failure's cause.
  • Unit tests for the wait budget (tests/unit/libs/test_rate_limit_wait_budget.py): its module loads no other application module, the admin service loads it but not the Auth0 module, it spends waits that fit, and it refuses negative and non-finite ones.
  • Service tests with a stand-in for Auth0 (tests/unit/v2/test_admin_user_profile_service.py). They cover:
    • filling, verified-email order, and other issuers in the list and the history;
    • stored values and the recorded cause when Auth0 or the settings fail (list, single read, PATCH and history), and unsafe causes;
    • the 422 refusals and their order, the 503, and the default profile source;
    • one wait budget per admin request, for the list, the single read, the type change and the history.
  • Route tests for the 422, the 503 and the OpenAPI descriptions (tests/unit/v2/test_admin_user_routes.py). Readiness, deploy validation, candidate deploy and push workflow tests cover the new settings.
  • A PostgreSQL test runs the email search as the runtime role (tests/integration/test_v2_user_type_persistence.py::test_the_runtime_role_finds_users_by_their_auth0_email).
  • The Management API calls these reads make (GET /api/v2/users/{id}, a search by several user IDs joined with OR, prefix, suffix and text-anywhere email searches, and sort=user_id:1) were tried by hand, read-only, on the production users' tenant.
  • Ran lint-imports, python -m scripts.rules validate, python scripts/run_quality_guards.py, ruff check, ruff format --check, mypy, scripts/export_migration_contracts.py (no diff) and the changelog fragment check.
  • Reviewed in three adversarial rounds. Round 1 found two bugs (rate-limited broad searches answering 503, and </> turning a short search into a range query), fixed test-first; rounds 2 and 3 found none, and their quality findings are fixed.

Inline suppressions added: none.

🤖 Generated with Claude Code

anth-volk and others added 30 commits November 11, 2026 23:10
The admin routes need each user's email and name from Auth0, read when
an admin asks and never stored. These tests describe a second, optional
settings group (AUTH0_USER_PROFILES_*), separate from the production-only
deletion settings, and the Management API client's two reads: profiles for
a set of user IDs, one search per 50 IDs, and user IDs found by part of an
email, with every query character escaped and paging stopped at Auth0's
1,000-result limit. Every failure is one "profiles unavailable" error whose
message carries no email, user ID, token or secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AUTH0_USER_PROFILES_DOMAIN, _CLIENT_ID and _CLIENT_SECRET or
_CLIENT_SECRET_RESOURCE configure a client for reading users, validated
like the deletion settings by the same loader, and built through the same
shared-client token cache. The AUTH0_MANAGEMENT_* deletion settings and
their messages are unchanged.

read_user_profiles searches Auth0 for each group of at most 50 user IDs
(q=user_id:("..." OR "..."), search engine v3), quoting each ID with its
quotes and backslashes escaped. search_user_ids_by_email escapes every
Lucene special character and whitespace in the text, uses a leading
wildcard only from three characters on, and pages 50 at a time up to
Auth0's 1,000-result limit, saying whether it reached it. Any failure,
the token request included, raises Auth0UserProfilesUnavailableError,
whose message names at most an HTTP status or an error type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The token request parses its answer with response.json(), which raises
ValueError for a body that isn't JSON. A profile read now reports that as
Auth0UserProfilesUnavailableError too, so callers handle one error type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a profile reader configured, the admin user list, a single user,
a type change's response and the type history take each user's email
and names from Auth0, looking up only identities from the users'
issuer: the most recently seen identity first, a verified email before
an unverified one, stored values when Auth0 has none. An email search
finds users through Auth0 as well as by their stored emails, with the
same paging. When Auth0 is unavailable, reads keep stored values and
record a WARNING without personal data, a type change still commits,
and an email search answers 503. Callers below admin never reach Auth0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin user list, a single user, a type change's response and the type
history now take each user's email and names from Auth0 through the
user profile reader, read after the database work and never stored. Only
identities whose issuer is the users' issuer are looked up, the most
recently seen first, one profile read per page; a verified email comes
before an unverified one, and stored values stay where Auth0 has none.

With profiles configured, an email search also lists the users with an
identity among the subjects Auth0 found for the text, together with the
users whose stored email matches, newest first, with the same offset,
limit and has_more. Without them, the search reads stored emails only,
as before.

When Auth0 can't answer, reads answer with stored values and record the
admin_user_profiles_unavailable WARNING with the operation and error type
only; a type change commits before its response reads Auth0; and an email
search answers 503 (AdminUserProfilesUnavailableError). The admin check
runs before any Auth0 search. Response shapes are unchanged; the OpenAPI
descriptions say where emails and names come from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A partial AUTH0_USER_PROFILES_* group, or one with a URL for its domain,
now fails readiness, which the deploy waits on, as the deletion settings
already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both deploy jobs in push.yml now pass AUTH0_USER_PROFILES_DOMAIN and
AUTH0_USER_PROFILES_CLIENT_ID from their GitHub environment's variables,
and AUTH0_USER_PROFILES_CLIENT_SECRET_RESOURCE from its secrets, as the
production job passes the AUTH0_MANAGEMENT_* settings.
validate_cloud_run_deploy_env.sh requires the three together or none, and
allows them in staging: the client only reads users, so it can't delete
the staging CI user as the deletion settings could.
deploy_cloud_run_candidate.sh writes them to the revision only when set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stage 12A design doc says where the admin routes' emails and names
come from, how the email search queries Auth0 and its limits, what each
route does when Auth0 can't answer, the AUTH0_USER_PROFILES_* settings
and the operator steps for the read-only client. The testing skill lists
the new service tests, and a changelog fragment describes the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The email search now lists users with an identity among the subjects
Auth0 found. This runs that read as the Public API's runtime role on
PostgreSQL, finding a user with no stored email through a stand-in for
Auth0, and checks that nothing read from Auth0 is stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…turns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e reads

One admin request can make several Management API requests in a row,
and on Auth0's Free plan (2 requests a second) the later ones get 429.
These tests describe a profile read or search that waits the time Auth0
gives (Retry-After, or X-RateLimit-Reset less the current time) and
retries once when that is at most 2 seconds, sends all of one operation's
requests through one HTTP client, sorts every search page by user ID so
pages don't shift, and gives every failure a cause without personal data
(the HTTP status, timeout, connection_error, unreadable_answer, or the
token request's status), so a missing read:users grant can be told from
rate limiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A profile read or email search now sends its token request and all its
searches through one httpx.Client, so one connection serves them, and
sorts every page by user ID (sort=user_id:1). A 429 is retried once after
the wait Auth0 gives, from Retry-After or X-RateLimit-Reset less the
current time, when that wait is at most 2 seconds; otherwise, or when the
retry is rate limited too, profiles are unavailable as before.

Every Auth0ManagementError now carries a cause without personal data:
http_<status>, token_http_<status>, token_unreadable_answer, timeout,
connection_error, unreadable_answer or secret_unavailable. A token answer
that isn't JSON raises Auth0ManagementError rather than ValueError.
Account deletion's requests and messages are otherwise unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A common text such as gmail matches many Auth0 users, and paging through
up to 1,000 of them takes 20 Management API requests in a row. These
tests describe a search that stops after 200 matching users (four pages
of 50, and a fifth to see whether more match), counts each user once
across pages, and is refused with 422 when more than 200 match; exactly
200 are not more. Text with < or >, which Auth0's query syntax reads as a
range and can't escape, and which no email address holds unquoted, is
refused with 422 before any Auth0 call, with or without profile settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or >

search_user_ids_by_email takes a match limit and reads pages of 50 until
a page is short or more users than the limit have matched, counting each
user once; it then returns no IDs and says more matched. The admin
service passes MAXIMUM_EMAIL_SEARCH_MATCHES (200), so a search reads at
most five pages, and answers 422 with "More than 200 users match that
email text; enter more of it". Exactly 200 matches are served. With the
1,000-result limit out of reach, its handling and the
admin_user_email_search_limit_reached record are gone.

Search text with < or > is refused with 422 before any Auth0 call,
whether or not profiles are configured: Auth0's query syntax reads them
as a range and can't escape them, and no email address holds them
unquoted. The client refuses such text too (ValueError).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ord a cause

The unusable-settings test now covers the single read, the type change's
response and the history, not only the list; a new test checks that the
history never looks up the admin's identities from another issuer. The
admin_user_profiles_unavailable record is expected to carry the failure's
safe cause (http_403, http_429, timeout, invalid_settings, or unknown for
an error without one), so a missing read:users grant can be told from
rate limiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin_user_profiles_unavailable WARNING now carries a cause beside
the operation and error type: the profile reader's cause (http_403 for a
client without read:users, http_429, http_5xx, timeout, connection_error,
unreadable_answer and so on), invalid_settings for unusable settings, or
unknown. Only a short lowercase identifier is recorded, so no email
address or subject can pass as a cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only identities from PUBLIC_API_AUTH_ISSUER are looked up, so profile
settings without it would quietly read profiles for nobody. These tests
expect the settings loader, readiness, the default profile source and
the deploy validation to refuse them instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load_auth0_user_profile_settings now raises when the AUTH0_USER_PROFILES_*
settings are set without PUBLIC_API_AUTH_ISSUER, so readiness fails, and
validate_cloud_run_deploy_env.sh refuses them too, instead of the
profiles being quietly read for nobody. The default profile source no
longer returns None for a missing issuer; an admin read then keeps stored
values and records invalid_settings, and an email search answers 503.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_auth0_user_profile_reader now returns the UserProfileReader
protocol, which has only the profile read and the email search, rather
than the whole Management API client with delete_user, so mypy refuses a
deletion through it. The protocol moves to the Auth0 module beside the
types it returns; the admin service refers to it from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arches

The admin list route's OpenAPI description and its email parameter now
say that text with < or > is refused (422), and so is text that more than
200 Auth0 users match. The Stage 12A design doc describes the limit, the
refusal, the sort, the one HTTP client per operation, the 429 retry, the
cause the warning record carries, and that the profile settings need
PUBLIC_API_AUTH_ISSUER. It drops the paid-plan rate-limit figures, which
weren't confirmed, and says that the release-tests job never receives the
Auth0 settings rather than that it unsets them. The changelog fragment
mentions the refusals and the issuer requirement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tails

Rate-limit waits added up across every Auth0 request one admin request
made. These tests describe a budget of 2 seconds of waiting in total,
shared by an admin request's email search and profile read and spent by
each wait, so a short wait still retries but several waits stop at 2
seconds. They also pin the retry's details: after a 401 refresh, the
retry of a rate-limited search sends the fresh token; Retry-After takes
precedence over X-RateLimit-Reset; and a wait of nan or infinity is not
waited for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RateLimitWaitBudget holds the seconds an operation may still wait for
Auth0's rate limit. A rate-limited search waits and retries once only when
its whole wait fits in what is left, and spends it. read_user_profiles and
search_user_ids_by_email take a budget, or start one of 2 seconds of their
own, and every admin service call that reads Auth0 starts one budget and
passes it to its email search and its profile read, so their waits
together stay within 2 seconds instead of adding up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A search's fifth page can be full only through duplicate or ID-less
entries, and it may hide more users, so it is expected to count as more
than the limit; 220 entries naming exactly 200 users are expected to be
served. Text with < or > is expected to be refused before the profile
settings are read, so unusable settings give 422, not 503. A reader
error whose cause is not a short identifier is expected to be recorded
as unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
search_user_ids_by_email reads at most match_limit // 50 + 1 pages. When
the last of them is full without more than match_limit distinct users,
which only duplicate or ID-less entries can cause, more users may match,
so the search now says more than the limit matched instead of serving
what it read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests of the 200-match limit, its refusal message and its OpenAPI
descriptions now read the limit from the constant, and derive their pages
from it, so they follow it if it changes. With the constant set to 150,
only the OpenAPI test fails, because the descriptions still write the
number as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin routes' 422 description, the user list's description and the
email parameter's description now take the number from
MAXIMUM_EMAIL_SEARCH_MATCHES rather than writing 200 as text, so the
OpenAPI document and the refusal can't disagree. The Stage 12A design doc
keeps the number in prose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 7 commits November 12, 2026 03:31
The admin service now passes one rate-limit wait budget to its email
search and profile read; the stand-in takes it and checks it is one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uest

build_auth0_user_profile_reader returns one client per process, so a
fallback budget kept on the client would make every admin request share
one budget. A test now checks that two calls without a budget on one
client each get 2 seconds, and another that a type change and the type
history each pass a budget of their own, as the list and the single read
do. Both pass, and each fails against the version it guards against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin service should always create a budget, which it can't without
importing the Auth0 module while the budget lives there. These tests
describe policyengine_api.libs.rate_limit_wait_budget: importing it loads
no other application module, the admin service module loads it but not
the Auth0 module, and a budget of 2 seconds spends only the waits that fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RateLimitWaitBudget and its 2-second default now live in
policyengine_api.libs.rate_limit_wait_budget, which imports no other
application module. The Auth0 module and the admin service both import
it at module level, so the service always starts a budget for each admin
request: its private methods take a RateLimitWaitBudget, never None, and
the import inside _wait_budget_for is gone with that function. The Auth0
module still stays out of the service's module-level imports.

The new module is added to mypy's files and to the paths of rule
api.quality.mypy-v2, whose statement now names the Auth0 Management
libraries with their wait budget; docs/generated/rules.md is re-rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
take(nan) succeeded and left nan seconds, which every later wait fits,
and take(-5.0) added 5 seconds. The test expects such waits to be refused
without spending anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RateLimitWaitBudget.take now returns False, spending nothing, for a wait
that is negative or not a finite number, as for one longer than what is
left, so a rate-limited request with such a wait counts as unavailable.
The client already clamps and filters Auth0's headers; this keeps the
budget sound for any caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in the design doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.99499% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.82%. Comparing base (cd513da) to head (bb52245).

Files with missing lines Patch % Lines
policyengine_api/libs/auth0_management.py 97.69% 5 Missing ⚠️
policyengine_api/fastapi_routes/dependencies.py 87.50% 1 Missing ⚠️
...olicyengine_api/services/v2/user_types/services.py 98.83% 0 Missing and 1 partial ⚠️
...gine_api/services/v2/user_types/transformations.py 97.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4283      +/-   ##
==========================================
+ Coverage   92.69%   92.82%   +0.13%     
==========================================
  Files         329      330       +1     
  Lines       18783    19154     +371     
  Branches     3114     3096      -18     
==========================================
+ Hits        17410    17780     +370     
- Misses        818      821       +3     
+ Partials      555      553       -2     
Flag Coverage Δ
integration 53.92% <47.86%> (+0.15%) ⬆️
unit 90.62% <97.49%> (+0.16%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The staging deploy job's comment on the read-only Auth0 client named
AUTH0_MANAGEMENT_*, which the existing guard against those settings
reaching staging (test_push_workflow_wires_user_auth_settings_and_staging_test_user)
refuses wherever it appears in the job. The comment now calls them the
production-only account-deletion settings, and the guard stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin user routes: read users' emails and names from Auth0 instead of storing them

1 participant