Skip to content

feat(testing): runtime user-persona impersonation via X-Test-* headers#459

Merged
larsgeorge-db merged 1 commit into
mainfrom
feat-runtime-test-user-header-override
May 28, 2026
Merged

feat(testing): runtime user-persona impersonation via X-Test-* headers#459
larsgeorge-db merged 1 commit into
mainfrom
feat-runtime-test-user-header-override

Conversation

@larsgeorge-db
Copy link
Copy Markdown
Collaborator

Summary

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 fronting FastAPI.

Complements (does not replace) the existing MOCK_USER_* env vars (process-wide, require restarts) and the in-process app.dependency_overrides[get_user_details_from_sdk] pattern used by integration tests.

What's new

Backend

  • Settings.TEST_USER_TOKEN + typed Settings.TESTING 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:
    • Gated on TEST_USER_TOKEN being set and X-Test-Token matching it exactly.
    • Requires X-Test-User-Email; missing email → 400.
    • X-Test-User-Groups optional (JSON array or CSV). When omitted, falls back to a real SCIM lookup so the persona mirrors actual workspace state.
    • Optional refinement headers: X-Test-User-{Username,Name,Ip}.
    • Audit-logs every 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 7 get_user_groups call sites so audience checks honor the override.
  • src/backend/src/data/test_personas.yaml with canned personas (admin, governance, steward, producer, consumer, security, anon) aligned with 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 every /api request 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:
    • Header groups (JSON + CSV), SCIM fallback, /api/test/personas.
    • Permission resolution from the overridden identity.
    • Wrong-token → ignored (impersonated email NOT honored).
    • Missing email when token matches → 400.
    • Feature fully disabled when token unset.
  • CONTRIBUTING.md, CLAUDE.md, backend + frontend .env.example updated with setup, usage from UI/curl, and security notes.

Security

  • Feature is fully inert unless TEST_USER_TOKEN is set server-side.
  • Token must be left unset in production (warning logged otherwise).
  • Token is never returned by any API; /api/test/personas returns 404 when the feature is disabled.
  • Frontend UI picker is also gated on the backend reporting the feature is enabled.

Test plan

  • CI: backend pytest suite (incl. the new test_user_header_override.py).
  • CI: frontend lint + unit tests.
  • Manual: set TEST_USER_TOKEN + VITE_TEST_USER_TOKEN locally, pick personas from the avatar dropdown, confirm the persona's permissions take effect across views.
  • Manual: hit a feature-gated endpoint with 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.
  • Manual: wrong X-Test-Token — confirm impersonation is silently ignored (regular identity resolution wins).
  • Manual: unset TEST_USER_TOKEN, confirm /api/test/personas returns 404 and the UI picker disappears.

@larsgeorge-db larsgeorge-db requested a review from a team as a code owner May 28, 2026 09:26
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.
@larsgeorge-db larsgeorge-db force-pushed the feat-runtime-test-user-header-override branch from b3cb177 to 3473a0d Compare May 28, 2026 09:49
@larsgeorge-db larsgeorge-db merged commit e8bd863 into main May 28, 2026
7 checks passed
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.
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.

1 participant