easyapi-django 1.3.2__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. {easyapi_django-1.3.2/easyapi_django.egg-info → easyapi_django-1.5.0}/PKG-INFO +19 -5
  2. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/README.md +18 -4
  3. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/base.py +268 -11
  4. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/blocking.py +25 -0
  5. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/settings/settings.py.j2 +14 -9
  6. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/rate_limit.py +28 -9
  7. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/security.py +106 -24
  8. {easyapi_django-1.3.2 → easyapi_django-1.5.0/easyapi_django.egg-info}/PKG-INFO +19 -5
  9. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/pyproject.toml +1 -1
  10. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/LICENSE +0 -0
  11. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/MANIFEST.in +0 -0
  12. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/__init__.py +0 -0
  13. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/auth.py +0 -0
  14. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/bearer.py +0 -0
  15. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/calc.py +0 -0
  16. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/calc_resource.py +0 -0
  17. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/client_ip.py +0 -0
  18. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/constants.py +0 -0
  19. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/core/__init__.py +0 -0
  20. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/core/endpoint.py +0 -0
  21. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/core/resource.py +0 -0
  22. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/dates.py +0 -0
  23. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/exception.py +0 -0
  24. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/filters.py +0 -0
  25. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/__init__.py +0 -0
  26. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/cli.py +0 -0
  27. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/config.py +0 -0
  28. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/generator.py +0 -0
  29. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/__init__.py +0 -0
  30. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/base.py +0 -0
  31. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/mysql.py +0 -0
  32. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/postgres.py +0 -0
  33. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/README.md.j2 +0 -0
  34. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/asgi.py.j2 +0 -0
  35. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/env.example.j2 +0 -0
  36. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/gitignore.j2 +0 -0
  37. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/manage.py.j2 +0 -0
  38. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/module/models.py.j2 +0 -0
  39. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/module/resources.py.j2 +0 -0
  40. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/requirements.txt.j2 +0 -0
  41. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/router/endpoints.py.j2 +0 -0
  42. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/router/urls.py.j2 +0 -0
  43. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/run.sh.j2 +0 -0
  44. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/settings/env.py.j2 +0 -0
  45. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/helpers.py +0 -0
  46. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/management/__init__.py +0 -0
  47. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/management/commands/__init__.py +0 -0
  48. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/management/commands/mcp_serve.py +0 -0
  49. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/__init__.py +0 -0
  50. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/protocol.py +0 -0
  51. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/schema.py +0 -0
  52. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/server.py +0 -0
  53. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/testing.py +0 -0
  54. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/tool.py +0 -0
  55. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/tools.py +0 -0
  56. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/middleware.py +0 -0
  57. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/__init__.py +0 -0
  58. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/_common.py +0 -0
  59. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/apps.py +0 -0
  60. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/clients.py +0 -0
  61. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/codes.py +0 -0
  62. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/constants.py +0 -0
  63. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/devices.py +0 -0
  64. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/discovery.py +0 -0
  65. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/migrations/0001_initial.py +0 -0
  66. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/migrations/__init__.py +0 -0
  67. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/models.py +0 -0
  68. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resolver.py +0 -0
  69. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/__init__.py +0 -0
  70. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/authorize.py +0 -0
  71. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/device.py +0 -0
  72. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/device_authorization.py +0 -0
  73. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/register.py +0 -0
  74. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/revoke.py +0 -0
  75. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/token.py +0 -0
  76. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/routes.py +0 -0
  77. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/session.py +0 -0
  78. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/settings_helper.py +0 -0
  79. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/templates/zeromcp_oauth/consent.html +0 -0
  80. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/templates/zeromcp_oauth/device.html +0 -0
  81. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/tokens.py +0 -0
  82. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/openapi.py +0 -0
  83. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/orm/__init__.py +0 -0
  84. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/redis_config.py +0 -0
  85. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/routes.py +0 -0
  86. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/schemas.py +0 -0
  87. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/sensitive.py +0 -0
  88. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/serializer.py +0 -0
  89. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/settings_helper.py +0 -0
  90. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/__init__.py +0 -0
  91. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/db_router.py +0 -0
  92. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/registry.py +0 -0
  93. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/tenant.py +0 -0
  94. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/util.py +0 -0
  95. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/SOURCES.txt +0 -0
  96. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/dependency_links.txt +0 -0
  97. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/entry_points.txt +0 -0
  98. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/requires.txt +0 -0
  99. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/top_level.txt +0 -0
  100. {easyapi_django-1.3.2 → easyapi_django-1.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi-django
3
- Version: 1.3.2
3
+ Version: 1.5.0
4
4
  Summary: Zero-effort MCP server (and REST API) generator from Django models.
5
5
  Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
6
6
  License-Expression: MIT
@@ -170,7 +170,9 @@ REST is mostly a solved problem now. The new pain is MCP — most teams are rebu
170
170
 
171
171
  **Shared (`CoreResource`)**
172
172
  - Bearer + `X-Api-Key` + session-cookie auth.
173
- - Per-IP rate limit, scanner blocking, 4xx flood detection.
173
+ - Pre-auth per-IP throttling; authenticated abuse blocking by tenant/user or
174
+ API-key digest (anonymous traffic falls back to IP).
175
+ - Scanner blocking and semantic invalid-request detection.
174
176
  - Multi-tenant DB routing. One call switches the connection for the request.
175
177
  - Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
176
178
  - Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
@@ -233,11 +235,24 @@ Defaults are demo-friendly. Production deployments should:
233
235
  - **Per-resource ownership.** Set `owner_field = 'owner_id'` on resources where rows belong to a single user — restricts every CRUD operation to the row's owner. `POST` always forces `owner_id` to the caller (override with `allow_owner_override = True` for admin paths).
234
236
  - **Authenticated cache.** If you turn on `cache = True` for an authenticated resource, set `session_cache = True` or `cache_scope_fields = (...)` — otherwise responses can leak across users. The framework warns at runtime when it detects this combination.
235
237
  - **Tune session TTLs.** `SESSION_TTL` (cookies, default 1800s) and `API_SESSION_TTL` (api-key cache, default 300s) both slide on use. Pick numbers that match your security/UX trade-off.
236
- - **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking, 4xx flood detection), rate limiting, credential-path defense and abuse blocking are all bypassed for dev ergonomics. The framework logs a loud startup warning when the bypass is active. To decouple from `DEBUG` — e.g. a staging box mirroring production — set `EASYAPI = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
237
- - **Trust your proxies explicitly.** IP-based rate limits and abuse blocking key on the client IP. `X-Forwarded-For` / `X-Real-IP` are only honored when `REMOTE_ADDR` is in `TRUSTED_PROXIES = ['10.0.0.0/8', ...]`; otherwise `REMOTE_ADDR` wins. Leave it unset behind a proxy and every request looks like it comes from the proxy.
238
+ - **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking, semantic invalid-request detection), rate limiting, credential-path defense and abuse blocking are all bypassed for dev ergonomics. The framework logs a loud startup warning when the bypass is active. To decouple from `DEBUG` — e.g. a staging box mirroring production — set `EASYAPI = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
239
+ - **Trust your proxies explicitly.** Pre-auth throttles, scanner blocking and anonymous abuse detection key on the client IP. `X-Forwarded-For` / `X-Real-IP` are only honored when `REMOTE_ADDR` is in `TRUSTED_PROXIES = ['10.0.0.0/8', ...]`; otherwise `REMOTE_ADDR` wins. Leave it unset behind a proxy and every request looks like it comes from the proxy.
240
+ - **Authenticated blocks follow the principal.** The fast pre-auth throttle,
241
+ login defense and path/User-Agent scanner signatures remain IP-based.
242
+ After successful authentication, semantic events and sustained abuse use a
243
+ tenant-scoped user digest or API-key digest; anonymous requests keep using
244
+ IP. This prevents one authenticated user behind a shared NAT from blocking
245
+ everyone else while still stopping the same principal after an IP change.
246
+ - **Bound external Bearer resolvers.** Query-contract failures are attributed
247
+ after authentication, so an unknown/protected parameter carrying a Bearer
248
+ token can invoke `BEARER_RESOLVER` before the request is rejected. The
249
+ default `NO_RESOLVER` path performs no external call. Custom network-backed
250
+ resolvers should enforce a short timeout, cache safely where appropriate,
251
+ and be latency/load-tested behind the pre-auth IP throttle.
238
252
  - **`/metrics` is opt-in.** `get_routes` doesn't mount it unless you pass `metrics_view=Metrics` (the generic, unscoped resource — `from easyapi import Metrics`) or a custom row-scoped subclass. Once mounted, bound which models it can query: set `METRICS_MODELS = ['app_Model', ...]` to allowlist them. References to credential-named columns (in filters, groups, order or calc) are always rejected regardless — the per-resource escape hatch does not reach `/metrics`.
