easyapi-django 1.3.0__tar.gz → 1.3.2__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 (101) hide show
  1. {easyapi_django-1.3.0/easyapi_django.egg-info → easyapi_django-1.3.2}/PKG-INFO +6 -5
  2. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/README.md +5 -4
  3. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/__init__.py +1 -0
  4. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/base.py +5 -3
  5. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/calc.py +130 -24
  6. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/cli.py +1 -1
  7. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/config.py +1 -1
  8. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/clients.py +3 -3
  9. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/constants.py +1 -1
  10. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/migrations/0001_initial.py +1 -1
  11. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resolver.py +1 -1
  12. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/routes.py +2 -2
  13. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/settings_helper.py +3 -2
  14. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/routes.py +7 -8
  15. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/security.py +11 -1
  16. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/settings_helper.py +41 -9
  17. easyapi_django-1.3.2/easyapi/util.py +105 -0
  18. {easyapi_django-1.3.0 → easyapi_django-1.3.2/easyapi_django.egg-info}/PKG-INFO +6 -5
  19. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/pyproject.toml +1 -1
  20. easyapi_django-1.3.0/easyapi/util.py +0 -54
  21. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/LICENSE +0 -0
  22. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/MANIFEST.in +0 -0
  23. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/auth.py +0 -0
  24. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/bearer.py +0 -0
  25. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/blocking.py +0 -0
  26. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/calc_resource.py +0 -0
  27. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/client_ip.py +0 -0
  28. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/constants.py +0 -0
  29. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/core/__init__.py +0 -0
  30. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/core/endpoint.py +0 -0
  31. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/core/resource.py +0 -0
  32. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/dates.py +0 -0
  33. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/exception.py +0 -0
  34. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/filters.py +0 -0
  35. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/__init__.py +0 -0
  36. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/generator.py +0 -0
  37. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/inspector/__init__.py +0 -0
  38. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/inspector/base.py +0 -0
  39. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/inspector/mysql.py +0 -0
  40. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/inspector/postgres.py +0 -0
  41. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/README.md.j2 +0 -0
  42. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/asgi.py.j2 +0 -0
  43. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/env.example.j2 +0 -0
  44. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/gitignore.j2 +0 -0
  45. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/manage.py.j2 +0 -0
  46. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/module/models.py.j2 +0 -0
  47. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/module/resources.py.j2 +0 -0
  48. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/requirements.txt.j2 +0 -0
  49. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/router/endpoints.py.j2 +0 -0
  50. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/router/urls.py.j2 +0 -0
  51. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/run.sh.j2 +0 -0
  52. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/settings/env.py.j2 +0 -0
  53. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/gen/templates/settings/settings.py.j2 +0 -0
  54. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/helpers.py +0 -0
  55. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/management/__init__.py +0 -0
  56. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/management/commands/__init__.py +0 -0
  57. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/management/commands/mcp_serve.py +0 -0
  58. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/mcp/__init__.py +0 -0
  59. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/mcp/protocol.py +0 -0
  60. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/mcp/schema.py +0 -0
  61. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/mcp/server.py +0 -0
  62. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/mcp/testing.py +0 -0
  63. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/mcp/tool.py +0 -0
  64. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/mcp/tools.py +0 -0
  65. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/middleware.py +0 -0
  66. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/__init__.py +0 -0
  67. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/_common.py +0 -0
  68. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/apps.py +0 -0
  69. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/codes.py +0 -0
  70. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/devices.py +0 -0
  71. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/discovery.py +0 -0
  72. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/migrations/__init__.py +0 -0
  73. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/models.py +0 -0
  74. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resources/__init__.py +0 -0
  75. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resources/authorize.py +0 -0
  76. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resources/device.py +0 -0
  77. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resources/device_authorization.py +0 -0
  78. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resources/register.py +0 -0
  79. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resources/revoke.py +0 -0
  80. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/resources/token.py +0 -0
  81. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/session.py +0 -0
  82. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/templates/zeromcp_oauth/consent.html +0 -0
  83. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/templates/zeromcp_oauth/device.html +0 -0
  84. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/oauth/tokens.py +0 -0
  85. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/openapi.py +0 -0
  86. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/orm/__init__.py +0 -0
  87. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/rate_limit.py +0 -0
  88. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/redis_config.py +0 -0
  89. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/schemas.py +0 -0
  90. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/sensitive.py +0 -0
  91. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/serializer.py +0 -0
  92. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/tenant/__init__.py +0 -0
  93. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/tenant/db_router.py +0 -0
  94. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/tenant/registry.py +0 -0
  95. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi/tenant/tenant.py +0 -0
  96. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi_django.egg-info/SOURCES.txt +0 -0
  97. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi_django.egg-info/dependency_links.txt +0 -0
  98. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi_django.egg-info/entry_points.txt +0 -0
  99. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi_django.egg-info/requires.txt +0 -0
  100. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/easyapi_django.egg-info/top_level.txt +0 -0
  101. {easyapi_django-1.3.0 → easyapi_django-1.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi-django
3
- Version: 1.3.0
3
+ Version: 1.3.2
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
@@ -174,7 +174,7 @@ REST is mostly a solved problem now. The new pain is MCP — most teams are rebu
174
174
  - Multi-tenant DB routing. One call switches the connection for the request.
175
175
  - Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
176
176
  - Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
177
- - Global read-only switch (`MCP = {'READ_ONLY': True}`) rejects every non-`GET` request with `405`.
177
+ - Global read-only switch (`EASYAPI = {'READ_ONLY': True}`) rejects every non-`GET` request with `405`.
178
178
  - Async end-to-end. Async ORM, async Redis, async dispatch.
179
179
 
180
180
  Full docs and reference: https://github.com/ssjunior/easyapi-django
@@ -233,11 +233,12 @@ Defaults are demo-friendly. Production deployments should:
233
233
  - **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
234
  - **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
235
  - **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 `MCP = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
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
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
- - **Bound the metrics surface.** The `/metrics` endpoint queries any model in the registry by default. Set `METRICS_MODELS = ['app_Model', ...]` to allowlist which models are queryable. References to credential-named columns (in filters, groups, order or calc) are always rejected — the per-resource escape hatch does not reach `/metrics`.
238
+ - **`/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
+ - **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.
239
240
  - **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.
240
- - **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `MCP['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).
241
+ - **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).
241
242
 
242
243
  ---
243
244
 
@@ -130,7 +130,7 @@ REST is mostly a solved problem now. The new pain is MCP — most teams are rebu
130
130
  - Multi-tenant DB routing. One call switches the connection for the request.
131
131
  - Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
132
132
  - Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
133
- - Global read-only switch (`MCP = {'READ_ONLY': True}`) rejects every non-`GET` request with `405`.
133
+ - Global read-only switch (`EASYAPI = {'READ_ONLY': True}`) rejects every non-`GET` request with `405`.
134
134
  - Async end-to-end. Async ORM, async Redis, async dispatch.
135
135
 
136
136
  Full docs and reference: https://github.com/ssjunior/easyapi-django
@@ -189,11 +189,12 @@ Defaults are demo-friendly. Production deployments should:
189
189
  - **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
190
  - **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
191
  - **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 `MCP = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
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
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
- - **Bound the metrics surface.** The `/metrics` endpoint queries any model in the registry by default. Set `METRICS_MODELS = ['app_Model', ...]` to allowlist which models are queryable. References to credential-named columns (in filters, groups, order or calc) are always rejected — the per-resource escape hatch does not reach `/metrics`.
194
+ - **`/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
+ - **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.
195
196
  - **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.
196
- - **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `MCP['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).
197
+ - **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).
197
198
 
198
199
  ---
199
200
 
@@ -16,6 +16,7 @@ except PackageNotFoundError: # editable/dev checkout without metadata
16
16
  try:
17
17
  from easyapi.auth import make_token, validate_token, validate_token_async # noqa: F401
18
18
  from easyapi.base import BaseResource # noqa
19
+ from easyapi.calc_resource import Metrics # noqa
19
20
  from easyapi.core import CoreResource, endpoint # noqa: F401
20
21
  from easyapi.exception import HTTPException # noqa
21
22
  from easyapi.middleware import AuthMiddleware, ExceptionMiddleware # noqa
@@ -116,7 +116,7 @@ def _warn_csrf_unprotected_at_startup():
116
116
  logger.warning(
117
117
  'authenticated mode is on but neither ENFORCE_TOKEN nor '
118
118
  'ALLOWED_ORIGINS is configured — no anti-CSRF defense at the '
119
- 'framework level. Set at least one in the MCP settings dict, '
119
+ 'framework level. Set at least one in the EASYAPI settings dict, '
120
120
  'or harden cookies at the deployment layer.',
121
121
  )
122
122
 
@@ -138,7 +138,7 @@ def _warn_if_security_bypassed_at_startup():
138
138
  'limiting, credential-path defense and abuse blocking are '
139
139
  'DISABLED (SECURITY_BYPASS set, or DEBUG=True with '
140
140
  'SECURITY_BYPASS unset). Never run this in production; set '
141
- "MCP['SECURITY_BYPASS'] = False to force protections on.",
141
+ "EASYAPI['SECURITY_BYPASS'] = False to force protections on.",
142
142
  )
