feat(testing): runtime user-persona impersonation via X-Test-* headers#459
Merged
Merged
Conversation
Adds a per-request user impersonation mechanism gated on a server-side
TEST_USER_TOKEN shared secret. Lets developers and Playwright tests
exercise the app as different personas (Admin / Producer / Consumer /
Steward / etc.) against a *single* running backend — no restarts, works
with or without Vite in front.
Backend
- `Settings.TEST_USER_TOKEN` + `Settings.TESTING` typed fields, with a
model_validator warning when TEST_USER_TOKEN is set in PROD.
- `_try_resolve_test_override(request, settings, manager)` in
`common/authorization.py`: checks `X-Test-Token`, parses
`X-Test-User-{Email,Groups,Username,Name,Ip}`, falls back to SCIM for
group resolution when the groups header is omitted, audit-logs each
active override. No-op when the token is unset.
- Wired at highest precedence into `get_user_details_from_sdk`,
`get_user_groups` (now accepts optional `request`), and
`user_routes.get_user_info_from_headers`.
- `comments_routes` threaded `request` through all `get_user_groups`
calls so audience checks honor the override.
- `data/test_personas.yaml` with canned personas (admin, governance,
steward, producer, consumer, security, anon) aligned to seeded roles.
- `GET /api/test/personas` route (404s when TEST_USER_TOKEN is unset;
never returns the token itself).
Frontend
- `stores/test-persona-store.ts` Zustand store: fetches personas,
reads `VITE_TEST_USER_TOKEN` (or `localStorage.ucapp.testToken`),
persists the selected persona, and installs a global `window.fetch`
interceptor that injects `X-Test-*` headers on all `/api` requests
when a persona is active.
- `app.tsx` initializes the store + installs the interceptor early.
- `user-info.tsx` adds a "Test persona" dropdown section (visible only
when the backend feature is enabled) and a yellow ring/dot on the
avatar so testers don't forget they're impersonating.
Tests + docs
- `tests/integration/test_user_header_override.py` covering positive
paths (header groups in JSON + CSV, SCIM fallback, /api/test/personas),
permission resolution from overridden identity, wrong-token rejection,
missing-email 400, and feature-disabled behavior.
- `CONTRIBUTING.md`, `CLAUDE.md`, backend + frontend `.env.example`
updated with setup, usage, and security notes.
Security: the feature is fully inert unless TEST_USER_TOKEN is set
server-side, must be left unset in production, and the token is never
exposed by any API.
b3cb177 to
3473a0d
Compare
4 tasks
larsgeorge-db
added a commit
that referenced
this pull request
May 29, 2026
Until now only the Admin role shipped with a default `assigned_groups` binding (driven by `APP_ADMIN_DEFAULT_GROUPS`). The other seeded roles — Data Governance Officer, Data Steward, Data Producer, Data Consumer, Security Officer — were created with empty `assigned_groups`, which meant the runtime persona override (added in #459 / e8bd863) would correctly impersonate the persona's identity but resolve to *zero* permissions until an operator manually added groups to each role in the Settings UI. This patch bakes the conventional group names into both seed sources so the persona flow works end-to-end on first run: Data Governance Officer <- data-governance-officers Data Steward <- data-stewards Data Producer <- data-producers Data Consumer <- data-consumers Security Officer <- security-officers The group names match `src/backend/src/data/test_personas.yaml` and the labels used in the Test Persona dropdown. Sources touched: - `src/backend/src/data/settings.yaml` (the authoritative seed YAML) - `DEFAULT_ROLES` in `settings_manager.py` (in-code fallback used when the YAML is missing) — including the `generated_roles` codepath that writes the YAML on first run, so a fresh deployment without `settings.yaml` also gets the bindings. Admin is intentionally NOT given a hardcoded default here — its `assigned_groups` continue to come from `APP_ADMIN_DEFAULT_GROUPS` so production deployments retain explicit control over admin assignment. Migration note: existing deployments already have role records persisted to the database and won't be affected by this change. To pick up the new bindings, either edit roles via Settings → Roles and add the groups listed above, or delete the affected role rows so they get re-seeded on next start.
larsgeorge-db
added a commit
that referenced
this pull request
May 29, 2026
Until now only the Admin role shipped with a default `assigned_groups` binding (driven by `APP_ADMIN_DEFAULT_GROUPS`). The other seeded roles — Data Governance Officer, Data Steward, Data Producer, Data Consumer, Security Officer — were created with empty `assigned_groups`, which meant the runtime persona override (added in #459 / e8bd863) would correctly impersonate the persona's identity but resolve to *zero* permissions until an operator manually added groups to each role in the Settings UI. This patch bakes the conventional group names into both seed sources so the persona flow works end-to-end on first run: Data Governance Officer <- data-governance-officers Data Steward <- data-stewards Data Producer <- data-producers Data Consumer <- data-consumers Security Officer <- security-officers The group names match `src/backend/src/data/test_personas.yaml` and the labels used in the Test Persona dropdown. Sources touched: - `src/backend/src/data/settings.yaml` (the authoritative seed YAML) - `DEFAULT_ROLES` in `settings_manager.py` (in-code fallback used when the YAML is missing) — including the `generated_roles` codepath that writes the YAML on first run, so a fresh deployment without `settings.yaml` also gets the bindings. Admin is intentionally NOT given a hardcoded default here — its `assigned_groups` continue to come from `APP_ADMIN_DEFAULT_GROUPS` so production deployments retain explicit control over admin assignment. Migration note: existing deployments already have role records persisted to the database and won't be affected by this change. To pick up the new bindings, either edit roles via Settings → Roles and add the groups listed above, or delete the affected role rows so they get re-seeded on next start.
This was referenced May 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a per-request user impersonation mechanism gated on a server-side
TEST_USER_TOKENshared secret. Lets developers and Playwright tests exercise the app as different personas (Admin / Producer / Consumer / Steward / etc.) against a single running backend — no restarts, works with or without Vite fronting FastAPI.Complements (does not replace) the existing
MOCK_USER_*env vars (process-wide, require restarts) and the in-processapp.dependency_overrides[get_user_details_from_sdk]pattern used by integration tests.What's new
Backend
Settings.TEST_USER_TOKEN+ typedSettings.TESTINGfields, with amodel_validatorwarning whenTEST_USER_TOKENis set in PROD._try_resolve_test_override(request, settings, manager)incommon/authorization.py:TEST_USER_TOKENbeing set andX-Test-Tokenmatching it exactly.X-Test-User-Email; missing email → 400.X-Test-User-Groupsoptional (JSON array or CSV). When omitted, falls back to a real SCIM lookup so the persona mirrors actual workspace state.X-Test-User-{Username,Name,Ip}.get_user_details_from_sdk,get_user_groups(now accepts optionalrequest), anduser_routes.get_user_info_from_headers.comments_routesthreadedrequestthrough all 7get_user_groupscall sites so audience checks honor the override.src/backend/src/data/test_personas.yamlwith canned personas (admin, governance, steward, producer, consumer, security, anon) aligned with seeded roles.GET /api/test/personasroute — 404s whenTEST_USER_TOKENis unset; never returns the token itself.Frontend
stores/test-persona-store.tsZustand store: fetches personas, readsVITE_TEST_USER_TOKEN(orlocalStorage.ucapp.testToken), persists the selected persona, and installs a globalwindow.fetchinterceptor that injectsX-Test-*headers on every/apirequest when a persona is active.app.tsxinitializes the store + installs the interceptor early.user-info.tsxadds a "Test persona" dropdown section (visible only when the backend feature is enabled) and a yellow ring/dot on the avatar so testers don't forget they're impersonating.Tests + docs
tests/integration/test_user_header_override.pycovering:/api/test/personas.CONTRIBUTING.md,CLAUDE.md, backend + frontend.env.exampleupdated with setup, usage from UI/curl, and security notes.Security
TEST_USER_TOKENis set server-side./api/test/personasreturns 404 when the feature is disabled.Test plan
test_user_header_override.py).TEST_USER_TOKEN+VITE_TEST_USER_TOKENlocally, pick personas from the avatar dropdown, confirm the persona's permissions take effect across views.curl -H "X-Test-Token: …" -H "X-Test-User-Email: consumer@test.local" -H "X-Test-User-Groups: [\"data-consumers\"]"— confirm response reflects the impersonated identity.X-Test-Token— confirm impersonation is silently ignored (regular identity resolution wins).TEST_USER_TOKEN, confirm/api/test/personasreturns 404 and the UI picker disappears.