239
253
  - **Bound the metrics fields, per model.** `METRICS_MODELS` only gates *which models* — every non-credential field on an allowed model is still fair game for `/metrics` (filter, group, order, calc), including arbitrary FK traversal. Set `METRICS_FIELDS = {'app_Model': ['status', 'owner__name', ...]}` to also allowlist *which field paths*, per model. Unlike REST's `filter_fields` (root-segment match), this checks the **full** `__`-joined path — `'owner'` in the list does not imply `'owner__anything'` is allowed. `filter_by` references tolerate one trailing Django lookup (`owner__name__icontains` matches an allowed `owner__name`) since those reach `Q(**kwargs)` directly; `calc`/`group_by`/`order`/`additional_fields`/`conditions` don't take lookups and are matched literally. A model absent from the dict, or the setting left unset, stays unrestricted — opt-in, same as `METRICS_MODELS`. If you already maintain a `bi_fields`/`related_fields` map for a frontend field picker, `easyapi.util.flatten_include_fields(model, bag)` turns that same data into the flat path list this setting expects, so there's one source of truth instead of two.
240
254
  - **Keep the escape hatch narrow.** Columns whose names look like credentials (`password`, `api_key`, `token`, `otp`, `secret`, …) are masked as `'*********'` on every response, on top of whatever `sensitive_fields` lists. When the heuristic catches a false positive, opt it out per surface: `edit_allow_sensitive_fields = ['token_count']` returns it verbatim on `GET /resource/<id>/` while LIST still masks it. `list_allow_sensitive_fields` does the same for LIST, where every row carries the value — reach for the detail-only form first.
