Counts-only retention preview — same numbers as ops-retention-preview.yml, on demand
Report prunable-row counts + age distribution without dispatching a workflow.
.github/workflows/ops-retention-preview.yml (#652) gives an operator the same numbers, but it can only be dispatched from a v* release tag — the production GitHub Environment's deployment-branch policy allows nothing else, so it is correct as a pre-flip gate but heavyweight for routine checking. This endpoint is the same read, on demand, no workflow dispatch required.
Provably read-only: the only service calls are count_prunable and count_row_age_histogram, both pure SELECT count(*) ... statements. Neither touches delete(), prune_all, or db.commit() — this handler must never import or call either. The handler itself issues no writes, so get_db's exit-time await session.commit() (database.py, run on every successful request regardless of handler) is a no-op here — nothing in the session is ever dirtied. That safety property lives in the dependency, not in this function; if a future edit adds a db.add(...) here, that commit stops being a no-op even though this docstring still says "read-only" — keep the two in sync.
Response mirrors the standalone pruner's dry-run receipt (python -m scripts.prune_auth_data, the same output preview-retention-counts.sh copies into the workflow's Actions log) so the two are directly comparable:
{
"retention_scheduler_enabled": <bool>,
"prunable": {
"auth_audit_log": <int>,
"password_reset_tokens": <int>,
"email_verification_tokens": <int>,
"support_messages": <int>,
"enterprise_waitlist": <int>
},
"age_histogram": {
"auth_audit_log": {"<=7d": <int>, "8-30d": <int>, "31-90d": <int>,
"91-180d": <int>, "181-365d": <int>,
">365d": <int>, "total": <int>},
... (one row per table above)
},
"windows": {
"auth_audit_log": {"retention_days": <int>},
"password_reset_tokens": {"grace_days": <int>},
"email_verification_tokens": {"grace_days": <int>},
"support_messages": {"retention_days": <int>},
"enterprise_waitlist": {"retention_days": <int>}
}
}
windows always reflects the currently configured values, even when a window is misconfigured (see below) — it is built before either service call so a bad value is never swallowed by the exception it causes.
If a configured window fails count_prunable's validation (e.g. AUTH_AUDIT_RETENTION_DAYS set below the 7-day safety floor — unlike SUPPORT_MESSAGE_RETENTION_DAYS / ENTERPRISE_WAITLIST_RETENTION_DAYS, it has no startup validator, so a bad value boots fine and only surfaces here), the response is still 200 with prunable and age_histogram omitted and an error string added (config names and integers only, no PII, admin-only audience) — the exact multi-hop-env misconfiguration this endpoint exists to diagnose must never collapse into an opaque 500.
Counts and configuration only. age_histogram buckets ALL rows by age at day-7-or-coarser granularity (see count_row_age_histogram's docstring for why buckets, not dates) — it never returns a row's content, email, IP, or exact timestamp.
Authorization: - Anonymous → 401 (auth dep on require_admin). - Authenticated non-admin → 403 (require_admin). - Admin → 200 (or 200 + error, see above).
Flag gate (rule #4): 404 when ADMIN_DASHBOARD_ENABLED is off, matching every other handler in this router. This does NOT hide the route's existence — main.py serves /openapi.json unauthenticated, so the path and schema are public regardless of flag state (same property already corrected once on _require_custody_notice_enabled above; matching that language here rather than repeating the mistake). What IS true: FastAPI resolves require_admin before this body runs, so anonymous still gets 401 and a non-admin caller still gets 403 no matter the flag — only an already-authenticated admin can distinguish 404 from 200, so flag state is never disclosed below the admin trust level this route already requires.
Request
- Base URL: https://api.badgeia.com
- URL: https://api.badgeia.com/api/v1/admin/retention/preview
- Auth: none declared
Response
Successful Response
Changes
No recorded changes to this operation across all 1 revision of this API.