143
143
 
144
144
 
@@ -147,7 +147,7 @@ _warn_if_security_bypassed_at_startup()
147
147
 
148
148
  class BaseResource(CoreResource):
149
149
  # ``True`` historically. Override globally via
150
- # ``MCP = {'DEFAULT_AUTHENTICATED': False}`` (the generator emits
150
+ # ``EASYAPI = {'DEFAULT_AUTHENTICATED': False}`` (the generator emits
151
151
  # this for demo-friendly first-run experience), or per-resource
152
152
  # by setting ``authenticated = False`` on a subclass.
153
153
  authenticated = DEFAULT_AUTHENTICATED
@@ -776,6 +776,8 @@ class BaseResource(CoreResource):
776
776
  if is_pydantic_model(schema):
777
777
  self.body = validate_input(schema, self.body)
778
778
  await self.hydrate(self.body)
779
+ elif request.content_type == 'multipart/form-data':
780
+ self.body = None
779
781
  else:
780
782
  raise HTTPException(400, 'Invalid body')
781
783
  else:
@@ -19,7 +19,9 @@ from .dates import Dates
19
19
  from .exception import HTTPException
20
20
  from .filters import Filter as OrmFilter
21
21
  from .sensitive import looks_sensitive
22
- from .settings_helper import get_metrics_max_execution_ms, get_metrics_models
22
+ from .settings_helper import (
23
+ get_metrics_fields, get_metrics_max_execution_ms, get_metrics_models,
24
+ )
23
25
  from .util import normalize_field
24
26
 