255
+ - **Keep query capabilities separate.** Query-string names outside the framework contract are rejected. Declare custom names in `extra_query_params`. A protected name allowed in a LIST response through `list_allow_sensitive_fields` is still forbidden as a direct/JSON filter; add it separately to `allow_protected_query_fields` only when filtering by that value is intentional. Existing resources using the response escape hatch may need this explicit filter capability after upgrading.
241
256
  - **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `EASYAPI['OAUTH_CLIENTS']` with no `client_secret`. A client becomes confidential only with an explicit secret; `/register` never hands out a confidential secret (provision those out of band).
242
257
 
243
258
  ---
@@ -262,4 +277,3 @@ There's no CLA, no contributor matrix, no roadmap voting. Just open an issue and
262
277
  - **Python** — 3.10+
263
278
  - **Django** — 5.0+
264
279
  - **Repo** — github.com/ssjunior/easyapi-django
265
-
@@ -126,7 +126,9 @@ REST is mostly a solved problem now. The new pain is MCP — most teams are rebu
126
126
 
127
127
  **Shared (`CoreResource`)**
128
128
  - Bearer + `X-Api-Key` + session-cookie auth.
129
- - Per-IP rate limit, scanner blocking, 4xx flood detection.
129
+ - Pre-auth per-IP throttling; authenticated abuse blocking by tenant/user or
130
+ API-key digest (anonymous traffic falls back to IP).
131
+ - Scanner blocking and semantic invalid-request detection.
130
132
  - Multi-tenant DB routing. One call switches the connection for the request.
131
133
  - Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
132
134
  - Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
@@ -189,11 +191,24 @@ Defaults are demo-friendly. Production deployments should:
189
191
  - **Per-resource ownership.** Set `owner_field = 'owner_id'` on resources where rows belong to a single user — restricts every CRUD operation to the row's owner. `POST` always forces `owner_id` to the caller (override with `allow_owner_override = True` for admin paths).
190
192
  - **Authenticated cache.** If you turn on `cache = True` for an authenticated resource, set `session_cache = True` or `cache_scope_fields = (...)` — otherwise responses can leak across users. The framework warns at runtime when it detects this combination.
191
193
  - **Tune session TTLs.** `SESSION_TTL` (cookies, default 1800s) and `API_SESSION_TTL` (api-key cache, default 300s) both slide on use. Pick numbers that match your security/UX trade-off.
192
- - **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking, 4xx flood detection), rate limiting, credential-path defense and abuse blocking are all bypassed for dev ergonomics. The framework logs a loud startup warning when the bypass is active. To decouple from `DEBUG` — e.g. a staging box mirroring production — set `EASYAPI = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
193
- - **Trust your proxies explicitly.** IP-based rate limits and abuse blocking key on the client IP. `X-Forwarded-For` / `X-Real-IP` are only honored when `REMOTE_ADDR` is in `TRUSTED_PROXIES = ['10.0.0.0/8', ...]`; otherwise `REMOTE_ADDR` wins. Leave it unset behind a proxy and every request looks like it comes from the proxy.
194
+ - **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking, semantic invalid-request detection), rate limiting, credential-path defense and abuse blocking are all bypassed for dev ergonomics. The framework logs a loud startup warning when the bypass is active. To decouple from `DEBUG` — e.g. a staging box mirroring production — set `EASYAPI = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
195
+ - **Trust your proxies explicitly.** Pre-auth throttles, scanner blocking and anonymous abuse detection key on the client IP. `X-Forwarded-For` / `X-Real-IP` are only honored when `REMOTE_ADDR` is in `TRUSTED_PROXIES = ['10.0.0.0/8', ...]`; otherwise `REMOTE_ADDR` wins. Leave it unset behind a proxy and every request looks like it comes from the proxy.
196
+ - **Authenticated blocks follow the principal.** The fast pre-auth throttle,
197
+ login defense and path/User-Agent scanner signatures remain IP-based.
198
+ After successful authentication, semantic events and sustained abuse use a
199
+ tenant-scoped user digest or API-key digest; anonymous requests keep using
200
+ IP. This prevents one authenticated user behind a shared NAT from blocking
201
+ everyone else while still stopping the same principal after an IP change.
202
+ - **Bound external Bearer resolvers.** Query-contract failures are attributed
203
+ after authentication, so an unknown/protected parameter carrying a Bearer
204
+ token can invoke `BEARER_RESOLVER` before the request is rejected. The
205
+ default `NO_RESOLVER` path performs no external call. Custom network-backed
206
+ resolvers should enforce a short timeout, cache safely where appropriate,
207
+ and be latency/load-tested behind the pre-auth IP throttle.
194
208
  - **`/metrics` is opt-in.** `get_routes` doesn't mount it unless you pass `metrics_view=Metrics` (the generic, unscoped resource — `from easyapi import Metrics`) or a custom row-scoped subclass. Once mounted, bound which models it can query: set `METRICS_MODELS = ['app_Model', ...]` to allowlist them. References to credential-named columns (in filters, groups, order or calc) are always rejected regardless — the per-resource escape hatch does not reach `/metrics`.
