Sprint 4 PR 4.5d — close /analytics auth gap - #229
Merged
Merged
Conversation
Summary:
PR 4.5b/c closed the auth gaps on the calendar router; the analytics
router was the last unauthenticated read surface with the same
cross-tenant shape. `GET /api/v1/analytics/{org_id}/volunteer-stats`,
`.../schedule-health`, and `.../burnout-risk` accepted org_id as a
path parameter and returned volunteer names, emails, and event
counts with no auth check at all — anyone reaching the API could
enumerate any org's roster. Gate the three endpoints on
`Depends(get_current_admin_user)` + `verify_org_member`, matching
the pattern used by `/calendar/org/export`.
Changed files:
- api/routers/analytics.py — add current_admin dependency and
verify_org_member call to get_volunteer_stats, get_schedule_health,
get_burnout_risk. `days`/`threshold` Query params moved before the
dep so keyword call sites in web/ still work.
- tests/api/test_analytics_auth.py — 12 new no_mock_auth cases: for
each endpoint, unauth → 401/403, volunteer in same org → 403,
admin cross-org → 403, admin same-org → 200.
- tests/api/test_analytics.py — pre-existing baseline tests now send
admin auth headers via an `admin_hdrs` fixture; the "unknown org
accepts 200|404" case is now "admin-of-known-org gets 403 on
unknown org" (verify_org_member rejects before the DB read).
- web/routers/pages.py — `_dashboard_kpis` and `_analytics` do
in-process direct calls to the API functions and had to grow the
same `current_admin` kwarg. Both helpers now take `person` instead
of `org_id` and forward it as `current_admin=person` so the
verify_org_member check still runs on the dashboard/analytics
pages. Call sites in admin_dashboard/admin_analytics updated.
- tests/contract/openapi.snapshot.json — refreshed via
make update-openapi-snapshot: HTTPBearer security added to the
three analytics operations; descriptions bumped.
Validation:
- poetry run pytest tests/api/test_analytics.py \
tests/api/test_analytics_auth.py \
tests/web/test_analytics_page.py --tb=short → 21 passed
- make test-unit-fast → 338 passed, 21 skipped
- poetry run pytest tests/api tests/contract tests/web \
tests/integration tests/cli --tb=short → 609 passed
- poetry run black --check api tests → clean
- poetry run ruff check api tests → clean
Follow-ups:
- None. This closes the last analytics auth gap. Post-merge the E2E
lane should run against updated main to pick up any downstream
web-flow assumptions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvZvSuB2SYqxrJBXoX1vBY
tomqwu
marked this pull request as ready for review
July 9, 2026 13:04
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
PR 4.5b/c closed the auth gaps on the calendar router; the analytics router was the last unauthenticated read surface with the same cross-tenant shape.
GET /api/v1/analytics/{org_id}/volunteer-stats,.../schedule-health, and.../burnout-riskacceptedorg_idas a path parameter and returned volunteer names, emails, and event counts with no auth check at all — anyone reaching the API could enumerate any org's roster.This PR gates the three endpoints on
Depends(get_current_admin_user)+verify_org_member, matching the pattern used by/calendar/org/export(PR 4.5c).Threat closed
Before this PR, an unauthenticated caller could:
GET /api/v1/analytics/{any_org_id}/volunteer-stats→ top volunteers by name + assignment countsGET /api/v1/analytics/{any_org_id}/burnout-risk→ volunteer id + name + email of anyone serving ≥ threshold in the last 30 daysGET /api/v1/analytics/{any_org_id}/schedule-health→ org KPIs + latest solution id + health scoreAfter this PR: 401/403 without JWT, 403 for volunteers (even in-org), 403 for cross-org admins, 200 only for admin-in-same-org.
Changes
api/routers/analytics.pycurrent_admin: Person = Depends(get_current_admin_user)+verify_org_member(current_admin, org_id)to all three endpoints.days/thresholdQueryparams moved before the dep so keyword call sites inweb/still work.tests/api/test_analytics_auth.pyno_mock_authcases: per endpoint, unauth → 401/403, volunteer in same org → 403, admin cross-org → 403, admin same-org → 200.tests/api/test_analytics.pyadmin_hdrsfixture. The "unknown-org accepts 200 or 404" case is now "admin-of-known-org gets 403 on unknown org" (verify_org_member rejects before the DB read).web/routers/pages.py_dashboard_kpisand_analyticsdo in-process direct calls to the API functions and had to grow the samecurrent_adminkwarg. Both helpers now takepersoninstead oforg_idand forward it ascurrent_admin=personsoverify_org_memberstill runs on the admin dashboard +/a/analyticspages. Call sites inadmin_dashboard/admin_analyticsupdated.tests/contract/openapi.snapshot.jsonmake update-openapi-snapshot— HTTPBearer security added to the three analytics operations, descriptions bumped.Test plan
poetry run pytest tests/api/test_analytics.py tests/api/test_analytics_auth.py tests/web/test_analytics_page.py→ 21 passedmake test-unit-fast→ 338 passed, 21 skippedpoetry run pytest tests/api tests/contract tests/web tests/integration tests/cli→ 609 passedpoetry run black --check api tests→ cleanpoetry run ruff check api tests→ cleanFollow-ups
None. This closes the last analytics auth gap. The E2E lane should run against updated
mainpost-merge to pick up any downstream web-flow assumptions.🤖 Generated with Claude Code
https://claude.ai/code/session_01SvZvSuB2SYqxrJBXoX1vBY
Generated by Claude Code