accounts: routes — POST /v1/auth/verify (GET redirige sans consommer), reset avec revoke_keys, throttle forgot par e-mail, /v1/me/{limits,usage?key_id,usage.csv,password,email,sessions/revoke-all,DELETE}, PATCH clés, admin session obligatoire + suppression + filtres audit + usage 7 j + admins_active
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 changed files +414 −118
modified
hfmarketdata/api/accounts/routes_admin.py
+88 −37
@@ -1,5 +1,9 @@ | ||
| 1 | 1 | """`/v1/admin` — user management, invitations, global usage, audit log (admin role only). |
| 2 | 2 | |
| 3 | +The whole area requires a **browser session** with the admin role: an API key — even an admin's — is refused | |
| 4 | +(`403 SESSION_REQUIRED`). Admins cannot change their own role/status and the last active admin can never be | |
| 5 | +demoted, disabled or deleted (`409 LAST_ADMIN`). | |
| 6 | + | |
| 3 | 7 | Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 4 | 8 | """ |
| 5 | 9 | from __future__ import annotations |
@@ -13,6 +17,7 @@ from sqlalchemy import func, select | ||
| 13 | 17 | from sqlalchemy.orm import Session |
| 14 | 18 | |
| 15 | 19 | from core.db import get_session |
| 20 | +from core.errors import ApiError | |
| 16 | 21 | from core.responses import clamp_limit, decode_cursor, encode_cursor, json_response |
| 17 | 22 | from ratelimit import usage |
| 18 | 23 | |
@@ -24,8 +29,14 @@ router = APIRouter(prefix="/v1/admin", tags=["admin"], dependencies=[Depends(req | ||
| 24 | 29 | |
| 25 | 30 | USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free", |
| 26 | 31 | "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z", |
| 27 | − "last_login_at": "2026-09-04T14:05:40Z", "keys_active": 1} | |
| 28 | −ADMIN_ERRORS = ["AUTH_REQUIRED", "FORBIDDEN"] | |
| 32 | + "last_login_at": "2026-09-04T14:05:40Z", "quota_alerts": True, "locked_until": None, "keys_active": 1} | |
| 33 | +KEY_EXAMPLE = {"id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "status": "active", "tier_override": None, | |
| 34 | + "created_at": "2026-09-04T14:06:02Z", "last_used_at": None, "last_used_ip": None, "revoked_at": None, | |
| 35 | + "expires_at": None, "expired": False, "note": None, "scopes": ["data"], "principal": "key:12"} | |
| 36 | +ADMIN_ERRORS = ["AUTH_REQUIRED", "SESSION_REQUIRED", "FORBIDDEN"] | |
| 37 | +AUDIT_ACTIONS = ("user.create, user.update, user.login, user.login_failed, user.verify, user.reset_password, " | |
| 38 | + "user.signup_existing, account.password_change, account.email_change, account.delete, " | |
| 39 | + "session.revoke_all, key.create, key.revoke, key.rotate, key.update, token.*") | |
| 29 | 40 | |
| 30 | 41 | |
| 31 | 42 | class UserCreateBody(BaseModel): |
@@ -33,8 +44,8 @@ class UserCreateBody(BaseModel): | ||
| 33 | 44 | name: str = Field("", max_length=200) |
| 34 | 45 | tier: Literal["free", "high_usage", "unlimited"] = "free" |
| 35 | 46 | role: Literal["user", "admin"] = "user" |
| 36 | − password: str | None = Field(None, description="Optional. Without it the user is *invited* (e-mail with a set-password link) " | |
| 37 | − "and receives an active API key.") | |
| 47 | + password: str | None = Field(None, max_length=256, description="Optional. Without it the user is *invited* (e-mail with a set-password link) " | |
| 48 | + "and receives an active API key.") | |
| 38 | 49 | key_name: str = Field("default", max_length=100) |
| 39 | 50 | |
| 40 | 51 | |
@@ -48,6 +59,8 @@ class UserUpdateBody(BaseModel): | ||
| 48 | 59 | class KeyCreateBody(BaseModel): |
| 49 | 60 | name: str = Field("default", max_length=100) |
| 50 | 61 | tier_override: Literal["free", "high_usage", "unlimited"] | None = None |
| 62 | + note: str | None = Field(None, max_length=500) | |
| 63 | + expires_in_days: int | None = Field(None, ge=1, le=service.MAX_KEY_EXPIRY_DAYS) | |
| 51 | 64 | |
| 52 | 65 | |
| 53 | 66 | def _actor(admin: User) -> str: |
@@ -63,10 +76,11 @@ def _keys_count(s: Session, user_ids: list[int]) -> dict[int, int]: | ||
| 63 | 76 | |
| 64 | 77 | |
| 65 | 78 | def _link_payload(issued: service.IssuedToken | None) -> dict: |
| 79 | + """`delivered` (bool), `queued` (mail handed to the provider after commit), `link` only when nothing is sent.""" | |
| 66 | 80 | if issued is None: |
| 67 | 81 | return {} |
| 68 | − out = {"delivered": issued.delivery.delivered} | |
| 69 | − if not issued.delivery.delivered: | |
| 82 | + out: dict = {"delivered": issued.delivery.delivered, "queued": issued.delivery.queued} | |
| 83 | + if not issued.delivery.delivered and not issued.delivery.queued: | |
| 70 | 84 | out["link"] = issued.link # admins may copy the link when no mail provider is configured |
| 71 | 85 | out["delivery_error"] = issued.delivery.error |
| 72 | 86 | return out |
@@ -74,8 +88,9 @@ def _link_payload(issued: service.IssuedToken | None) -> dict: | ||
| 74 | 88 | |
| 75 | 89 | @router.get( |
| 76 | 90 | "/users", summary="List users", |
| 77 | − description="Paginated (cursor on id), optional substring `search` on e-mail/name, filters on tier/status/role.", | |
| 78 | − responses={200: {"content": {"application/json": {"example": {"data": [USER_EXAMPLE], "meta": {"count": 1, "next_cursor": None}}}}}}, | |
| 91 | + description="Paginated (cursor on id), optional substring `search` on e-mail/name, filters on tier/status/role. " | |
| 92 | + "`meta.admins_active` = number of active administrators (show a warning when it is 1).", | |
| 93 | + responses={200: {"content": {"application/json": {"example": {"data": [USER_EXAMPLE], "meta": {"count": 1, "next_cursor": None, "admins_active": 2}}}}}}, | |
| 79 | 94 | openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]}) |
| 80 | 95 | def list_users(search: str | None = None, tier: str | None = None, status: str | None = None, role: str | None = None, |
| 81 | 96 | limit: int | None = Query(None, ge=1, le=500), cursor: str | None = None, |
@@ -98,7 +113,8 @@ def list_users(search: str | None = None, tier: str | None = None, status: str | | ||
| 98 | 113 | nxt = encode_cursor(users[lim - 1].id) if len(users) > lim else None |
| 99 | 114 | users = users[:lim] |
| 100 | 115 | counts = _keys_count(s, [u.id for u in users]) |
| 101 | − return json_response([service.user_public(u, keys_count=counts.get(u.id, 0)) for u in users], meta={"next_cursor": nxt}) | |
| 116 | + return json_response([service.user_public(u, keys_count=counts.get(u.id, 0)) for u in users], | |
| 117 | + meta={"next_cursor": nxt, "admins_active": service.active_admins(s)}) | |
| 102 | 118 | |
| 103 | 119 | |
| 104 | 120 | @router.post( |
@@ -106,11 +122,12 @@ def list_users(search: str | None = None, tier: str | None = None, status: str | | ||
| 106 | 122 | description=("With `password`: active + verified account. Without: **invitation** — user created with status " |
| 107 | 123 | "`invited`, an active API key is generated (its prefix is returned; the key itself is never shown to " |
| 108 | 124 | "admins) and a set-your-password link is e-mailed (valid 7 days). When no mail provider is configured, " |
| 109 | − "`invitation.link` is returned so you can forward it."), | |
| 125 | + "`invitation.link` is returned so you can forward it; with a provider the mail is queued after commit " | |
| 126 | + "(`invitation.queued`)."), | |
| 110 | 127 | responses={201: {"content": {"application/json": {"example": {"data": { |
| 111 | 128 | "user": {**USER_EXAMPLE, "status": "invited", "email_verified": False}, |
| 112 | 129 | "key": {"id": 12, "prefix": "hfmd_live_ab12cd34", "name": "default"}, |
| 113 | − "invitation": {"delivered": False, "link": "https://www.hfmarketdata.io/accept-invite?token=…", "delivery_error": "no_provider"}}, | |
| 130 | + "invitation": {"delivered": False, "queued": False, "link": "https://www.hfmarketdata.io/accept-invite?token=…", "delivery_error": "no_provider"}}, | |
| 114 | 131 | "meta": {"count": 1}}}}}}, |
| 115 | 132 | openapi_extra={"x-errors": ADMIN_ERRORS + ["EMAIL_TAKEN", "WEAK_PASSWORD", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]}, |
| 116 | 133 | dependencies=[Depends(csrf)]) |
@@ -121,7 +138,6 @@ def create_user(body: UserCreateBody, s: Session = Depends(get_session), admin: | ||
| 121 | 138 | u.email_verified_at = service.now() |
| 122 | 139 | return json_response({"user": service.user_public(u, keys_count=0), "key": None, "invitation": None}, status=201) |
| 123 | 140 | if service.get_user_by_email(s, body.email): |
| 124 | − from core.errors import ApiError | |
| 125 | 141 | raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Use POST /v1/admin/users/{id}/invite to re-send.") |
| 126 | 142 | u, _raw, issued = service.invite(s, body.email, body.name, tier=body.tier, role=body.role, actor=_actor(admin), |
| 127 | 143 | key_name=body.key_name) |
@@ -133,40 +149,64 @@ def create_user(body: UserCreateBody, s: Session = Depends(get_session), admin: | ||
| 133 | 149 | |
| 134 | 150 | |
| 135 | 151 | @router.get( |
| 136 | − "/users/{user_id}", summary="User detail with keys", | |
| 137 | − responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "keys": [ | |
| 138 | − {"id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "status": "active", "tier_override": None, | |
| 139 | − "created_at": "2026-09-04T14:06:02Z", "last_used_at": None, "revoked_at": None, "principal": "key:12"}], | |
| 140 | − "pending_invite_link": None}, "meta": {"count": 1}}}}}}, | |
| 152 | + "/users/{user_id}", summary="User detail: keys, pending links, 7-day usage", | |
| 153 | + description="`usage` is the 7-day hourly series over all the user's keys (same shape as `GET /v1/me/usage?range=7d`).", | |
| 154 | + responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "keys": [KEY_EXAMPLE], | |
| 155 | + "pending_invite_link": None, "pending_reset_link": None, | |
| 156 | + "usage": {"range": "7d", "step_seconds": 3600, "points": [{"t": "2026-09-04T15:00:00Z", "requests": 12, "rows": 60_000, "status_429": 0, "bytes": 0, "rows_parquet": 0}], | |
| 157 | + "totals": {"requests": 12, "rows": 60_000, "status_429": 0, "bytes": 0, "rows_parquet": 0}}}, "meta": {"count": 1}}}}}}, | |
| 141 | 158 | openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND"]}) |
| 142 | 159 | def get_user(user_id: int, s: Session = Depends(get_session), _: User = Depends(require_admin)): |
| 143 | 160 | u = service.require_user(s, user_id) |
| 144 | 161 | keys = service.list_keys(s, u) |
| 145 | 162 | return json_response({"user": service.user_public(u, keys_count=sum(k.status == "active" for k in keys)), |
| 146 | 163 | "keys": [service.key_public(k) for k in keys], |
| 147 | − "pending_invite_link": service.pending_link(s, u, "invite")}) | |
| 164 | + "pending_invite_link": service.pending_link(s, u, "invite"), | |
| 165 | + "pending_reset_link": service.pending_link(s, u, "reset"), | |
| 166 | + "usage": usage.usage_series([k.principal for k in keys], "7d")}) | |
| 148 | 167 | |
| 149 | 168 | |
| 150 | 169 | @router.patch( |
| 151 | 170 | "/users/{user_id}", summary="Update tier / role / status / name", |
| 152 | − description="Setting `status: disabled` refuses the user's keys immediately (401 on their next call).", | |
| 171 | + description=("Setting `status: disabled` refuses the user's keys immediately (401 on their next call) and signs out " | |
| 172 | + "their sessions; a role change signs them out too. You cannot change your own role or status " | |
| 173 | + "(`403 FORBIDDEN`), and the last active admin cannot be demoted or disabled (`409 LAST_ADMIN`)."), | |
| 153 | 174 | responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "tier": "high_usage"}, "meta": {"count": 1}}}}}}, |
| 154 | − openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 175 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "INVALID_PARAMETER", "LAST_ADMIN", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 155 | 176 | dependencies=[Depends(csrf)]) |
| 156 | 177 | def update_user(user_id: int, body: UserUpdateBody, s: Session = Depends(get_session), admin: User = Depends(require_admin)): |
| 157 | 178 | u = service.require_user(s, user_id) |
| 179 | + if u.id == admin.id and (body.role is not None or body.status is not None): | |
| 180 | + raise ApiError(403, "FORBIDDEN", "You cannot change your own role or status. Ask another administrator.") | |
| 158 | 181 | service.update_user(s, u, actor=_actor(admin), name=body.name, tier=body.tier, role=body.role, status=body.status) |
| 159 | 182 | return json_response(service.user_public(u, keys_count=len(service.active_keys(s, u)))) |
| 160 | 183 | |
| 161 | 184 | |
| 185 | +@router.delete( | |
| 186 | + "/users/{user_id}", summary="Delete (anonymise) a user", | |
| 187 | + description="Soft delete: keys revoked, sessions signed out, pending links voided, e-mail and name removed, status " | |
| 188 | + "`deleted`. Usage counters stay (anonymous). Refused for yourself and for the last active admin.", | |
| 189 | + responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "email": "deleted-7@deleted.invalid", "name": "", "status": "deleted"}, "meta": {"count": 1}}}}}}, | |
| 190 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "LAST_ADMIN", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 191 | + dependencies=[Depends(csrf)]) | |
| 192 | +def delete_user(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)): | |
| 193 | + u = service.require_user(s, user_id) | |
| 194 | + if u.id == admin.id: | |
| 195 | + raise ApiError(403, "FORBIDDEN", "Delete your own account from the dashboard (Account → Delete), not from the admin area.") | |
| 196 | + service.delete_account(s, u, actor=_actor(admin)) | |
| 197 | + return json_response(service.user_public(u, keys_count=0)) | |
| 198 | + | |
| 199 | + | |
| 162 | 200 | @router.post( |
| 163 | 201 | "/users/{user_id}/invite", summary="(Re-)send the invitation", |
| 164 | 202 | description="Creates a fresh 7-day invitation token (older ones are voided) and e-mails it. Ensures the user has an active key. Body `{}`.", |
| 165 | 203 | responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "invitation": { |
| 166 | − "delivered": True}}, "meta": {"count": 1}}}}}}, | |
| 167 | − openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 204 | + "delivered": False, "queued": True}}, "meta": {"count": 1}}}}}}, | |
| 205 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 168 | 206 | def resend_invite(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)): |
| 169 | 207 | u = service.require_user(s, user_id) |
| 208 | + if u.status == "deleted": | |
| 209 | + raise ApiError(409, "CONFLICT", "Deleted accounts cannot be invited.") | |
| 170 | 210 | if not service.active_keys(s, u): |
| 171 | 211 | service.create_key(s, u, "default", actor=_actor(admin), notify=False) |
| 172 | 212 | issued = service.issue_token(s, u, "invite", actor=_actor(admin)) |
@@ -176,34 +216,36 @@ def resend_invite(user_id: int, s: Session = Depends(get_session), admin: User = | ||
| 176 | 216 | @router.post( |
| 177 | 217 | "/users/{user_id}/reset-password", summary="Send a password-reset link to a user", |
| 178 | 218 | description="Creates a 1-hour reset token and e-mails it (link returned when no mail provider). Body `{}`.", |
| 179 | − responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "reset": {"delivered": True}}, "meta": {"count": 1}}}}}}, | |
| 180 | − openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 219 | + responses={200: {"content": {"application/json": {"example": {"data": {"user": USER_EXAMPLE, "reset": {"delivered": False, "queued": True}}, "meta": {"count": 1}}}}}}, | |
| 220 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 181 | 221 | def admin_reset(user_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)): |
| 182 | 222 | u = service.require_user(s, user_id) |
| 223 | + if u.status in ("deleted", "disabled"): | |
| 224 | + raise ApiError(409, "CONFLICT", "This account cannot receive a reset link (disabled or deleted).") | |
| 183 | 225 | issued = service.issue_token(s, u, "reset", actor=_actor(admin)) |
| 184 | 226 | return json_response({"user": service.user_public(u), "reset": _link_payload(issued)}) |
| 185 | 227 | |
| 186 | 228 | |
| 187 | 229 | @router.post( |
| 188 | 230 | "/users/{user_id}/keys", summary="Create an API key for a user (shown once)", status_code=201, |
| 189 | − description="The full key is returned once to the admin (e.g. to hand over out of band). `tier_override` pins the key's tier.", | |
| 231 | + description="The full key is returned once to the admin (e.g. to hand over out of band). `tier_override` pins the key's tier; " | |
| 232 | + "`expires_in_days` gives it a lifetime.", | |
| 190 | 233 | responses={201: {"content": {"application/json": {"example": {"data": { |
| 191 | − "id": 14, "name": "default", "prefix": "hfmd_live_qq11ww22", "status": "active", "tier_override": None, | |
| 192 | − "created_at": "2026-09-04T16:00:00Z", "last_used_at": None, "revoked_at": None, "principal": "key:14", | |
| 193 | − "key": "hfmd_live_qq11ww22EXAMPLEKEYnotARealOne00"}, "meta": {"count": 1}}}}}}, | |
| 194 | − openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 234 | + **KEY_EXAMPLE, "id": 14, "prefix": "hfmd_live_qq11ww22", "key": "hfmd_live_qq11ww22EXAMPLEKEYnotARealOne00"}, "meta": {"count": 1}}}}}}, | |
| 235 | + openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "KEY_LIMIT_REACHED", "CONFLICT", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 195 | 236 | dependencies=[Depends(csrf)]) |
| 196 | 237 | def admin_create_key(user_id: int, body: KeyCreateBody, s: Session = Depends(get_session), admin: User = Depends(require_admin)): |
| 197 | 238 | u = service.require_user(s, user_id) |
| 198 | − raw, k = service.create_key(s, u, body.name, actor=_actor(admin), notify=False) | |
| 199 | − if body.tier_override: | |
| 200 | − k.tier_override = body.tier_override | |
| 239 | + if u.status == "deleted": | |
| 240 | + raise ApiError(409, "CONFLICT", "Deleted accounts cannot receive keys.") | |
| 241 | + raw, k = service.create_key(s, u, body.name, actor=_actor(admin), notify=False, note=body.note, | |
| 242 | + expires_at=service.validate_expiry(None, body.expires_in_days), tier_override=body.tier_override) | |
| 201 | 243 | return json_response({**service.key_public(k), "key": raw}, status=201) |
| 202 | 244 | |
| 203 | 245 | |
| 204 | 246 | @router.delete( |
| 205 | 247 | "/users/{user_id}/keys/{key_id}", summary="Revoke a user's API key", |
| 206 | − responses={200: {"content": {"application/json": {"example": {"data": {"id": 12, "status": "revoked"}, "meta": {"count": 1}}}}}}, | |
| 248 | + responses={200: {"content": {"application/json": {"example": {"data": {**KEY_EXAMPLE, "status": "revoked"}, "meta": {"count": 1}}}}}}, | |
| 207 | 249 | openapi_extra={"x-errors": ADMIN_ERRORS + ["USER_NOT_FOUND", "KEY_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, |
| 208 | 250 | dependencies=[Depends(csrf)]) |
| 209 | 251 | def admin_revoke_key(user_id: int, key_id: int, s: Session = Depends(get_session), admin: User = Depends(require_admin)): |
@@ -215,11 +257,12 @@ def admin_revoke_key(user_id: int, key_id: int, s: Session = Depends(get_session | ||
| 215 | 257 | @router.get( |
| 216 | 258 | "/usage", summary="Global usage: totals per day + top principals", |
| 217 | 259 | description="Folded data (usage_daily). `days` bounds both series; `top` limits the heaviest principals list. " |
| 218 | − "Principals `key:<id>` are mapped to their user.", | |
| 260 | + "Principals `key:<id>` are mapped to their user (`user` is null for keyless `ip:<hash>` principals).", | |
| 219 | 261 | responses={200: {"content": {"application/json": {"example": {"data": { |
| 220 | 262 | "per_day": [{"day": "2026-09-04", "requests": 18_240, "rows": 41_000_000, "rows_parquet": 2_000_000, "status_429": 12, "principals": 37}], |
| 221 | 263 | "top": [{"principal": "key:12", "requests": 5_120, "rows": 12_000_000, "status_429": 0, |
| 222 | − "user": {"id": 7, "email": "ada@example.com", "tier": "free"}}]}, "meta": {"count": 1}}}}}}, | |
| 264 | + "user": {"id": 7, "email": "ada@example.com", "tier": "free", "key_name": "default", "prefix": "hfmd_live_ab12cd34"}}], | |
| 265 | + "totals": {"requests": 18_240, "rows": 41_000_000, "rows_parquet": 2_000_000, "status_429": 12}}, "meta": {"count": 1}}}}}}, | |
| 223 | 266 | openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]}) |
| 224 | 267 | def admin_usage(days: int = Query(30, ge=1, le=365), top: int = Query(20, ge=1, le=200), |
| 225 | 268 | s: Session = Depends(get_session), _: User = Depends(require_admin)): |
@@ -231,22 +274,30 @@ def admin_usage(days: int = Query(30, ge=1, le=365), top: int = Query(20, ge=1, | ||
| 231 | 274 | owners[f"key:{k.id}"] = {"id": u.id, "email": u.email, "tier": u.tier, "key_name": k.name, "prefix": k.prefix} |
| 232 | 275 | for t in tops: |
| 233 | 276 | t["user"] = owners.get(t["principal"]) |
| 234 | − return json_response({"per_day": usage.totals_per_day(days=days), "top": tops}) | |
| 277 | + per_day = usage.totals_per_day(days=days) | |
| 278 | + totals = {f: sum(d[f] for d in per_day) for f in ("requests", "rows", "rows_parquet", "status_429")} | |
| 279 | + return json_response({"per_day": per_day, "top": tops, "totals": totals}) | |
| 235 | 280 | |
| 236 | 281 | |
| 237 | 282 | @router.get( |
| 238 | 283 | "/audit", summary="Audit log", |
| 239 | − description="Newest first, cursor on id. Actions: user.create, user.update, user.login, key.create, key.revoke, token.*, …", | |
| 284 | + description=f"Newest first, cursor on id. Filters: `action` (prefix), `actor` (exact, e.g. `user:7` or `cli`), " | |
| 285 | + f"`target` (exact, e.g. `user:7` or `key:12`). Actions: {AUDIT_ACTIONS}.", | |
| 240 | 286 | responses={200: {"content": {"application/json": {"example": {"data": [{ |
| 241 | 287 | "id": 91, "ts": "2026-09-04T16:00:00Z", "actor": "user:1", "action": "user.update", "target": "user:7", |
| 242 | 288 | "meta": {"tier": "high_usage"}}], "meta": {"count": 1, "next_cursor": None}}}}}}, |
| 243 | 289 | openapi_extra={"x-errors": ADMIN_ERRORS + ["INVALID_PARAMETER"]}) |
| 244 | 290 | def audit_log(limit: int | None = Query(None, ge=1, le=1000), cursor: str | None = None, action: str | None = None, |
| 291 | + actor: str | None = Query(None, max_length=64), target: str | None = Query(None, max_length=64), | |
| 245 | 292 | s: Session = Depends(get_session), _: User = Depends(require_admin)): |
| 246 | 293 | lim = clamp_limit(limit, 100, 1000) |
| 247 | 294 | q = select(AuditLog).order_by(AuditLog.id.desc()) |
| 248 | 295 | if action: |
| 249 | 296 | q = q.where(AuditLog.action.like(f"{action}%")) |
| 297 | + if actor: | |
| 298 | + q = q.where(AuditLog.actor == actor.strip()) | |
| 299 | + if target: | |
| 300 | + q = q.where(AuditLog.target == target.strip()) | |
| 250 | 301 | before = decode_cursor(cursor) |
| 251 | 302 | if before is not None: |
| 252 | 303 | q = q.where(AuditLog.id < int(before)) |
modified
hfmarketdata/api/accounts/routes_auth.py
+105 −44
@@ -1,28 +1,38 @@ | ||
| 1 | 1 | """`/v1/auth` — signup, e-mail verification, login/logout, password reset, invitation acceptance. |
| 2 | 2 | |
| 3 | −All endpoints are throttled per IP (10 requests / hour, see ratelimit.middleware) and answer with the | |
| 4 | −uniform error envelope. State-changing calls must be JSON (`Content-Type: application/json`). | |
| 3 | +Throttled per IP and per endpoint (login 20/h, signup 5/h, forgot 5/h + 5/h per e-mail, verify 30/h; see | |
| 4 | +ratelimit.middleware); login/signup/forgot fail closed when Redis is down. Answers use the uniform error | |
| 5 | +envelope. State-changing calls must be JSON (`Content-Type: application/json`) or carry `X-Requested-With: hfmd`. | |
| 6 | +Signup and forgot never reveal whether an address exists. | |
| 5 | 7 | |
| 6 | 8 | Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 7 | 9 | """ |
| 8 | 10 | from __future__ import annotations |
| 9 | 11 | |
| 12 | +from urllib.parse import quote | |
| 13 | + | |
| 10 | 14 | from fastapi import APIRouter, Depends, Query, Request |
| 15 | +from fastapi.responses import RedirectResponse | |
| 11 | 16 | from pydantic import BaseModel, Field |
| 12 | 17 | from sqlalchemy.orm import Session |
| 13 | 18 | |
| 14 | 19 | from core.config import settings |
| 15 | 20 | from core.db import get_session |
| 21 | +from core.errors import ApiError | |
| 16 | 22 | from core.responses import json_response |
| 23 | +from ratelimit import redis_limiter as rl | |
| 24 | +from ratelimit.tiers import AUTH_FAIL_CLOSED_RETRY_S, AUTH_WINDOW_S, FORGOT_PER_EMAIL_PER_HOUR | |
| 17 | 25 | |
| 18 | 26 | from . import security, service |
| 19 | −from .deps import csrf, debug_links_enabled | |
| 27 | +from .deps import client_ip_hash, csrf, debug_links_enabled | |
| 20 | 28 | |
| 21 | 29 | router = APIRouter(prefix="/v1/auth", tags=["auth"]) |
| 22 | 30 | |
| 23 | 31 | USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free", |
| 24 | 32 | "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z", |
| 25 | − "last_login_at": "2026-09-04T14:05:40Z"} | |
| 33 | + "last_login_at": "2026-09-04T14:05:40Z", "quota_alerts": True, "locked_until": None} | |
| 34 | +THROTTLE_NOTE = ("\n\nThrottled per IP: see the limit of each endpoint in its description; `429 RATE_LIMIT_EXCEEDED` " | |
| 35 | + "with `Retry-After`. `415 UNSUPPORTED_MEDIA_TYPE` when the body is not JSON.") | |
| 26 | 36 | |
| 27 | 37 | |
| 28 | 38 | class SignupBody(BaseModel): |
@@ -32,28 +42,37 @@ class SignupBody(BaseModel): | ||
| 32 | 42 | |
| 33 | 43 | |
| 34 | 44 | class LoginBody(BaseModel): |
| 35 | − email: str = Field(..., examples=["ada@example.com"]) | |
| 36 | − password: str | |
| 45 | + email: str = Field(..., examples=["ada@example.com"], max_length=254) | |
| 46 | + password: str = Field(..., max_length=256) | |
| 37 | 47 | |
| 38 | 48 | |
| 39 | 49 | class EmailBody(BaseModel): |
| 40 | − email: str = Field(..., examples=["ada@example.com"]) | |
| 50 | + email: str = Field(..., examples=["ada@example.com"], max_length=254) | |
| 51 | + | |
| 52 | + | |
| 53 | +class TokenBody(BaseModel): | |
| 54 | + token: str = Field(..., max_length=256, description="The `token` query parameter of the link received by e-mail.") | |
| 41 | 55 | |
| 42 | 56 | |
| 43 | 57 | class TokenPasswordBody(BaseModel): |
| 44 | − token: str = Field(..., description="The `token` query parameter of the link received by e-mail.") | |
| 58 | + token: str = Field(..., max_length=256, description="The `token` query parameter of the link received by e-mail.") | |
| 45 | 59 | password: str = Field(..., min_length=1, max_length=256, description="At least 10 characters.") |
| 46 | 60 | |
| 47 | 61 | |
| 62 | +class ResetBody(TokenPasswordBody): | |
| 63 | + revoke_keys: bool = Field(True, description="Also revoke every active API key of the account (recommended: a reset " | |
| 64 | + "usually means the credentials may have leaked). Default true.") | |
| 65 | + | |
| 66 | + | |
| 48 | 67 | def _with_link(payload: dict, issued: service.IssuedToken | None) -> dict: |
| 49 | 68 | if issued is not None and debug_links_enabled(): |
| 50 | 69 | payload["debug_link"] = issued.link |
| 51 | 70 | return payload |
| 52 | 71 | |
| 53 | 72 | |
| 54 | −def _session_response(user, status: int = 200): | |
| 55 | − resp = json_response(service.user_public(user), status=status) | |
| 56 | − security.set_session_cookie(resp, user.id) | |
| 73 | +def _session_response(user, status: int = 200, extra: dict | None = None): | |
| 74 | + resp = json_response({**service.user_public(user), **(extra or {})}, status=status) | |
| 75 | + security.set_session_cookie(resp, user.id, user.session_version or 1) | |
| 57 | 76 | return resp |
| 58 | 77 | |
| 59 | 78 | |
@@ -61,48 +80,74 @@ def _session_response(user, status: int = 200): | ||
| 61 | 80 | "/signup", summary="Create an account (sends a verification e-mail)", |
| 62 | 81 | description=( |
| 63 | 82 | "Creates an account and e-mails a verification link (valid 48 hours). The account cannot sign in " |
| 64 | − "before the e-mail is verified. If the address was **invited** by an admin, the invitation is re-sent " | |
| 65 | − "instead. Passwords: 10 characters minimum, hashed with argon2.\n\n" | |
| 66 | − "Throttled: 10 requests per hour per IP on every `/v1/auth/*` endpoint."), | |
| 83 | + "before the e-mail is verified. Passwords: 10 characters minimum, hashed with argon2.\n\n" | |
| 84 | + "**The answer is always `202 verification_sent`**, whatever the state of the address: a new address gets a " | |
| 85 | + "verification mail, an *invited* address gets its invitation again, an unverified one a fresh verification " | |
| 86 | + "mail, an existing verified account a notice (\"you already have an account\"). An existing account is never " | |
| 87 | + "modified by a signup (no password or name change).\n\n" | |
| 88 | + "Throttled: 5 requests per hour per IP." + THROTTLE_NOTE), | |
| 67 | 89 | status_code=202, |
| 68 | − responses={202: {"description": "Verification e-mail sent (or invitation re-sent).", "content": {"application/json": { | |
| 90 | + responses={202: {"description": "Verification e-mail sent (uniform answer).", "content": {"application/json": { | |
| 69 | 91 | "example": {"data": {"status": "verification_sent", "email": "ada@example.com"}, "meta": {"count": 1}}}}}}, |
| 70 | − openapi_extra={"x-errors": ["EMAIL_TAKEN", "WEAK_PASSWORD", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 92 | + openapi_extra={"x-errors": ["WEAK_PASSWORD", "INVALID_PARAMETER", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 71 | 93 | dependencies=[Depends(csrf)]) |
| 72 | 94 | def signup(body: SignupBody, s: Session = Depends(get_session)): |
| 73 | 95 | issued = service.signup(s, body.email, body.name, body.password) |
| 74 | − status = "invitation_sent" if issued.token.kind == "invite" else "verification_sent" | |
| 75 | − return json_response(_with_link({"status": status, "email": issued.user.email}, issued), status=202) | |
| 96 | + return json_response(_with_link({"status": "verification_sent", "email": service.normalize_email(body.email)}, issued), | |
| 97 | + status=202) | |
| 76 | 98 | |
| 77 | 99 | |
| 78 | 100 | @router.get( |
| 79 | − "/verify", summary="Verify an e-mail address", | |
| 80 | − description=("Consumes the token of the verification link, activates the account and opens a session " | |
| 81 | − "(HttpOnly cookie `hfmd_session`). The web app calls this from `/verify?token=…`."), | |
| 82 | − responses={200: {"description": "Account verified; session cookie set.", "content": {"application/json": { | |
| 83 | − "example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, | |
| 84 | − openapi_extra={"x-errors": ["INVALID_TOKEN"]}) | |
| 85 | −def verify(token: str = Query(..., description="Token from the e-mail link"), s: Session = Depends(get_session)): | |
| 86 | − user = service.verify_email(s, token) | |
| 87 | − return _session_response(user) | |
| 101 | + "/verify", summary="Verification link landing (redirects to the web page, does not consume the token)", | |
| 102 | + description=("Legacy entry point kept for compatibility: answers **302** to the web page `/verify?token=…` " | |
| 103 | + "without consuming the token, so mail pre-fetchers and link scanners cannot burn it. The page " | |
| 104 | + "then calls `POST /v1/auth/verify`."), | |
| 105 | + status_code=302, responses={302: {"description": "Redirect to the web page."}}, | |
| 106 | + openapi_extra={"x-errors": []}) | |
| 107 | +def verify_redirect(token: str = Query(..., max_length=256, description="Token from the e-mail link")): | |
| 108 | + return RedirectResponse(f"{settings.public_url}/verify?token={quote(token, safe='')}", status_code=302) | |
| 109 | + | |
| 110 | + | |
| 111 | +@router.post( | |
| 112 | + "/verify", summary="Verify an e-mail address (or confirm an e-mail change)", | |
| 113 | + description=("Consumes the token of a verification link (`kind: verify` → activates the account) or of an " | |
| 114 | + "e-mail-change confirmation (`kind: email_change` → the new address becomes effective, every other " | |
| 115 | + "session is signed out) and opens a session (HttpOnly cookie `hfmd_session`). Called by the web page " | |
| 116 | + "`/verify?token=…`, which removes the token from the address bar first.\n\nThrottled: 30 per hour per IP." | |
| 117 | + + THROTTLE_NOTE), | |
| 118 | + responses={200: {"description": "Verified; session cookie set.", "content": {"application/json": { | |
| 119 | + "example": {"data": {**USER_EXAMPLE, "kind": "verify"}, "meta": {"count": 1}}}}}}, | |
| 120 | + openapi_extra={"x-errors": ["INVALID_TOKEN", "ACCOUNT_DISABLED", "EMAIL_TAKEN", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 121 | + dependencies=[Depends(csrf)]) | |
| 122 | +def verify(body: TokenBody, s: Session = Depends(get_session)): | |
| 123 | + kind, user = service.verify_any(s, body.token) | |
| 124 | + return _session_response(user, extra={"kind": kind}) | |
| 88 | 125 | |
| 89 | 126 | |
| 90 | 127 | @router.post( |
| 91 | 128 | "/login", summary="Sign in with e-mail + password", |
| 92 | − description=("Opens a 30-day session (HttpOnly, SameSite=Lax cookie). Requires a verified e-mail. " | |
| 93 | − "Programmatic access does not need a session: use your API key as `Authorization: Bearer …`."), | |
| 129 | + description=("Opens a 30-day session (HttpOnly, SameSite=Lax cookie). Requires a verified e-mail. The check costs " | |
| 130 | + "the same for unknown and known addresses. After 5 wrong passwords the account is locked with a " | |
| 131 | + "progressive delay (30 s, 2 min, 10 min, then 1 h) → `423 ACCOUNT_LOCKED` with `Retry-After`; the " | |
| 132 | + "counter resets on a successful sign-in and a warning e-mail is sent at the 10th failure.\n\n" | |
| 133 | + "Programmatic access does not need a session: use your API key as `Authorization: Bearer …` " | |
| 134 | + "(read-only on the account). Throttled: 20 per hour per IP; fails closed (429) when the rate limiter " | |
| 135 | + "is unavailable." + THROTTLE_NOTE), | |
| 94 | 136 | responses={200: {"description": "Signed in.", "content": {"application/json": { |
| 95 | 137 | "example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, |
| 96 | − openapi_extra={"x-errors": ["INVALID_CREDENTIALS", "EMAIL_NOT_VERIFIED", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 138 | + openapi_extra={"x-errors": ["INVALID_CREDENTIALS", "ACCOUNT_LOCKED", "EMAIL_NOT_VERIFIED", "ACCOUNT_DISABLED", | |
| 139 | + "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 97 | 140 | dependencies=[Depends(csrf)]) |
| 98 | −def login(body: LoginBody, s: Session = Depends(get_session)): | |
| 99 | − user = service.login(s, body.email, body.password) | |
| 100 | − service.audit(s, f"user:{user.id}", "user.login", f"user:{user.id}") | |
| 141 | +def login(body: LoginBody, request: Request, s: Session = Depends(get_session)): | |
| 142 | + user = service.login(s, body.email, body.password, ip_hash=client_ip_hash(request), | |
| 143 | + user_agent=request.headers.get("user-agent")) | |
| 101 | 144 | return _session_response(user) |
| 102 | 145 | |
| 103 | 146 | |
| 104 | 147 | @router.post( |
| 105 | − "/logout", summary="Sign out", description="Clears the session cookie. Send `{}` as JSON body.", | |
| 148 | + "/logout", summary="Sign out (this browser)", | |
| 149 | + description="Clears the session cookie. Send `{}` as JSON body (or an empty body with `X-Requested-With: hfmd`). " | |
| 150 | + "To sign out every browser at once use `POST /v1/me/sessions/revoke-all`.", | |
| 106 | 151 | responses={200: {"content": {"application/json": {"example": {"data": {"status": "signed_out"}, "meta": {"count": 1}}}}}}, |
| 107 | 152 | openapi_extra={"x-errors": ["UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) |
| 108 | 153 | def logout(request: Request): |
@@ -113,33 +158,49 @@ def logout(request: Request): | ||
| 113 | 158 | |
| 114 | 159 | @router.post( |
| 115 | 160 | "/forgot", summary="Request a password-reset link", |
| 116 | − description=("Always answers 202 — whether or not the address exists — and e-mails a reset link valid " | |
| 117 | − "1 hour when it does."), | |
| 161 | + description=("Always answers `202 reset_sent` — whether or not the address exists — and e-mails a reset link valid " | |
| 162 | + "1 hour when it does (active or invited account). Throttled: 5 per hour per IP **and** 5 per hour per " | |
| 163 | + "e-mail address; fails closed (429) when the rate limiter is unavailable." + THROTTLE_NOTE), | |
| 118 | 164 | status_code=202, |
| 119 | 165 | responses={202: {"content": {"application/json": {"example": {"data": {"status": "reset_sent"}, "meta": {"count": 1}}}}}}, |
| 120 | 166 | openapi_extra={"x-errors": ["UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) |
| 121 | 167 | def forgot(body: EmailBody, s: Session = Depends(get_session)): |
| 168 | + email_hash = security.hash_token("forgot|" + (body.email or "").strip().lower()) | |
| 169 | + d = rl.throttle(f"auth:forgot:email:{email_hash}", limit=FORGOT_PER_EMAIL_PER_HOUR, window_s=AUTH_WINDOW_S, namespace="rl") | |
| 170 | + if d is None: | |
| 171 | + raise ApiError(429, "RATE_LIMIT_EXCEEDED", "Password reset is temporarily unavailable (rate limiter offline). " | |
| 172 | + f"Retry in {AUTH_FAIL_CLOSED_RETRY_S} s.", type="requests_per_hour", | |
| 173 | + headers={"Retry-After": str(AUTH_FAIL_CLOSED_RETRY_S)}) | |
| 174 | + if not d.allowed_requests: | |
| 175 | + retry = max(1, d.reset_requests - rl.now_ms() // 1000) | |
| 176 | + raise ApiError(429, "RATE_LIMIT_EXCEEDED", f"Too many reset requests for this address: {FORGOT_PER_EMAIL_PER_HOUR} " | |
| 177 | + f"per hour. Retry in {retry} s.", type="requests_per_hour", headers={"Retry-After": str(retry)}) | |
| 122 | 178 | issued = service.forgot(s, body.email) |
| 123 | 179 | return json_response(_with_link({"status": "reset_sent"}, issued), status=202) |
| 124 | 180 | |
| 125 | 181 | |
| 126 | 182 | @router.post( |
| 127 | 183 | "/reset", summary="Set a new password from a reset link", |
| 128 | − description="Consumes the reset token, stores the new password and opens a session.", | |
| 129 | − responses={200: {"content": {"application/json": {"example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, | |
| 130 | − openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 131 | −def reset(body: TokenPasswordBody, s: Session = Depends(get_session)): | |
| 132 | − user = service.reset_password(s, body.token, body.password) | |
| 133 | − return _session_response(user) | |
| 184 | + description=("Consumes the reset token, stores the new password, **signs out every existing session** and, unless " | |
| 185 | + "`revoke_keys` is false, revokes every active API key. Opens a fresh session. Throttled: 10 per hour per IP." | |
| 186 | + + THROTTLE_NOTE), | |
| 187 | + responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "keys_revoked": 2}, "meta": {"count": 1}}}}}}, | |
| 188 | + openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 189 | + dependencies=[Depends(csrf)]) | |
| 190 | +def reset(body: ResetBody, s: Session = Depends(get_session)): | |
| 191 | + user, revoked = service.reset_password(s, body.token, body.password, revoke_keys=body.revoke_keys) | |
| 192 | + return _session_response(user, extra={"keys_revoked": revoked}) | |
| 134 | 193 | |
| 135 | 194 | |
| 136 | 195 | @router.post( |
| 137 | 196 | "/accept-invite", summary="Accept an invitation (set your password)", |
| 138 | 197 | description=("Invited users (created by an admin or the CLI) choose their password here. The account " |
| 139 | 198 | "becomes active, the e-mail is considered verified and a session is opened. An API key was " |
| 140 | − f"already created for them — visible in the dashboard ({settings.public_url}/dashboard/keys)."), | |
| 199 | + f"already created for them — visible in the dashboard ({settings.public_url}/dashboard/keys). " | |
| 200 | + "Throttled: 10 per hour per IP." + THROTTLE_NOTE), | |
| 141 | 201 | responses={200: {"content": {"application/json": {"example": {"data": USER_EXAMPLE, "meta": {"count": 1}}}}}}, |
| 142 | − openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 202 | + openapi_extra={"x-errors": ["INVALID_TOKEN", "WEAK_PASSWORD", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 203 | + dependencies=[Depends(csrf)]) | |
| 143 | 204 | def accept_invite(body: TokenPasswordBody, s: Session = Depends(get_session)): |
| 144 | 205 | user = service.accept_invite(s, body.token, body.password) |
| 145 | 206 | return _session_response(user) |
modified
hfmarketdata/api/accounts/routes_me.py
+221 −37
@@ -1,12 +1,15 @@ | ||
| 1 | −"""`/v1/me` — profile, API keys, usage — and the public `/v1/limits`. | |
| 1 | +"""`/v1/me` — profile, API keys, usage, live quotas, account security — and the public `/v1/limits`. | |
| 2 | 2 | |
| 3 | 3 | Authentication: session cookie (web dashboard) or `Authorization: Bearer hfmd_live_…` (programmatic). |
| 4 | +An API key is **read-only** here: it may call `GET /v1/me`, `GET /v1/me/usage`, `GET /v1/me/usage.csv` and | |
| 5 | +`GET /v1/me/limits`; every other endpoint requires a browser session (`403 SESSION_REQUIRED`). | |
| 4 | 6 | These endpoints are NOT charged against the data quota. |
| 5 | 7 | |
| 6 | 8 | Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 7 | 9 | """ |
| 8 | 10 | from __future__ import annotations |
| 9 | 11 | |
| 12 | +from datetime import datetime | |
| 10 | 13 | from typing import Literal |
| 11 | 14 | |
| 12 | 15 | from fastapi import APIRouter, Depends, Query, Request |
@@ -15,27 +18,64 @@ from sqlalchemy.orm import Session | ||
| 15 | 18 | |
| 16 | 19 | from core.config import settings |
| 17 | 20 | from core.db import get_session |
| 21 | +from core.errors import ApiError | |
| 18 | 22 | from core.responses import json_response |
| 23 | +from fastapi.responses import PlainTextResponse | |
| 19 | 24 | from ratelimit import redis_limiter as rl |
| 20 | 25 | from ratelimit import usage |
| 26 | +from ratelimit.middleware import Principal | |
| 21 | 27 | from ratelimit.middleware import snapshot as rl_snapshot |
| 22 | 28 | from ratelimit.tiers import TIERS, tier_for, tiers_public, upgrade_hint |
| 23 | 29 | |
| 24 | −from . import service | |
| 25 | −from .deps import csrf, current_user | |
| 30 | +from . import security, service | |
| 31 | +from .deps import csrf, current_user, require_session | |
| 26 | 32 | from .models import User |
| 27 | 33 | |
| 28 | 34 | router = APIRouter(prefix="/v1/me", tags=["me"]) |
| 29 | 35 | limits_router = APIRouter(prefix="/v1", tags=["meta"]) |
| 30 | 36 | |
| 31 | 37 | KEY_EXAMPLE = {"id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "status": "active", "tier_override": None, |
| 32 | − "created_at": "2026-09-04T14:06:02Z", "last_used_at": "2026-09-04T15:31:09Z", "revoked_at": None, | |
| 33 | − "principal": "key:12"} | |
| 38 | + "created_at": "2026-09-04T14:06:02Z", "last_used_at": "2026-09-04T15:31:09Z", "last_used_ip": "3f9a1c0b7d2e4f61", | |
| 39 | + "revoked_at": None, "expires_at": None, "expired": False, "note": None, "scopes": ["data"], "principal": "key:12"} | |
| 34 | 40 | LIMITS_EXAMPLE = {"tier": "free", "window_seconds": 60, "requests": 120, "rows": 1_000_000, "max_rows_per_request": 50_000} |
| 41 | +USER_EXAMPLE = {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free", | |
| 42 | + "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z", | |
| 43 | + "last_login_at": "2026-09-04T14:05:40Z", "quota_alerts": True, "locked_until": None, "keys_active": 1} | |
| 44 | +POINT_EXAMPLE = {"t": "2026-09-04T15:30:00Z", "requests": 12, "rows": 60_000, "status_429": 0, "bytes": 1_843_200, "rows_parquet": 0} | |
| 45 | +SESSION_ERRORS = ["AUTH_REQUIRED", "SESSION_REQUIRED", "ACCOUNT_DISABLED", "UNSUPPORTED_MEDIA_TYPE"] | |
| 46 | +SESSION_NOTE = "\n\nRequires a **browser session** (cookie) — refused with `403 SESSION_REQUIRED` when called with an API key." | |
| 35 | 47 | |
| 36 | 48 | |
| 37 | 49 | class KeyCreateBody(BaseModel): |
| 38 | 50 | name: str = Field("default", max_length=100, examples=["backtest-laptop"]) |
| 51 | + note: str | None = Field(None, max_length=500, description="Free text shown in the dashboard (where the key lives, what for).") | |
| 52 | + expires_in_days: int | None = Field(None, ge=1, le=service.MAX_KEY_EXPIRY_DAYS, description="Optional lifetime; the key stops working afterwards.") | |
| 53 | + expires_at: datetime | None = Field(None, description="Alternative to `expires_in_days`: absolute UTC instant (≤ 365 days ahead).") | |
| 54 | + scopes: list[str] | None = Field(None, description='Reserved. Only `["data"]` is accepted today (default).') | |
| 55 | + | |
| 56 | + | |
| 57 | +class KeyUpdateBody(BaseModel): | |
| 58 | + name: str | None = Field(None, max_length=100) | |
| 59 | + note: str | None = Field(None, max_length=500) | |
| 60 | + | |
| 61 | + | |
| 62 | +class PasswordChangeBody(BaseModel): | |
| 63 | + current_password: str = Field(..., max_length=256) | |
| 64 | + new_password: str = Field(..., min_length=1, max_length=256, description="At least 10 characters.") | |
| 65 | + | |
| 66 | + | |
| 67 | +class EmailChangeBody(BaseModel): | |
| 68 | + new_email: str = Field(..., max_length=254, examples=["ada.lovelace@example.com"]) | |
| 69 | + password: str = Field(..., max_length=256, description="Current password (re-authentication).") | |
| 70 | + | |
| 71 | + | |
| 72 | +class DeleteBody(BaseModel): | |
| 73 | + password: str = Field(..., max_length=256, description="Current password (re-authentication).") | |
| 74 | + | |
| 75 | + | |
| 76 | +class ProfileBody(BaseModel): | |
| 77 | + name: str | None = Field(None, max_length=200) | |
| 78 | + quota_alerts: bool | None = Field(None, description="E-mail at 80 % / 100 % of the rows quota or first 429 of the day (max 1 mail/day).") | |
| 39 | 79 | |
| 40 | 80 | |
| 41 | 81 | def _limits_for(u: User) -> dict: |
@@ -44,86 +84,231 @@ def _limits_for(u: User) -> dict: | ||
| 44 | 84 | "max_rows_per_request": t.max_rows_per_request, "upgrade": upgrade_hint(t)} |
| 45 | 85 | |
| 46 | 86 | |
| 87 | +def _session_refresh(resp, u: User): | |
| 88 | + """After a session_version bump the caller's own cookie must be re-issued or it would be signed out too.""" | |
| 89 | + security.set_session_cookie(resp, u.id, u.session_version or 1) | |
| 90 | + return resp | |
| 91 | + | |
| 92 | + | |
| 93 | +def _principals(s: Session, u: User, key_id: int | None) -> list[str]: | |
| 94 | + if key_id is not None: | |
| 95 | + return [service.get_key(s, u, key_id).principal] | |
| 96 | + return [k.principal for k in service.list_keys(s, u)] | |
| 97 | + | |
| 98 | + | |
| 99 | +# ------------------------------------------------------------------------------------------------ profile | |
| 100 | + | |
| 47 | 101 | @router.get( |
| 48 | 102 | "", summary="Your profile, tier and limits", |
| 49 | − description="Profile of the signed-in user (cookie session or API key), the tier limits that apply to their keys " | |
| 50 | − "and the number of active keys.", | |
| 103 | + description="Profile of the signed-in user (cookie session or API key), the tier limits that apply to their keys, " | |
| 104 | + "the number of active keys and `auth` = how this call was authenticated (`session` | `key`).", | |
| 51 | 105 | responses={200: {"content": {"application/json": {"example": {"data": { |
| 52 | − "user": {"id": 7, "email": "ada@example.com", "name": "Ada Lovelace", "role": "user", "tier": "free", | |
| 53 | − "status": "active", "email_verified": True, "created_at": "2026-09-04T14:02:11Z", | |
| 54 | − "last_login_at": "2026-09-04T14:05:40Z", "keys_active": 1}, | |
| 55 | − "limits": LIMITS_EXAMPLE}, "meta": {"count": 1}}}}}}, | |
| 106 | + "user": USER_EXAMPLE, "limits": LIMITS_EXAMPLE, "auth": "session"}, "meta": {"count": 1}}}}}}, | |
| 56 | 107 | openapi_extra={"x-errors": ["AUTH_REQUIRED", "ACCOUNT_DISABLED"]}) |
| 57 | −def me(u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 108 | +def me(request: Request, u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 58 | 109 | return json_response({"user": service.user_public(u, keys_count=len(service.active_keys(s, u))), |
| 59 | − "limits": _limits_for(u)}) | |
| 110 | + "limits": _limits_for(u), "auth": getattr(request.state, "auth_kind", None)}) | |
| 111 | + | |
| 112 | + | |
| 113 | +@router.patch( | |
| 114 | + "", summary="Update your profile (name, quota alerts)", | |
| 115 | + description="Change your display name and/or the quota-alert e-mails opt-in." + SESSION_NOTE, | |
| 116 | + responses={200: {"content": {"application/json": {"example": {"data": {**USER_EXAMPLE, "quota_alerts": False}, "meta": {"count": 1}}}}}}, | |
| 117 | + openapi_extra={"x-errors": SESSION_ERRORS}, dependencies=[Depends(csrf)]) | |
| 118 | +def update_profile(body: ProfileBody, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 119 | + if body.name is not None: | |
| 120 | + u.name = body.name.strip()[:200] | |
| 121 | + service.audit(s, f"user:{u.id}", "account.name_change", f"user:{u.id}") | |
| 122 | + if body.quota_alerts is not None: | |
| 123 | + service.set_quota_alerts(s, u, body.quota_alerts) | |
| 124 | + return json_response(service.user_public(u, keys_count=len(service.active_keys(s, u)))) | |
| 125 | + | |
| 126 | + | |
| 127 | +@router.post( | |
| 128 | + "/password", summary="Change your password", | |
| 129 | + description="Requires the current password. Every **other** session is signed out (this browser stays signed in) " | |
| 130 | + "and a notice is e-mailed." + SESSION_NOTE, | |
| 131 | + responses={200: {"content": {"application/json": {"example": {"data": {"status": "password_changed"}, "meta": {"count": 1}}}}}}, | |
| 132 | + openapi_extra={"x-errors": SESSION_ERRORS + ["INVALID_CREDENTIALS", "WEAK_PASSWORD"]}, dependencies=[Depends(csrf)]) | |
| 133 | +def change_password(body: PasswordChangeBody, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 134 | + service.change_password(s, u, body.current_password, body.new_password) | |
| 135 | + return _session_refresh(json_response({"status": "password_changed"}), u) | |
| 136 | + | |
| 137 | + | |
| 138 | +@router.post( | |
| 139 | + "/email", summary="Change your e-mail address (confirmation link sent to the new address)", | |
| 140 | + description=("Requires the current password. A confirmation link (valid 48 hours) is e-mailed to the **new** " | |
| 141 | + "address; the change only becomes effective when it is opened (`POST /v1/auth/verify`), and every " | |
| 142 | + "session is then signed out. The answer is the same whether or not the new address is already used by " | |
| 143 | + "another account (that account receives a notice instead)." + SESSION_NOTE), | |
| 144 | + status_code=202, | |
| 145 | + responses={202: {"content": {"application/json": {"example": {"data": {"status": "confirmation_sent", "new_email": "ada.lovelace@example.com"}, "meta": {"count": 1}}}}}}, | |
| 146 | + openapi_extra={"x-errors": SESSION_ERRORS + ["INVALID_CREDENTIALS", "INVALID_PARAMETER"]}, dependencies=[Depends(csrf)]) | |
| 147 | +def change_email(body: EmailChangeBody, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 148 | + issued = service.request_email_change(s, u, body.new_email, body.password) | |
| 149 | + payload = {"status": "confirmation_sent", "new_email": service.normalize_email(body.new_email)} | |
| 150 | + from .deps import debug_links_enabled | |
| 151 | + if issued is not None and debug_links_enabled(): | |
| 152 | + payload["debug_link"] = issued.link | |
| 153 | + return json_response(payload, status=202) | |
| 154 | + | |
| 155 | + | |
| 156 | +@router.post( | |
| 157 | + "/sessions/revoke-all", summary="Sign out everywhere", | |
| 158 | + description="Invalidates every session cookie of the account (including other browsers and devices). This browser " | |
| 159 | + "receives a fresh cookie and stays signed in. Body `{}`." + SESSION_NOTE, | |
| 160 | + responses={200: {"content": {"application/json": {"example": {"data": {"status": "sessions_revoked"}, "meta": {"count": 1}}}}}}, | |
| 161 | + openapi_extra={"x-errors": SESSION_ERRORS}, dependencies=[Depends(csrf)]) | |
| 162 | +def revoke_all_sessions(u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 163 | + service.revoke_all_sessions(s, u) | |
| 164 | + return _session_refresh(json_response({"status": "sessions_revoked"}), u) | |
| 60 | 165 | |
| 61 | 166 | |
| 167 | +@router.delete( | |
| 168 | + "", summary="Delete your account", | |
| 169 | + description=("Requires the current password. Revokes every API key, signs out every session, voids pending links and " | |
| 170 | + "anonymises the account (e-mail and name removed, status `deleted`). Aggregated usage counters are kept " | |
| 171 | + "anonymously. The last active administrator cannot delete their account." + SESSION_NOTE), | |
| 172 | + responses={200: {"content": {"application/json": {"example": {"data": {"status": "deleted"}, "meta": {"count": 1}}}}}}, | |
| 173 | + openapi_extra={"x-errors": SESSION_ERRORS + ["INVALID_CREDENTIALS", "LAST_ADMIN"]}, dependencies=[Depends(csrf)]) | |
| 174 | +def delete_me(body: DeleteBody, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 175 | + if not security.verify_password(body.password, u.password_hash): | |
| 176 | + raise ApiError(401, "INVALID_CREDENTIALS", "The current password is wrong.") | |
| 177 | + service.delete_account(s, u, actor=f"user:{u.id}") | |
| 178 | + resp = json_response({"status": "deleted"}) | |
| 179 | + security.clear_session_cookie(resp) | |
| 180 | + return resp | |
| 181 | + | |
| 182 | + | |
| 183 | +# --------------------------------------------------------------------------------------------------- keys | |
| 184 | + | |
| 62 | 185 | @router.get( |
| 63 | 186 | "/keys", summary="List your API keys", |
| 64 | − description="Active and revoked keys. Only the display prefix is stored — the full key is never retrievable.", | |
| 187 | + description="Active and revoked keys with creation / last use (time + hashed IP) / expiry. Only the display prefix is " | |
| 188 | + "stored — the full key is never retrievable." + SESSION_NOTE, | |
| 65 | 189 | responses={200: {"content": {"application/json": {"example": {"data": [KEY_EXAMPLE], "meta": {"count": 1}}}}}}, |
| 66 | − openapi_extra={"x-errors": ["AUTH_REQUIRED"]}) | |
| 67 | −def list_keys(u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 190 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "SESSION_REQUIRED"]}) | |
| 191 | +def list_keys(u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 68 | 192 | return json_response([service.key_public(k) for k in service.list_keys(s, u)]) |
| 69 | 193 | |
| 70 | 194 | |
| 71 | 195 | @router.post( |
| 72 | 196 | "/keys", summary="Create an API key (shown once)", status_code=201, |
| 73 | 197 | description=("Creates a key `hfmd_live_…` (32 base62 characters). **The full key is returned only in this " |
| 74 | − f"response** — store it now. Up to {service.MAX_ACTIVE_KEYS} active keys per account. " | |
| 75 | − "A notice (without the key) is e-mailed to you."), | |
| 198 | + f"response** — store it now. Up to {service.MAX_ACTIVE_KEYS} active keys per account. Optional `note`, " | |
| 199 | + "`expires_in_days` / `expires_at` (the key stops working afterwards) and `scopes` (reserved, `[\"data\"]`). " | |
| 200 | + "A notice (without the key) is e-mailed to you." + SESSION_NOTE), | |
| 76 | 201 | responses={201: {"content": {"application/json": {"example": {"data": { |
| 77 | 202 | **KEY_EXAMPLE, "key": "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00"}, "meta": {"count": 1}}}}}}, |
| 78 | − openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 203 | + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_LIMIT_REACHED", "INVALID_PARAMETER"]}, | |
| 79 | 204 | dependencies=[Depends(csrf)]) |
| 80 | −def create_key(body: KeyCreateBody, u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 81 | − raw, k = service.create_key(s, u, body.name, actor=f"user:{u.id}") | |
| 205 | +def create_key(body: KeyCreateBody, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 206 | + expires = service.validate_expiry(body.expires_at, body.expires_in_days) | |
| 207 | + raw, k = service.create_key(s, u, body.name, actor=f"user:{u.id}", expires_at=expires, note=body.note, scopes=body.scopes) | |
| 82 | 208 | return json_response({**service.key_public(k), "key": raw}, status=201) |
| 83 | 209 | |
| 84 | 210 | |
| 211 | +@router.patch( | |
| 212 | + "/keys/{key_id}", summary="Rename a key / edit its note", | |
| 213 | + responses={200: {"content": {"application/json": {"example": {"data": {**KEY_EXAMPLE, "note": "CI runner"}, "meta": {"count": 1}}}}}}, | |
| 214 | + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_NOT_FOUND"]}, dependencies=[Depends(csrf)]) | |
| 215 | +def update_key(key_id: int, body: KeyUpdateBody, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 216 | + k = service.get_key(s, u, key_id) | |
| 217 | + if body.name is not None: | |
| 218 | + k.name = body.name.strip()[:100] or k.name | |
| 219 | + if body.note is not None: | |
| 220 | + k.note = body.note.strip()[:500] or None | |
| 221 | + service.audit(s, f"user:{u.id}", "key.update", f"key:{k.id}") | |
| 222 | + return json_response(service.key_public(k)) | |
| 223 | + | |
| 224 | + | |
| 85 | 225 | @router.delete( |
| 86 | 226 | "/keys/{key_id}", summary="Revoke an API key", |
| 87 | − description="Revocation is immediate (the lookup cache expires within 60 s on every worker). Idempotent.", | |
| 227 | + description="Revocation is immediate on every worker (shared cache generation). Idempotent. A notice is e-mailed." + SESSION_NOTE, | |
| 88 | 228 | responses={200: {"content": {"application/json": {"example": {"data": {**KEY_EXAMPLE, "status": "revoked", |
| 89 | 229 | "revoked_at": "2026-09-04T16:00:00Z"}, "meta": {"count": 1}}}}}}, |
| 90 | − openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_NOT_FOUND", "UNSUPPORTED_MEDIA_TYPE"]}, dependencies=[Depends(csrf)]) | |
| 91 | −def revoke_key(key_id: int, u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 230 | + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_NOT_FOUND"]}, dependencies=[Depends(csrf)]) | |
| 231 | +def revoke_key(key_id: int, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 92 | 232 | k = service.revoke_key(s, service.get_key(s, u, key_id), actor=f"user:{u.id}") |
| 93 | 233 | return json_response(service.key_public(k)) |
| 94 | 234 | |
| 95 | 235 | |
| 96 | 236 | @router.post( |
| 97 | 237 | "/keys/{key_id}/rotate", summary="Rotate an API key (revoke + create)", status_code=201, |
| 98 | − description="Revokes the key and creates a new one with the same name. The new key is shown once. Send `{}` as body.", | |
| 238 | + description="Revokes the key and creates a new one with the same name, note, scopes and expiry. The new key is shown " | |
| 239 | + "once. A notice is e-mailed. Send `{}` as body." + SESSION_NOTE, | |
| 99 | 240 | responses={201: {"content": {"application/json": {"example": {"data": { |
| 100 | 241 | **KEY_EXAMPLE, "id": 13, "prefix": "hfmd_live_zz98yy76", "key": "hfmd_live_zz98yy76EXAMPLEKEYnotARealOne00", |
| 101 | 242 | "rotated_from": 12}, "meta": {"count": 1}}}}}}, |
| 102 | − openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_NOT_FOUND", "KEY_LIMIT_REACHED", "UNSUPPORTED_MEDIA_TYPE"]}, | |
| 243 | + openapi_extra={"x-errors": SESSION_ERRORS + ["KEY_NOT_FOUND", "KEY_LIMIT_REACHED"]}, | |
| 103 | 244 | dependencies=[Depends(csrf)]) |
| 104 | −def rotate_key(key_id: int, u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 245 | +def rotate_key(key_id: int, u: User = Depends(require_session), s: Session = Depends(get_session)): | |
| 105 | 246 | old = service.get_key(s, u, key_id) |
| 106 | 247 | raw, k = service.rotate_key(s, u, old, actor=f"user:{u.id}") |
| 107 | 248 | return json_response({**service.key_public(k), "key": raw, "rotated_from": old.id}, status=201) |
| 108 | 249 | |
| 109 | 250 | |
| 251 | +# -------------------------------------------------------------------------------------------------- usage | |
| 252 | + | |
| 110 | 253 | @router.get( |
| 111 | − "/usage", summary="Your usage series (requests, rows)", | |
| 112 | − description=("Aggregated over all your keys (active and revoked). `24h` → per minute, `7d` → per hour, " | |
| 113 | − "`30d` → per day. Points are UTC ISO 8601; live (unfolded) minutes are included."), | |
| 254 | + "/usage", summary="Your usage series (requests, rows, 429s)", | |
| 255 | + description=("Aggregated over all your keys (active and revoked) or one key (`key_id`). `24h` → per minute, `7d` → " | |
| 256 | + "per hour, `30d` → per day. Each point: `requests`, `rows`, `status_429`, `bytes`, `rows_parquet`. Points " | |
| 257 | + "are UTC ISO 8601; the live (unfolded) minute is included. Works with an API key."), | |
| 114 | 258 | responses={200: {"content": {"application/json": {"example": {"data": { |
| 115 | 259 | "range": "24h", "step_seconds": 60, "from": "2026-09-03T15:32:00Z", "to": "2026-09-04T15:32:00Z", |
| 116 | − "points": [{"t": "2026-09-04T15:30:00Z", "requests": 12, "rows": 60_000}], | |
| 117 | − "totals": {"requests": 1_240, "rows": 3_100_000}, "principals": ["key:12"]}, "meta": {"count": 1}}}}}}, | |
| 118 | − openapi_extra={"x-errors": ["AUTH_REQUIRED", "INVALID_PARAMETER"]}) | |
| 119 | −def my_usage(range: Literal["24h", "7d", "30d"] = Query("24h"), u: User = Depends(current_user), | |
| 120 | − s: Session = Depends(get_session)): | |
| 121 | − principals = [k.principal for k in service.list_keys(s, u)] | |
| 260 | + "points": [POINT_EXAMPLE], "totals": {"requests": 1_240, "rows": 3_100_000, "status_429": 2, "bytes": 90_000_000, "rows_parquet": 0}, | |
| 261 | + "principals": ["key:12"], "key_id": None}, "meta": {"count": 1}}}}}}, | |
| 262 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "INVALID_PARAMETER", "KEY_NOT_FOUND"]}) | |
| 263 | +def my_usage(range: Literal["24h", "7d", "30d"] = Query("24h"), key_id: int | None = Query(None, description="Restrict to one key"), | |
| 264 | + u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 265 | + principals = _principals(s, u, key_id) | |
| 122 | 266 | series = usage.usage_series(principals, range) |
| 123 | 267 | series["principals"] = principals |
| 268 | + series["key_id"] = key_id | |
| 124 | 269 | return json_response(series) |
| 125 | 270 | |
| 126 | 271 | |
| 272 | +@router.get( | |
| 273 | + "/usage.csv", summary="Your usage series as CSV", | |
| 274 | + description="Same data as `GET /v1/me/usage`, as `text/csv` (`t,requests,rows,status_429,bytes,rows_parquet`, one line per " | |
| 275 | + "point, UTC). `X-Row-Count` carries the number of points. Works with an API key.", | |
| 276 | + responses={200: {"content": {"text/csv": {"example": "t,requests,rows,status_429,bytes,rows_parquet\n2026-09-04T00:00:00Z,120,55000,0,1600000,0\n"}}}}, | |
| 277 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "INVALID_PARAMETER", "KEY_NOT_FOUND"]}) | |
| 278 | +def my_usage_csv(range: Literal["24h", "7d", "30d"] = Query("30d"), key_id: int | None = Query(None), | |
| 279 | + u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 280 | + series = usage.usage_series(_principals(s, u, key_id), range) | |
| 281 | + body = usage.usage_csv(series) | |
| 282 | + return PlainTextResponse(body, media_type="text/csv", headers={ | |
| 283 | + "X-Row-Count": str(len(series["points"])), | |
| 284 | + "Content-Disposition": f'attachment; filename="hfmd-usage-{range}{f"-key{key_id}" if key_id else ""}.csv"'}) | |
| 285 | + | |
| 286 | + | |
| 287 | +@router.get( | |
| 288 | + "/limits", summary="Live quota of your keys (remaining requests / rows in the current window)", | |
| 289 | + description=("For each active key (or the one given by `key_id`): the tier that applies (`tier_override` or the " | |
| 290 | + "account tier), the window and the **remaining** requests and rows right now with the reset instant " | |
| 291 | + "(unix seconds). `redis: false` means the limiter is unavailable and counters are unknown. Not charged; " | |
| 292 | + "works with an API key."), | |
| 293 | + responses={200: {"content": {"application/json": {"example": {"data": { | |
| 294 | + "tier": LIMITS_EXAMPLE, "keys": [{"key_id": 12, "name": "default", "prefix": "hfmd_live_ab12cd34", "principal": "key:12", | |
| 295 | + "kind": "key", "tier": "free", "window_seconds": 60, "max_rows_per_request": 50_000, | |
| 296 | + "requests": {"limit": 120, "remaining": 118, "reset": 1788012345}, | |
| 297 | + "rows": {"limit": 1_000_000, "remaining": 940_000, "reset": 1788012345}, "redis": True}]}, | |
| 298 | + "meta": {"count": 1}}}}}}, | |
| 299 | + openapi_extra={"x-errors": ["AUTH_REQUIRED", "KEY_NOT_FOUND"]}) | |
| 300 | +def my_limits(key_id: int | None = Query(None), u: User = Depends(current_user), s: Session = Depends(get_session)): | |
| 301 | + keys = [service.get_key(s, u, key_id)] if key_id is not None else service.active_keys(s, u) | |
| 302 | + out = [] | |
| 303 | + for k in keys: | |
| 304 | + tier = tier_for(k.tier_override or u.tier) | |
| 305 | + snap = rl_snapshot(rl.peek_tier(k.principal, tier), tier, Principal(k.principal, "key", tier, u.id, k.id)) | |
| 306 | + out.append({"key_id": k.id, "name": k.name, "prefix": k.prefix, "status": k.status, "expires_at": service.iso(k.expires_at), **snap}) | |
| 307 | + return json_response({"tier": _limits_for(u), "keys": out}) | |
| 308 | + | |
| 309 | + | |
| 310 | +# ------------------------------------------------------------------------------------------- public limits | |
| 311 | + | |
| 127 | 312 | @limits_router.get( |
| 128 | 313 | "/limits", summary="Tier table + your current counters (works without a key)", |
| 129 | 314 | description=("Public. Returns the three tiers (keyless · free · high_usage) and, for the principal making the " |
@@ -150,7 +335,6 @@ def limits(request: Request): | ||
| 150 | 335 | principal = getattr(request.state, "principal", None) |
| 151 | 336 | tier = TIERS.get(getattr(request.state, "tier", "keyless"), TIERS["keyless"]) |
| 152 | 337 | d = rl.peek_tier(principal, tier) if principal else None |
| 153 | − from ratelimit.middleware import Principal | |
| 154 | 338 | snap = rl_snapshot(d, tier, Principal(principal or "ip:unknown", getattr(request.state, "principal_kind", "keyless"), tier)) |
| 155 | 339 | tier = TIERS.get(snap["tier"], TIERS["keyless"]) |
| 156 | 340 | return json_response({"tiers": tiers_public(), "principal": snap, |
| 157 | 341 | |