195
209
  - **Bound the metrics fields, per model.** `METRICS_MODELS` only gates *which models* — every non-credential field on an allowed model is still fair game for `/metrics` (filter, group, order, calc), including arbitrary FK traversal. Set `METRICS_FIELDS = {'app_Model': ['status', 'owner__name', ...]}` to also allowlist *which field paths*, per model. Unlike REST's `filter_fields` (root-segment match), this checks the **full** `__`-joined path — `'owner'` in the list does not imply `'owner__anything'` is allowed. `filter_by` references tolerate one trailing Django lookup (`owner__name__icontains` matches an allowed `owner__name`) since those reach `Q(**kwargs)` directly; `calc`/`group_by`/`order`/`additional_fields`/`conditions` don't take lookups and are matched literally. A model absent from the dict, or the setting left unset, stays unrestricted — opt-in, same as `METRICS_MODELS`. If you already maintain a `bi_fields`/`related_fields` map for a frontend field picker, `easyapi.util.flatten_include_fields(model, bag)` turns that same data into the flat path list this setting expects, so there's one source of truth instead of two.
196
210
  - **Keep the escape hatch narrow.** Columns whose names look like credentials (`password`, `api_key`, `token`, `otp`, `secret`, …) are masked as `'*********'` on every response, on top of whatever `sensitive_fields` lists. When the heuristic catches a false positive, opt it out per surface: `edit_allow_sensitive_fields = ['token_count']` returns it verbatim on `GET /resource/<id>/` while LIST still masks it. `list_allow_sensitive_fields` does the same for LIST, where every row carries the value — reach for the detail-only form first.
211
+ - **Keep query capabilities separate.** Query-string names outside the framework contract are rejected. Declare custom names in `extra_query_params`. A protected name allowed in a LIST response through `list_allow_sensitive_fields` is still forbidden as a direct/JSON filter; add it separately to `allow_protected_query_fields` only when filtering by that value is intentional. Existing resources using the response escape hatch may need this explicit filter capability after upgrading.
197
212
  - **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `EASYAPI['OAUTH_CLIENTS']` with no `client_secret`. A client becomes confidential only with an explicit secret; `/register` never hands out a confidential secret (provision those out of band).
198
213
 
199
214
  ---
@@ -218,4 +233,3 @@ There's no CLA, no contributor matrix, no roadmap voting. Just open an issue and
218
233
  - **Python** — 3.10+
219
234
  - **Django** — 5.0+
220
235
  - **Repo** — github.com/ssjunior/easyapi-django
221
-
@@ -98,6 +98,14 @@ RATE_LIMITS = get_setting('RATE_LIMITS', default={
98
98
  ],
99
99
  })
100
100
 
101
+ _STANDARD_QUERY_PARAMS = frozenset({
102
+ 'search', 'fields', 'page', 'limit', 'order_by', 'filter', 'count',
103
+ 'normalize', 'normalize_list', 'context', 'tags', 'tags_operator',
104
+ })
105
+ _PRIVILEGED_QUERY_FIELDS = frozenset({
106
+ 'is_staff', 'is_superuser', 'groups', 'user_permissions', 'permissions',
107
+ })
108
+
101
109
 
102
110
  logger = logging.getLogger(__name__)
103
111
  _WARNED_UNSCOPED_AUTH_CACHE = set()
@@ -239,6 +247,15 @@ class BaseResource(CoreResource):
239
247
  filter_fields = None
240
248
  queryset_filter = None
241
249
 
250
+ # Query-string contract. Framework-owned parameters and ``filter_fields``
251
+ # are accepted automatically; custom routes/resources must declare any
252
+ # additional names here. Protected credential/privilege fields remain
253
+ # denied as direct filters unless explicitly listed in
254
+ # ``allow_protected_query_fields``. Response projection via ``fields=``
255
+ # has a separate capability: ``list_allow_sensitive_fields``.
256
+ extra_query_params = None
257
+ allow_protected_query_fields = None
258
+
242
259
  # Querystring param that resolves to a server-side stored filter
243
260
  # expression (e.g. ``'segment_id'``, ``'view_id'``). When set, the
244
261
  # framework reads ``request.GET[stored_filter_param]`` and calls
@@ -293,6 +310,8 @@ class BaseResource(CoreResource):
293
310
  user = None
294
311
  account = None
295
312
  body = None
313
+ auth_method = None
314
+ security_identifier = None
296
315
 
297
316
  def __init__(self):
298
317
 
@@ -304,6 +323,13 @@ class BaseResource(CoreResource):
304
323
  self.list_related_fields = dict(self.list_related_fields) if self.list_related_fields else {}
305
324
  self.many_to_many_models = dict(self.many_to_many_models) if self.many_to_many_models else {}
306
325
  self.filter_fields = list(self.filter_fields) if self.filter_fields else []