25
27
  SAFE_OPS = {
@@ -584,52 +586,99 @@ def _collect_condition_fields(node, refs):
584
586
  _collect_condition_fields(item, refs)
585
587
 
586
588
 
587
- def _reject_sensitive_fields(data):
588
- """Block any field reference in the metrics body that touches a
589
- credential-named column.
589
+ _DJANGO_LOOKUPS = frozenset({
590
+ 'exact', 'iexact', 'contains', 'icontains', 'in',
591
+ 'gt', 'gte', 'lt', 'lte', 'startswith', 'istartswith',
592
+ 'endswith', 'iendswith', 'range', 'date', 'year', 'iso_year',
593
+ 'month', 'day', 'week', 'week_day', 'iso_week_day', 'quarter',
594
+ 'time', 'hour', 'minute', 'second', 'isnull', 'regex', 'iregex',
595
+ })
590
596
 
591
- The metrics endpoint references models/fields straight from the
592
- request body — filters, groups, order and calc targets. Without this
593
- guard, an authenticated caller can turn counts into a boolean oracle
594
- (``owner__password__startswith=a`` read the count) or read values
595
- directly (``group_by.fields=['owner__api_key']`` the api_key comes
596
- back as a group label; the LIST/detail sensitive-field scrub does not
597
- cover metrics output). We reject any reference whose ``__``-split
598
- segments include a sensitive name.
597
+
598
+ def _strip_known_lookup(ref):
599
+ """Strip one trailing Django lookup segment (``__icontains``, ``__gte``,
600
+ ...) if present. Returns ``None`` when there's nothing recognized to
601
+ strip, so callers can distinguish "no lookup suffix" from "stripped to
602
+ an empty path"."""
603
+ if '__' not in ref:
604
+ return None
605
+ head, _, tail = ref.rpartition('__')
606
+ return head if tail in _DJANGO_LOOKUPS else None
607
+
608
+
609
+ def _collect_metrics_field_refs(data):
610
+ """Walk the metrics request body and collect every field reference —
611
+ filters, groups, order and calc targets — as they appear verbatim
612
+ (``__``-joined paths). Shared by ``_reject_sensitive_fields`` and
613
+ ``_check_field_allowlist`` so both see the exact same surface; collecting
614
+ twice risks one guard covering a body key the other misses.
615
+
616
+ Returns ``(strict_refs, filter_refs, order_refs)``. ``filter_refs`` are
617
+ ``filter_by.fields`` keys (including ``or_conditions`` entries) — these
618
+ reach ``queryset.filter(**kwargs)``/``Q(**kwargs)`` directly, so they may
619
+ carry a trailing Django lookup (``owner__name__icontains``) that isn't
620
+ part of the field path itself. ``order_refs`` are ``order`` entries
621
+ (``-`` stripped) — these may reference either a real field/path or a
622
+ ``calc.formula`` name (``count``, ``sum``, ...), since ``group_by``
623
+ annotates the queryset with the formula name as the aggregation's alias
624
+ and ``order_by`` can target that alias directly (see ``group_by()``).
625
+ Every other reference (``calc``, ``group_by``, ``additional_fields``,
626
+ ``period.field``, Layer-2 ``conditions``) is a bare field/path — Layer-2
627
+ conditions carry the lookup in a separate ``operator`` key, not appended
628
+ to ``field``.
599
629
  """
600
- refs = []
630
+ strict_refs = []
631
+ filter_refs = []
632
+ order_refs = []
601
633
 
602
634
  calc_cfg = data.get('calc') or {}
603
635
  on_field = calc_cfg.get('field')
604
636
  if isinstance(on_field, str):
605
- refs.append(on_field)
637
+ strict_refs.append(on_field)
606
638
  elif isinstance(on_field, list):
607
- refs += [f for f in on_field if isinstance(f, str)]
639
+ strict_refs += [f for f in on_field if isinstance(f, str)]
608
640
 
609
641
  gb = data.get('group_by') or {}
610
- refs += [f for f in (gb.get('fields') or []) if isinstance(f, str)]
642
+ strict_refs += [f for f in (gb.get('fields') or []) if isinstance(f, str)]
611
643
  date_group = gb.get('date') or {}
612
644
  if isinstance(date_group.get('field'), str):
613
- refs.append(date_group['field'])
645
+ strict_refs.append(date_group['field'])
614
646
 
615
- refs += [f for f in (data.get('additional_fields') or []) if isinstance(f, str)]
616
- refs += [o.lstrip('-') for o in (data.get('order') or []) if isinstance(o, str)]
647
+ strict_refs += [f for f in (data.get('additional_fields') or []) if isinstance(f, str)]
648
+ order_refs += [o.lstrip('-') for o in (data.get('order') or []) if isinstance(o, str)]
617
649
 
618
650
  filter_by = data.get('filter_by') or {}
619
651
  for key, value in (filter_by.get('fields') or {}).items():
620
652
  if key == 'or_conditions':
621
653
  for cond in value or []:
622
654
  if isinstance(cond, dict):
623
- refs += list(cond.keys())
655
+ filter_refs += list(cond.keys())
624
656
  else:
625
- refs.append(key)
657
+ filter_refs.append(key)
626
658
  period = filter_by.get('period') or {}
627
659
  if isinstance(period.get('field'), str):
628
- refs.append(period['field'])
660
+ strict_refs.append(period['field'])
661
+
662
+ _collect_condition_fields(data.get('conditions'), strict_refs)
629
663
 
630
- _collect_condition_fields(data.get('conditions'), refs)
664
+ return strict_refs, filter_refs, order_refs
631
665
 
632
- for ref in refs:
666
+
667
+ def _reject_sensitive_fields(data):
668
+ """Block any field reference in the metrics body that touches a
669
+ credential-named column.
670
+
671
+ The metrics endpoint references models/fields straight from the
672
+ request body — filters, groups, order and calc targets. Without this
673
+ guard, an authenticated caller can turn counts into a boolean oracle
674
+ (``owner__password__startswith=a`` → read the count) or read values
675
+ directly (``group_by.fields=['owner__api_key']`` → the api_key comes
676
+ back as a group label; the LIST/detail sensitive-field scrub does not
677
+ cover metrics output). We reject any reference whose ``__``-split
678
+ segments include a sensitive name.
679
+ """
680
+ strict_refs, filter_refs, order_refs = _collect_metrics_field_refs(data)
681
+ for ref in strict_refs + filter_refs + order_refs:
633
682
  for segment in str(ref).split('__'):
634
683
  if looks_sensitive(segment):
635
684
  raise HTTPException(
@@ -639,6 +688,62 @@ def _reject_sensitive_fields(data):
639
688
  )
640
689
 
641
690
 
691
+ def _check_field_allowlist(data, model_label):
692
+ """Enforce ``EASYAPI['METRICS_FIELDS']`` (see ``get_metrics_fields``) when the
693
+ project has configured an allowlist for ``model_label``. Each reference
694
+ must match a listed path exactly — full ``__``-joined path, not just the
695
+ root segment. A model absent from the configured dict, or the setting
696
+ left unset entirely, is unrestricted (opt-in, preserves current
697
+ behavior).
698
+
699
+ ``filter_by`` references try the literal path first; only when that
700
+ fails and the trailing segment is a recognized Django lookup
701
+ (``__icontains``, ``__gte``, ...) do we retry without it — a filter
702
+ allowed on a field shouldn't require enumerating every lookup variant.
703
+ ``order`` references try the literal path first too, then fall back to
704
+ matching a ``calc.formula`` name (``count``, ``sum``, ...) — ``group_by``
705
+ annotates the queryset with the formula name as the aggregation's alias,
706
+ so ``order: ['-count']`` targets that alias, not a model field, and
707
+ would otherwise 403 on every grouped/aggregated request. Every other
708
+ reference must match literally: neither a lookup suffix nor a formula
709
+ alias is valid there, so allowing either would loosen the check for no
710
+ reason.
711
+ """
712
+ allowed_by_model = get_metrics_fields()
713
+ if not allowed_by_model or model_label not in allowed_by_model:
714
+ return
715
+
716
+ allowed = allowed_by_model[model_label]
717
+ strict_refs, filter_refs, order_refs = _collect_metrics_field_refs(data)
718
+
719
+ for ref in strict_refs:
720
+ if ref not in allowed:
721
+ raise HTTPException(
722
+ 403,
723
+ f'Field not allowed for metrics on "{model_label}": {ref}',
724
+ )
725
+
726
+ for ref in filter_refs:
727
+ if ref in allowed:
728
+ continue
729
+ stripped = _strip_known_lookup(ref)
730
+ if stripped is not None and stripped in allowed:
731
+ continue
732
+ raise HTTPException(
733
+ 403,
734
+ f'Field not allowed for metrics on "{model_label}": {ref}',
735
+ )
736
+
737
+ calc_formulas = (data.get('calc') or {}).get('formula') or []
738
+ for ref in order_refs:
739
+ if ref in allowed or ref in calc_formulas:
740
+ continue
741
+ raise HTTPException(
742
+ 403,
743
+ f'Field not allowed for metrics on "{model_label}": {ref}',
744
+ )
745
+
746
+
642
747
  async def get_results(timezone, data, base_queryset=None):
643
748
  """Entry point for the metrics endpoint.
644
749
 
@@ -659,6 +764,7 @@ async def get_results(timezone, data, base_queryset=None):
659
764
  raise HTTPException(403, f'Metrics not allowed for model "{model_label}"')
660
765
 
661
766
  _reject_sensitive_fields(data)
767
+ _check_field_allowlist(data, model_label)
662
768
 
663
769
  model = get_model(model_label)
664
770
 
@@ -297,7 +297,7 @@ def _print_next_steps(out: Path, sample: dict | None, *, read_only: bool = False
297
297
  print(
298
298
  '\n⚠ this project was generated read-only — once you register\n'
299
299
  ' MCP toolsets, `POST /mcp` will return 405. Re-run with\n'
300
- ' `--writable` (or drop `MCP[\'READ_ONLY\']` in settings.py)\n'
300
+ ' `--writable` (or drop `EASYAPI[\'READ_ONLY\']` in settings.py)\n'
301
301
  ' if you plan to expose an MCP server.',
302
302
  file=sys.stderr,
303
303
  )
@@ -47,7 +47,7 @@ class Project:
47
47
  backend: str
48
48
  database: str
49
49
  # When True (default), the generated ``settings.py`` carries
50
- # ``MCP = {'READ_ONLY': True}`` — a global gate that rejects every
50
+ # ``EASYAPI = {'READ_ONLY': True}`` — a global gate that rejects every
51
51
  # non-GET request with 405 across all resources (REST + MCP).
52
52
  # Safer first-run default for projects pointed at an existing
53
53
  # database; flip with ``--writable`` to drop the gate.
@@ -1,8 +1,8 @@
1
1
  """Lazy accessor for the configured OAuth clients.
2
2
 
3
- The project defines its clients under ``MCP['OAUTH_CLIENTS']``::
3
+ The project defines its clients under ``EASYAPI['OAUTH_CLIENTS']``::
4
4
 
5
- MCP = {
5
+ EASYAPI = {
6
6
  'OAUTH_CLIENTS': {
7
7
  'claude': {
8
8
  # Public client — MCP clients (Claude.ai, other LLMs)
@@ -31,7 +31,7 @@ from .settings_helper import get_oauth_clients
31
31
 
32
32
  class _ClientsView:
33
33
  """Dict-like proxy that re-reads settings on every access so tests
34
- overriding ``MCP['OAUTH_CLIENTS']`` see fresh data without an
34
+ overriding ``EASYAPI['OAUTH_CLIENTS']`` see fresh data without an
35
35
  import-time freeze."""
36
36
 
37
37
  def __contains__(self, key):
@@ -1,6 +1,6 @@
1
1
  """Static constants for the OAuth sub-package.
2
2
 
3
- ``SCOPE.ALL`` is built lazily from ``MCP['OAUTH_SCOPES']`` so the set
3
+ ``SCOPE.ALL`` is built lazily from ``EASYAPI['OAUTH_SCOPES']`` so the set
4
4
  of advertised scopes stays project-defined.
5
5
  """
6
6
  from .settings_helper import get_oauth_scopes
@@ -6,7 +6,7 @@ from easyapi.oauth.settings_helper import get_oauth_user_model
6
6
 
7
7
  # Resolve the FK target at import time, mirroring Django's own
8
8
  # ``settings.AUTH_USER_MODEL`` pattern. Projects must set
9
- # ``MCP['OAUTH_USER_MODEL']`` BEFORE running the first migrate;
9
+ # ``EASYAPI['OAUTH_USER_MODEL']`` BEFORE running the first migrate;
10
10
  # changing it later requires a new migration in the project.
11
11
  _USER_MODEL = get_oauth_user_model()
12
12
 
@@ -19,7 +19,7 @@ Contract for ``OAUTH_USER_RESOLVER`` (dotted path in ``MCP``)::
19
19
  '''
20
20
 
21
21
  When no resolver is configured, a minimal fallback resolves the user
22
- via ``MCP['OAUTH_USER_MODEL']`` and returns ``{'user': {'id': ...},
22
+ via ``EASYAPI['OAUTH_USER_MODEL']`` and returns ``{'user': {'id': ...},
23
23
  'account': {'id': account_id}}``.
24
24
  """
25
25
  from django.apps import apps
@@ -2,7 +2,7 @@
2
2
 
3
3
  Returns the full RFC 6749 + RFC 8628 surface so the project can plug
4
4
  it into its ``urls.py`` with a single call. Returns ``[]`` when
5
- ``MCP['OAUTH_URL']`` is not configured — keeps the surface invisible
5
+ ``EASYAPI['OAUTH_URL']`` is not configured — keeps the surface invisible
6
6
  for projects that don't use OAuth.
7
7
  """
8
8
  from django.urls import path
@@ -13,7 +13,7 @@ from easyapi.settings_helper import get_oauth_url
13
13
  def get_oauth_routes():
14
14
  """Return Django URL patterns for the OAuth endpoints.
15
15
 
16
- Returns empty list if ``MCP['OAUTH_URL']`` is not configured.
16
+ Returns empty list if ``EASYAPI['OAUTH_URL']`` is not configured.
17
17
  """
18
18
  if not get_oauth_url():
19
19
  return []
@@ -1,7 +1,8 @@
1
1
  """Module-local helpers around ``easyapi.settings_helper.get_setting``.
2
2
 
3
3
  All project-specific knobs for the OAuth sub-package live under the
4
- shared ``MCP`` settings dict. Each helper here returns a safe default
4
+ shared ``EASYAPI`` settings dict (``MCP`` as a legacy alias see
5
+ ``easyapi.settings_helper``). Each helper here returns a safe default
5
6
  so the package can be imported (and migrated) before the project has
6
7
  configured anything OAuth-specific.
7
8
  """
@@ -46,7 +47,7 @@ def get_oauth_clients():
46
47
  A client becomes **confidential** only by opting in explicitly:
47
48
 
48
49
  * ``'client_secret': '<value>'`` — that literal secret, or
49
- * ``'client_secret': ''`` — share ``MCP['OAUTH_SECRET']``
50
+ * ``'client_secret': ''`` — share ``EASYAPI['OAUTH_SECRET']``
50
51
  across confidential clients without repeating it (``None`` when
51
52
  ``OAUTH_SECRET`` is unset).
52
53
 
@@ -5,7 +5,6 @@ from django.urls import path, re_path
5
5
  from django.http import HttpResponse, JsonResponse
6
6
 
7
7
  from . import bearer as _bearer
8
- from .calc_resource import Metrics
9
8
  from .openapi import SCALAR_HTML, build_spec
10
9
  from .redis_config import KEY_PREFIX, get_redis, getex as redis_getex
11
10
  from .settings_helper import (
@@ -66,12 +65,12 @@ def get_routes(endpoints, **kwargs):
66
65
  to make them anonymous; default is ``False`` (requires the same auth as
67
66
  the rest of the API — session cookie or X-Api-Key).
68
67
 
69
- ``metrics_view`` controls the built-in ``/metrics`` route. Default mounts
70
- the generic :class:`Metrics` (no row scoping). Pass a custom resource to
71
- replace it (e.g. a role-scoped subclass that injects a ``base_queryset``),
72
- or ``None`` to not mount it at all — so a project that owns row scoping
73
- can guarantee the unscoped route does not exist, instead of relying on
74
- URL-order shadowing.
68
+ ``metrics_view`` controls the built-in ``/metrics`` route. Default is
69
+ ``None`` not mounted. Pass :class:`Metrics` to opt into the generic
70
+ (no row scoping) resource, or a custom resource (e.g. a role-scoped
71
+ subclass that injects a ``base_queryset``) to replace it. Metrics is a
72
+ real attack surface if left on by accident, so a project has to ask for
73
+ it explicitly rather than lose it silently to a missed override.
75
74
  """
76
75
  from . import __version__ as _easyapi_version
77
76
 
@@ -79,7 +78,7 @@ def get_routes(endpoints, **kwargs):
79
78
  spec_version = kwargs.get('version', _easyapi_version)
80
79
  spec_description = kwargs.get('description')
81
80
  docs_public = kwargs.get('docs_public', False)
82
- metrics_view = kwargs.get('metrics_view', Metrics)
81
+ metrics_view = kwargs.get('metrics_view', None)
83
82
 
84
83
  async def openapi_json(request, *args, **kwargs):
85
84
  if not docs_public and not await _has_valid_session(request):
@@ -22,6 +22,12 @@ from .redis_config import KEY_PREFIX, get_redis
22
22
 
23
23
  from .settings_helper import get_setting, security_bypassed
24
24
 
25
+ # EASYAPI['BLOCK_PATTERNS']/EASYAPI['BLOCK_USER_AGENTS'] *replace* the framework
26
+ # defaults below — they do not merge. A project that already sets either key
27
+ # gets none of the patterns/signatures added here in later versions; copy the
28
+ # current DEFAULT_BLOCK_PATTERNS/DEFAULT_BLOCK_USER_AGENTS into the override
29
+ # and extend it instead of starting from an empty list, or new protections
30
+ # added upstream won't reach that project until the override is refreshed.
25
31
  BLOCK_PATTERNS = get_setting('BLOCK_PATTERNS')
26
32
  BLOCK_USER_AGENTS = get_setting('BLOCK_USER_AGENTS')
27
33
  MAX_4XX_PER_MINUTE = get_setting('MAX_4XX_PER_MINUTE', default=10)
@@ -36,10 +42,14 @@ DEFAULT_BLOCK_PATTERNS = [
36
42
  r'/(cgi-bin|fcgi-bin|server-status|server-info)',
37
43
  r'/(actuator|jmx-console|config\.json|appsettings\.json)',
38
44
  r'\.\./|%2e%2e|\.\.\\',
39
- r'\bunion\s+select\b|\bor\s+1\s*=\s*1\b|\bsleep\s*\(',
45
+ r'\bunion\s+select\b|\bor\s+1\s*=\s*1\b|\band\s+1\s*=\s*1\b|\bsleep\s*\(',
46
+ r"'\s*or\s*'1'\s*=\s*'1|;--|\bwaitfor\s+delay\b|\bbenchmark\s*\(",
47
+ r'\bxp_cmdshell\b|\bpg_sleep\s*\(|information_schema|\bextractvalue\s*\(|\bupdatexml\s*\(',
40
48
  r'<script\b|javascript:|onerror\s*=',
41
49
  r'/etc/passwd|/etc/shadow|/proc/self',
42
50
  r'\bphp://|\bdata:.*base64',
51
+ r';\s*(cat|rm|wget|curl|nc|bash|sh)\b|\$\(.*\)|`[^`]*`',
52
+ r'%0d%0a|\r\n',
43
53
  ]
44
54
 
45
55
  DEFAULT_BLOCK_USER_AGENTS = [
@@ -1,14 +1,20 @@
1
- """Read easyapi configuration from the project's ``MCP`` dict.
1
+ """Read easyapi configuration from the project's ``EASYAPI`` dict.
2
2
 
3
- A single dict named ``MCP`` in ``settings.py`` collects every
3
+ A single dict named ``EASYAPI`` in ``settings.py`` collects every
4
4
  easyapi-owned setting (DRF/Celery-style namespace)::
5
5
 
6
- MCP = {
6
+ EASYAPI = {
7
7
  'CACHE_TTL': 300,
8
8
  'ENFORCE_TOKEN': True,
9
9
  'RATE_LIMITS': {...},
10
10
  }
11
11
 
12
+ ``MCP`` is kept as a legacy alias for projects that haven't migrated
13
+ yet — ``get_setting`` reads ``EASYAPI`` first and falls back to
14
+ ``MCP`` when the project only defines the old name. Both point at the
15
+ same project; ``EASYAPI`` reflects that the framework serves REST
16
+ first, MCP is one surface among several.
17
+
12
18
  Inside the bag the historical ``MCP_`` prefix is redundant:
13
19
  ``MCP_API_KEY_RESOLVER`` and ``API_KEY_RESOLVER`` resolve to the
14
20
  same setting.
@@ -16,17 +22,20 @@ same setting.
16
22
 
17
23
 
18
24
  def get_setting(name, default=None):
19
- """Look up a setting by name in ``settings.MCP``.
25
+ """Look up a setting by name in ``settings.EASYAPI`` (or ``settings.MCP``
26
+ as a legacy alias — see module docstring).
20
27
 
21
28
  Returns ``default`` (which itself defaults to ``None``) when the
22
- project hasn't defined the bag or the key is missing.
29
+ project hasn't defined either bag or the key is missing.
23
30
  """
24
31
  try:
25
32
  from settings import settings as _project_settings
26
33
  except ImportError:
27
34
  return default
28
35
 
29
- bag = getattr(_project_settings, 'MCP', None)
36
+ bag = getattr(_project_settings, 'EASYAPI', None)
37
+ if not isinstance(bag, dict):
38
+ bag = getattr(_project_settings, 'MCP', None)
30
39
  if not isinstance(bag, dict):
31
40
  return default
32
41
 
@@ -115,6 +124,29 @@ def get_metrics_models():
115
124
  return get_setting('METRICS_MODELS', default=None)
116
125
 
117
126
 
127
+ def get_metrics_fields():
128
+ """Per-model allowlist of field references (``"applabel_ClassName": [...]``)
129
+ the metrics endpoint may filter, group, order or aggregate by. Each entry
130
+ is a full ``__``-joined path (e.g. ``"agency__country__name"``), not just
131
+ the root segment — stricter than ``filters.validate_conditions``, which
132
+ only checks the root.
133
+
134
+ ``calc``/``group_by``/``order``/``additional_fields``/``conditions``
135
+ references must match a listed path exactly. ``filter_by.fields`` (and
136
+ ``or_conditions``) reach ``Q(**kwargs)`` directly and may carry a
137
+ trailing Django lookup not present in the list — a reference like
138
+ ``"owner__name__icontains"`` matches a listed ``"owner__name"`` by
139
+ stripping one recognized lookup segment before comparing.
140
+
141
+ ``None`` (default) disables the check entirely. A model label absent from
142
+ the dict is also unrestricted — set ``METRICS_MODELS`` alongside this to
143
+ close that gap for every model not explicitly listed here.
144
+
145
+ Orthogonal to ``METRICS_MODELS`` (bounds *which models*) and
146
+ ``base_queryset`` (bounds *which rows*): this bounds *which fields*."""
147
+ return get_setting('METRICS_FIELDS', default=None)
148
+
149
+
118
150
  def get_credential_paths():
119
151
  """Path-segment list that triggers the credential-path rate-limit
120
152
  auto-defense. Anything matching one of these segments — at the
@@ -129,13 +161,13 @@ def get_credential_paths():
129
161
  * ``/forgotten`` → NOT matched (different segment)
130
162
 
131
163
  Configure with leading slash for readability — it's stripped at
132
- match time. Set to ``[]`` in ``MCP`` to disable.
164
+ match time. Set to ``[]`` in ``EASYAPI`` to disable.
133
165
 
134
- **Override semantics:** ``MCP['CREDENTIAL_PATHS']`` *replaces* the
166
+ **Override semantics:** ``EASYAPI['CREDENTIAL_PATHS']`` *replaces* the
135
167
  framework default — does not merge. To add project-specific paths
136
168
  while keeping the defaults, copy the default list and extend::
137
169
 
138
- MCP = {
170
+ EASYAPI = {
139
171
  'CREDENTIAL_PATHS': [
140
172
  '/forgot', '/change_password', '/signup',
141
173
  '/reset', '/recover', '/register', # default
@@ -0,0 +1,105 @@
1
+ import re
2
+
3
+ SESSION_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9_\-:]{5,100}$')
4
+
5
+
6
+ def validate_session_key(session_key):
7
+ if not session_key or not SESSION_KEY_PATTERN.match(session_key):
8
+ return None
9
+ return session_key
10
+
11
+
12
+ def make_list(data):
13
+ if not data:
14
+ data = []
15
+ elif not isinstance(data, list):
16
+ data = [data]
17
+
18
+ return data
19
+
20
+
21
+ def make_unique_list(data):
22
+ return list(set(make_list(data)))
23
+
24
+
25
+ def normalize_field(name):
26
+ # Used by Filter.distinct to render unique column values as filter
27
+ # options in the UI. Falsy non-int values collapse to 'Blank' so the
28
+ # user sees a single "empty" option for fields where null=True,
29
+ # blank=True both occur (e.g. segment filters).
30
+ #
31
+ # `bool` is a subclass of `int` in Python, so `isinstance(False, int)`
32
+ # would be True. We use `type(name) is int` instead to keep False in
33
+ # the 'Blank' bucket alongside '' and [].
34
+ #
35
+ # Behaviour table:
36
+ #
37
+ # | Input | Output | Notes |
38
+ # |--------|-----------|-----------------------------------------|
39
+ # | None | 'Null' | |
40
+ # | 0 | 0 | int passes through |
41
+ # | 0.0 | 'Blank' | float, falsy, not int |
42
+ # | '' | 'Blank' | |
43
+ # | 'x' | 'x' | |
44
+ # | [] | 'Blank' | empty collection, falsy |
45
+ # | True | True | int(True) == 1, truthy |
46
+ # | False | 'Blank' | bool not in int bucket here, falsy |
47
+ #
48
+ # If the segment UI ever needs False to be a real value (not "empty"),
49
+ # swap `type(name) is not int` for `not isinstance(name, int)`.
50
+ if name is None:
51
+ return 'Null'
52
+ if type(name) is not int and not name:
53
+ return 'Blank'
54
+ return name
55
+
56
+
57
+ def flatten_include_fields(model, bag):
58
+ """Flatten a project's ``INCLUDE_FIELDS[label]`` bag (as used by the
59
+ hand-maintained ``modules/segment/constants.py`` convention) into a flat
60
+ list of full ``__``-joined field paths, suitable for
61
+ ``EASYAPI['METRICS_FIELDS'][label]``.
62
+
63
+ ``model`` is the Django model class the label refers to. ``bag`` is a
64
+ dict shaped like::
65
+
66
+ {
67
+ 'bi_fields': ['id', 'active', ...],
68
+ 'related_fields': {
69
+ 1: {'agency': ['name', 'country', ...]},
70
+ 2: {'country': ['name', 'code', ...]},
71
+ },
72
+ }
73
+
74
+ ``bi_fields`` are copied verbatim (top-level columns on ``model``
75
+ itself). ``related_fields`` is walked level by level, using the model's
76
+ own ``_meta`` to resolve each relation's ``related_model`` and recurse —
77
+ mirroring the traversal ``modules/segment/util.py``'s ``get_related``
78
+ already performs to build the frontend's field picker, so the allowlist
79
+ matches exactly what the frontend is told exists. ``related_models`` is
80
+ not consulted: it's redundant with (and, in practice, sometimes
81
+ inconsistent with) the ``related_fields`` keys, which are the actual
82
+ gate ``get_related`` walks by.
83
+
84
+ Only fields explicitly enumerated in ``bi_fields``/``related_fields``
85
+ are returned — a relation absent from ``related_fields`` is never
86
+ recursed into, so its own columns never appear in the output.
87
+ """
88
+ paths = list(bag.get('bi_fields') or [])
89
+
90
+ def walk(current_model, level, prefix):
91
+ level_map = (bag.get('related_fields') or {}).get(level) or {}
92
+ for relation_name, leaf_fields in level_map.items():
93
+ try:
94
+ field = current_model._meta.get_field(relation_name)
95
+ except Exception:
96
+ continue
97
+ full_prefix = f'{prefix}__{relation_name}' if prefix else relation_name
98
+ for leaf in leaf_fields:
99
+ paths.append(f'{full_prefix}__{leaf}')
100
+ related_model = getattr(field, 'related_model', None)
101
+ if related_model:
102
+ walk(related_model, level + 1, full_prefix)
103
+
104
+ walk(model, 1, '')
105
+ return paths
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyapi-django
3
- Version: 1.3.0
3
+ Version: 1.3.2
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
@@ -174,7 +174,7 @@ REST is mostly a solved problem now. The new pain is MCP — most teams are rebu
174
174
  - Multi-tenant DB routing. One call switches the connection for the request.
175
175
  - Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
176
176
  - Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
177
- - Global read-only switch (`MCP = {'READ_ONLY': True}`) rejects every non-`GET` request with `405`.
177
+ - Global read-only switch (`EASYAPI = {'READ_ONLY': True}`) rejects every non-`GET` request with `405`.
178
178
  - Async end-to-end. Async ORM, async Redis, async dispatch.
179
179
 
180
180
  Full docs and reference: https://github.com/ssjunior/easyapi-django
@@ -233,11 +233,12 @@ Defaults are demo-friendly. Production deployments should:
233
233
  - **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
234
  - **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
235
  - **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 `MCP = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
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
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
- - **Bound the metrics surface.** The `/metrics` endpoint queries any model in the registry by default. Set `METRICS_MODELS = ['app_Model', ...]` to allowlist which models are queryable. References to credential-named columns (in filters, groups, order or calc) are always rejected — the per-resource escape hatch does not reach `/metrics`.
238
+ - **`/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
+ - **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.
239
240
  - **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.
240
- - **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `MCP['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).
241
+ - **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).
241
242
 
242
243
  ---
243
244
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "easyapi-django"
7
- version = "1.3.0"
7
+ version = "1.3.2"
8
8
  authors = [
9
9
  { name="Stamatios Stamou Jr", email="bushier.outsets.0c@icloud.com" },
10
10
  ]
@@ -1,54 +0,0 @@
1
- import re
2
-
3
- SESSION_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9_\-:]{5,100}$')
4
-
5
-
6
- def validate_session_key(session_key):
7
- if not session_key or not SESSION_KEY_PATTERN.match(session_key):
8
- return None
9
- return session_key
10
-
11
-
12
- def make_list(data):
13
- if not data:
14
- data = []
15
- elif not isinstance(data, list):
16
- data = [data]
17
-
18
- return data
19
-
20
-
21
- def make_unique_list(data):
22
- return list(set(make_list(data)))
23
-
24
-
25
- def normalize_field(name):
26
- # Used by Filter.distinct to render unique column values as filter
27
- # options in the UI. Falsy non-int values collapse to 'Blank' so the
28
- # user sees a single "empty" option for fields where null=True,
29
- # blank=True both occur (e.g. segment filters).
30
- #
31
- # `bool` is a subclass of `int` in Python, so `isinstance(False, int)`
32
- # would be True. We use `type(name) is int` instead to keep False in
33
- # the 'Blank' bucket alongside '' and [].
34
- #
35
- # Behaviour table:
36
- #
37
- # | Input | Output | Notes |
38
- # |--------|-----------|-----------------------------------------|
39
- # | None | 'Null' | |
40
- # | 0 | 0 | int passes through |
41
- # | 0.0 | 'Blank' | float, falsy, not int |
42
- # | '' | 'Blank' | |
43
- # | 'x' | 'x' | |
44
- # | [] | 'Blank' | empty collection, falsy |
45
- # | True | True | int(True) == 1, truthy |
46
- # | False | 'Blank' | bool not in int bucket here, falsy |
47
- #
48
- # If the segment UI ever needs False to be a real value (not "empty"),
49
- # swap `type(name) is not int` for `not isinstance(name, int)`.
50
- if name is None:
51
- return 'Null'
52
- if type(name) is not int and not name:
53
- return 'Blank'
54
- return name
File without changes
File without changes