326
+ self.extra_query_params = (
327
+ set(self.extra_query_params) if self.extra_query_params else set()
328
+ )
329
+ self.allow_protected_query_fields = (
330
+ set(self.allow_protected_query_fields)
331
+ if self.allow_protected_query_fields else set()
332
+ )
307
333
  self.queryset_filter = dict(self.queryset_filter) if self.queryset_filter else {}
308
334
  self.search_fields = list(self.search_fields) if self.search_fields else []
309
335
  self.order_fields = list(self.order_fields) if self.order_fields else []
@@ -329,6 +355,8 @@ class BaseResource(CoreResource):
329
355
  self.cache_scope_fields
330
356
  )
331
357
  self.diff = {}
358
+ self.auth_method = None
359
+ self.security_identifier = None
332
360
 
333
361
  if self.model:
334
362
  fields = []
@@ -400,10 +428,10 @@ class BaseResource(CoreResource):
400
428
 
401
429
  raise HTTPException(403, 'Not allowed')
402
430
 
403
- async def block(self, identifier):
431
+ async def block(self, identifier, reason='api_rate_limit'):
404
432
  logger.warning('Blocking %s', identifier)
405
433
  redis = get_redis()
406
- await block_identifier(redis, identifier, 'api_rate_limit', ttl=86400)
434
+ await block_identifier(redis, identifier, reason, ttl=86400)
407
435
  raise HTTPException(403, 'Blocked due to misbehavior')
408
436
 
409
437
  async def check_is_blocked(self, identifier):
@@ -411,6 +439,92 @@ class BaseResource(CoreResource):
411
439
  if await is_blocked(redis, identifier):
412
440
  raise HTTPException(403, 'Blocked due to misbehavior')
413
441
 
442
+ @staticmethod
443
+ def _safe_log_values(values):
444
+ """Bound and strip control characters from attacker-owned log data."""
445
+ safe = []
446
+ for value in values:
447
+ value = re.sub(r'[\x00-\x1f\x7f]', '?', str(value))
448
+ safe.append(value[:128])
449
+ return tuple(safe)
450
+
451
+ def _log_security_event(self, event, request, values):
452
+ # SecurityMiddleware consumes one principal event after the response
453
+ # is rendered. Store only classification/count on the request; raw
454
+ # attacker-controlled values remain in the sanitized application log.
455
+ request._easyapi_security_event = {
456
+ 'event': event,
457
+ 'count': max(len(values), 1),
458
+ }
459
+ logger.warning(
460
+ 'security_event=%s ip=%s identity=%s method=%s path=%r values=%r',
461
+ event,
462
+ getattr(self, 'identifier', ''),
463
+ getattr(self, 'security_identifier', ''),
464
+ getattr(request, 'method', ''),
465
+ self._safe_log_values([getattr(request, 'path', '')])[0],
466
+ self._safe_log_values(values),
467
+ )
468
+
469
+ def _query_filter_field(self, key):
470
+ """Mirror the legacy query-filter lookup normalization."""
471
+ if search_regex.search(key):
472
+ keys = key.split('__')
473
+ if len(keys) == 3:
474
+ return f'{keys[0]}__{keys[1]}'
475
+ return keys[0]
476
+ return key
477
+
478
+ def _is_protected_field(self, field, allowed_fields):
479
+ root = field.split('__', 1)[0]
480
+ if field in allowed_fields or root in allowed_fields:
481
+ return False
482
+ return (
483
+ is_sensitive(field, self.sensitive_fields)
484
+ or is_sensitive(root, self.sensitive_fields)
485
+ or root.lower() in _PRIVILEGED_QUERY_FIELDS
486
+ )
487
+
488
+ def _is_protected_query_field(self, field):
489
+ """Whether a field is forbidden as a direct query-string filter."""
490
+ return self._is_protected_field(
491
+ field, self.allow_protected_query_fields,
492
+ )
493
+
494
+ def _is_protected_response_field(self, field):
495
+ """Whether ``fields=`` may project a field into a list response."""
496
+ return self._is_protected_field(
497
+ field, self.list_allow_sensitive_fields,
498
+ )
499
+
500
+ def _validate_query_parameters(self, request):
501
+ """Reject query-string names outside the resource's public contract."""
502
+ invalid = []
503
+ protected = []
504
+ allowed = _STANDARD_QUERY_PARAMS | self.extra_query_params
505
+ if self.stored_filter_param:
506
+ allowed = allowed | {self.stored_filter_param}
507
+
508
+ for key in request.GET:
509
+ field = self._query_filter_field(key)
510
+ if self._is_protected_query_field(field):
511
+ protected.append(key)
512
+ continue
513
+ if key in allowed or field in self.filter_fields:
514
+ continue
515
+ invalid.append(key)
516
+
517
+ if protected:
518
+ self._log_security_event(
519
+ 'protected_query_field', request, protected,
520
+ )
521
+ elif invalid:
522
+ self._log_security_event(
523
+ 'unknown_query_parameter', request, invalid,
524
+ )
525
+ if protected or invalid:
526
+ raise HTTPException(400, 'Invalid query parameters')
527
+
414
528
  async def _enforce_rate_limit(self, request):
415
529
  # Local development bypasses rate limiting and abuse blocking.
416
530
  # Hammering ``/docs`` while wiring up auth would otherwise trigger
@@ -441,12 +555,30 @@ class BaseResource(CoreResource):
441
555
  await self.block(self.identifier)
442
556
  return
443
557
 
444
- result = await RateLimiter.api_limited(self.identifier, RATE_LIMITS)
445
- if result['abuse']:
446
- await self.block(self.identifier)
558
+ # Cheap pre-auth pass: only the short API windows use IP. The
559
+ # sustained abuse windows run after authentication with the real
560
+ # principal, preventing one user behind a shared NAT from blocking
561
+ # everyone on that IP.
562
+ result = await RateLimiter.api_limited(
563
+ self.identifier, RATE_LIMITS, include_abuse=False,
564
+ )
447
565
  if result['rate_limited']:
448
566
  raise HTTPException(429, 'Slow down, too many requests. You will be blocked.')
449
567
 
568
+ async def _enforce_identity_abuse_limit(self, request):
569
+ """Apply abuse windows to an authenticated principal or anonymous IP."""
570
+ if security_bypassed() or request.path.startswith('/login'):
571
+ return
572
+ result = await RateLimiter.abuse_limited(
573
+ self.security_identifier, RATE_LIMITS,
574
+ )
575
+ if result.get('blocked'):
576
+ raise HTTPException(403, 'Blocked due to misbehavior')
577
+ if result['abuse']:
578
+ await self.block(
579
+ self.security_identifier, reason='identity_abuse_rate_limit',
580
+ )
581
+
450
582
  async def _enforce_resource_rate_limits(self):
451
583
  """Declarative ``self.rate_limits`` buckets — runs after auth so
452
584
  keys derived from ``self.user`` resolve. Keys returning ``None``
@@ -496,6 +628,7 @@ class BaseResource(CoreResource):
496
628
  request.headers.get('Authorization'),
497
629
  )
498
630
  if code == _bearer.OK:
631
+ self.auth_method = 'bearer'
499
632
  self._activate_session(session)
500
633
  await apply_session_to_request(request, session)
501
634
  return session, None
@@ -517,6 +650,7 @@ class BaseResource(CoreResource):
517
650
  if request.headers.get('X-Api-Key'):
518
651
  session = await get_api_session(request.headers.get('X-Api-Key'))
519
652
  if session:
653
+ self.auth_method = 'api_key'
520
654
  self._activate_session(session)
521
655
  await apply_session_to_request(request, session)
522
656
  return session, None
@@ -527,6 +661,7 @@ class BaseResource(CoreResource):
527
661
 
528
662
  existing = getattr(request, 'session', None)
529
663
  if existing:
664
+ self.auth_method = 'session'
530
665
  self._activate_session(existing)
531
666
  return existing, session_key
532
667
 
@@ -538,10 +673,55 @@ class BaseResource(CoreResource):
538
673
  return None, None
539
674
 
540
675
  session = json.loads(raw)
676
+ self.auth_method = 'session'
541
677
  self._activate_session(session)
542
678
  await apply_session_to_request(request, session)
543
679
  return session, session_key
544
680
 
681
+ @staticmethod
682
+ def _identity_digest(value):
683
+ return hashlib.sha256(str(value).encode('utf-8')).hexdigest()[:32]
684
+
685
+ def _set_security_identifier(self, request, session, session_key):
686
+ """Derive the stable Redis identity after authentication.
687
+
688
+ API keys stay independent; Bearer/cookie sessions converge on the
689
+ tenant-scoped user so token/session rotation cannot bypass a block.
690
+ Raw credentials and session ids never enter Redis keys.
691
+ """
692
+ kind = 'ip'
693
+ identifier = self.identifier
694
+
695
+ if session and self.auth_method == 'api_key':
696
+ api_key = request.headers.get('X-Api-Key', '')
697
+ kind = 'api_key'
698
+ identifier = f'apikey:{self._identity_digest(api_key)}'
699
+ elif session:
700
+ user = session.get('user') if isinstance(session, dict) else None
701
+ account = session.get('account') if isinstance(session, dict) else None
702
+ user_id = user.get('id') if isinstance(user, dict) else None
703
+ account_id = account.get('id') if isinstance(account, dict) else 'single'
704
+ if user_id is not None:
705
+ kind = 'user'
706
+ principal = json.dumps(
707
+ [account_id, user_id],
708
+ separators=(',', ':'),
709
+ default=str,
710
+ )
711
+ identifier = f'user:{self._identity_digest(principal)}'
712
+ elif session_key:
713
+ kind = 'session'
714
+ identifier = f'session:{self._identity_digest(session_key)}'
715
+ elif self.auth_method == 'bearer':
716
+ kind = 'bearer'
717
+ auth_header = request.headers.get('Authorization', '')
718
+ identifier = f'bearer:{self._identity_digest(auth_header)}'
719
+
720
+ self.security_identifier = identifier
721
+ request._easyapi_security_identifier = identifier
722
+ request._easyapi_security_identity_kind = kind
723
+ return identifier
724
+
545
725
  def _activate_session(self, session):
546
726
  self.user = session['user']
547
727
  self.tz = get_tz(self.user.get('timezone', 'UTC'))
@@ -834,6 +1014,15 @@ class BaseResource(CoreResource):
834
1014
  await self._enforce_rate_limit(request)
835
1015
 
836
1016
  session, session_key = await self._authenticate(request)
1017
+ self._set_security_identifier(request, session, session_key)
1018
+
1019
+ # IP blocks are checked before authentication; principal blocks can
1020
+ # only be checked after the real identity is known. This second gate
1021
+ # prevents a blocked user/API key from bypassing via a new IP.
1022
+ if self.security_identifier != self.identifier:
1023
+ await self.check_is_blocked(self.security_identifier)
1024
+
1025
+ self._validate_query_parameters(request)
837
1026
 
838
1027
  if self.authenticated and not session:
839
1028
  raise HTTPException(401, 'Not authorized')
@@ -843,6 +1032,7 @@ class BaseResource(CoreResource):
843
1032
  if ALLOWED_ORIGINS:
844
1033
  self.get_allowed_domain(request)
845
1034
 
1035
+ await self._enforce_identity_abuse_limit(request)
846
1036
  await self._enforce_resource_rate_limits()
847
1037
 
848
1038
  if session and session.get('account'):
@@ -1144,8 +1334,43 @@ class BaseResource(CoreResource):
1144
1334
 
1145
1335
  self.count_results = {'count': count}
1146
1336
 
1147
- def _validate_filter_fields(self, conditions):
1148
- validate_conditions(conditions, self.filter_fields)
1337
+ @staticmethod
1338
+ def _condition_field_names(conditions):
1339
+ fields = []
1340
+
1341
+ def collect(node):
1342
+ if isinstance(node, dict):
1343
+ field = node.get('field')
1344
+ if field:
1345
+ fields.append(field)
1346
+ for rule in node.get('rules') or []:
1347
+ collect(rule)
1348
+ elif isinstance(node, list):
1349
+ for item in node:
1350
+ collect(item)
1351
+
1352
+ collect(conditions)
1353
+ return fields
1354
+
1355
+ def _validate_filter_fields(self, conditions, request):
1356
+ fields = self._condition_field_names(conditions)
1357
+ protected = [
1358
+ field for field in fields
1359
+ if self._is_protected_query_field(field)
1360
+ ]
1361
+ if protected:
1362
+ self._log_security_event(
1363
+ 'protected_filter_field', request, protected,
1364
+ )
1365
+ raise HTTPException(403, 'Filtering not allowed')
1366
+
1367
+ try:
1368
+ validate_conditions(conditions, self.filter_fields)
1369
+ except HTTPException as exc:
1370
+ self._log_security_event(
1371
+ 'forbidden_filter_field', request, fields or ['filter'],
1372
+ )
1373
+ raise HTTPException(403, 'Filtering not allowed') from exc
1149
1374
 
1150
1375
  async def resolve_stored_filter(self, value, **kwargs):
1151
1376
  """Hook: turn a stored-filter id into a Layer-2 conditions dict.
@@ -1186,12 +1411,18 @@ class BaseResource(CoreResource):
1186
1411
  groups.append(stored_conditions)
1187
1412
 
1188
1413
  if filter_:
1189
- url_conditions = json.loads(filter_)
1414
+ try:
1415
+ url_conditions = json.loads(filter_)
1416
+ except (json.JSONDecodeError, TypeError):
1417
+ self._log_security_event(
1418
+ 'invalid_filter_syntax', request, ['filter'],
1419
+ )
1420
+ raise HTTPException(400, 'Invalid filter') from None
1190
1421
  if url_conditions:
1191
1422
  # ``?filter=`` is caller-controlled — must pass the
1192
1423
  # whitelist. Stored filters are trusted because the hook
1193
1424
  # owns the lookup (and any validation, scoping, RBAC).
1194
- self._validate_filter_fields(url_conditions)
1425
+ self._validate_filter_fields(url_conditions, request)
1195
1426
  groups.append(url_conditions)
1196
1427
 
1197
1428
  if not groups:
@@ -1318,10 +1549,36 @@ class BaseResource(CoreResource):
1318
1549
  results = []
1319
1550
 
1320
1551
  fields = request.GET.get('fields')
1321
- if fields:
1552
+ if 'fields' in request.GET:
1322
1553
  requested = [f.strip() for f in fields.split(',') if f.strip()]
1323
1554
  allowed = set(self.list_fields or [])
1324
- list_fields = [f for f in requested if f in allowed]
1555
+ wildcard = [
1556
+ f for f in requested
1557
+ if f.lower() in {'*', 'all', '__all__'}
1558
+ ]
1559
+ protected = [
1560
+ f for f in requested
1561
+ if f not in wildcard and self._is_protected_response_field(f)
1562
+ ]
1563
+ unknown = [
1564
+ f for f in requested
1565
+ if f not in wildcard and f not in protected and f not in allowed
1566
+ ]
1567
+ if not requested:
1568
+ self._log_security_event(
1569
+ 'invalid_fields_syntax', request, ['fields'],
1570
+ )
1571
+ elif wildcard:
1572
+ self._log_security_event('wildcard_field_probe', request, wildcard)
1573
+ elif protected:
1574
+ self._log_security_event('protected_field_probe', request, protected)
1575
+ elif unknown:
1576
+ self._log_security_event('unknown_response_field', request, unknown)
1577
+ if not requested or wildcard or protected or unknown:
1578
+ raise HTTPException(400, 'Invalid fields')
1579
+ list_fields = requested
1580
+ if self.normalize_list and 'id' not in list_fields:
1581
+ list_fields = ['id'] + list_fields
1325
1582
  related = False
1326
1583
  else:
1327
1584
  list_fields = self.list_fields
@@ -5,6 +5,8 @@ key, so ``SecurityMiddleware`` and ``BaseResource`` agree on format and
5
5
  TTL semantics.
6
6
  """
7
7
 
8
+ from redis.exceptions import WatchError
9
+
8
10
  from .redis_config import KEY_PREFIX
9
11
 
10
12
 
@@ -27,6 +29,29 @@ async def block(redis, identifier, reason=None, ttl=86400):
27
29
  await redis.setex(block_key(identifier), ttl, reason or '1')
28
30
 
29
31
 
32
+ async def block_at_least(redis, identifier, reason=None, ttl=86400):
33
+ """Block without ever replacing a longer existing block with a shorter one.
34
+
35
+ ``WATCH`` keeps concurrent short/long escalation decisions from racing in
36
+ a way that accidentally reduces a 24-hour block to one hour.
37
+ """
38
+ key = block_key(identifier)
39
+ while True:
40
+ async with redis.pipeline(transaction=True) as pipeline:
41
+ try:
42
+ await pipeline.watch(key)
43
+ current_ttl = await pipeline.ttl(key)
44
+ if current_ttl >= ttl:
45
+ await pipeline.unwatch()
46
+ return
47
+ pipeline.multi()
48
+ pipeline.setex(key, ttl, reason or '1')
49
+ await pipeline.execute()
50
+ return
51
+ except WatchError:
52
+ continue
53
+
54
+
30
55
  async def get_block_reason(redis, identifier):
31
56
  """Return the stored block reason, or ``None`` when not blocked."""
32
57
  return await redis.get(block_key(identifier))
@@ -128,10 +128,10 @@ MCP = {
128
128
  # 'CREDENTIAL_RATE_LIMIT': {'limit': 5, 'window': 30}, # 5 hits / 30s per IP
129
129
 
130
130
  # ── Rate limit & abuse blocking ──────────────────────────────────
131
- # Per-IP windows. Each list is "if more than ``limit`` requests in
132
- # ``interval`` ms, slow down or block". ``api`` covers regular
133
- # endpoints, ``login`` is the auth endpoint (tighter), ``abuse``
134
- # triggers the IP block when exceeded.
131
+ # ``api`` is the cheap pre-auth IP throttle; ``login`` stays per-IP.
132
+ # ``abuse`` runs after authentication and keys on tenant+user, API-key
133
+ # digest, or IP only for anonymous requests. Each list is "if more than
134
+ # ``limit`` requests in ``interval`` ms, slow down or block".
135
135
  # 'RATE_LIMITS': {
136
136
  # 'api': [{'interval': 1000, 'limit': 4},
137
137
  # {'interval': 5000, 'limit': 20}],
@@ -140,11 +140,16 @@ MCP = {
140
140
  # 'abuse': [{'interval': 5000, 'limit': 20},
141
141
  # {'interval': 3600000, 'limit': 200}],
142
142
  # },
143
- # Anti-scanner: count 4xx responses per IP. Crossing either
144
- # threshold parks the IP in the block list for ``BLOCK_DURATION_SECONDS``.
145
- # 'MAX_4XX_PER_MINUTE': 10,
146
- # 'MAX_4XX_PER_HOUR': 30,
147
- # 'BLOCK_DURATION_SECONDS': 86400, # 24h
143
+ # Semantic abuse detection. Protected-field/wildcard probes block
144
+ # immediately for BLOCK_DURATION_SECONDS. Generic invalid requests
145
+ # tolerate one occurrence; the second inside the low-event window
146
+ # blocks briefly, and a recurrence during the escalation window
147
+ # receives the full block.
148
+ # 'SECURITY_LOW_EVENT_LIMIT': 2,
149
+ # 'SECURITY_LOW_EVENT_WINDOW_SECONDS': 900, # 15 min
150
+ # 'SECURITY_SHORT_BLOCK_SECONDS': 3600, # 1h
151
+ # 'SECURITY_ESCALATION_WINDOW_SECONDS': 86400,
152
+ # 'BLOCK_DURATION_SECONDS': 86400, # 24h
148
153
  # Pre-emptive bans by URL path or User-Agent regex (vulnerability
149
154
  # scanners, known bad bots). ``None`` keeps the framework's
150
155
  # baseline list; pass ``[...]`` to replace, ``[]`` to disable.