easyapi-django 1.3.1__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.
- {easyapi_django-1.3.1/easyapi_django.egg-info → easyapi_django-1.3.2}/PKG-INFO +6 -5
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/README.md +5 -4
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/__init__.py +1 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/base.py +3 -3
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/calc.py +130 -24
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/cli.py +1 -1
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/config.py +1 -1
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/clients.py +3 -3
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/constants.py +1 -1
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/migrations/0001_initial.py +1 -1
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resolver.py +1 -1
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/routes.py +2 -2
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/settings_helper.py +3 -2
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/routes.py +7 -8
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/security.py +11 -1
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/settings_helper.py +41 -9
- easyapi_django-1.3.2/easyapi/util.py +105 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2/easyapi_django.egg-info}/PKG-INFO +6 -5
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/pyproject.toml +1 -1
- easyapi_django-1.3.1/easyapi/util.py +0 -54
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/LICENSE +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/MANIFEST.in +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/auth.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/bearer.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/blocking.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/calc_resource.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/client_ip.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/constants.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/core/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/core/endpoint.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/core/resource.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/dates.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/exception.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/filters.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/generator.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/inspector/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/inspector/base.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/inspector/mysql.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/inspector/postgres.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/README.md.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/asgi.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/env.example.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/gitignore.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/manage.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/module/models.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/module/resources.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/requirements.txt.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/router/endpoints.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/router/urls.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/run.sh.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/settings/env.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/gen/templates/settings/settings.py.j2 +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/helpers.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/management/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/mcp/schema.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/mcp/server.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/mcp/testing.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/mcp/tool.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/middleware.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/_common.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/apps.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/codes.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/devices.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/discovery.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/migrations/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/models.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/authorize.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/device.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/device_authorization.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/register.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/revoke.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/token.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/session.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/templates/zeromcp_oauth/consent.html +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/templates/zeromcp_oauth/device.html +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/tokens.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/openapi.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/rate_limit.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/redis_config.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/schemas.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/sensitive.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/serializer.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi_django.egg-info/SOURCES.txt +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi_django.egg-info/entry_points.txt +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-1.3.1 → 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.
|
|
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 (`
|
|
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 `
|
|
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
|
-
-
|
|
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 `
|
|
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 (`
|
|
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 `
|
|
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
|
-
-
|
|
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 `
|
|
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
|
|
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
|
-
"
|
|
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
|
-
# ``
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
637
|
+
strict_refs.append(on_field)
|
|
606
638
|
elif isinstance(on_field, list):
|
|
607
|
-
|
|
639
|
+
strict_refs += [f for f in on_field if isinstance(f, str)]
|
|
608
640
|
|
|
609
641
|
gb = data.get('group_by') or {}
|
|
610
|
-
|
|
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
|
-
|
|
645
|
+
strict_refs.append(date_group['field'])
|
|
614
646
|
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
655
|
+
filter_refs += list(cond.keys())
|
|
624
656
|
else:
|
|
625
|
-
|
|
657
|
+
filter_refs.append(key)
|
|
626
658
|
period = filter_by.get('period') or {}
|
|
627
659
|
if isinstance(period.get('field'), str):
|
|
628
|
-
|
|
660
|
+
strict_refs.append(period['field'])
|
|
661
|
+
|
|
662
|
+
_collect_condition_fields(data.get('conditions'), strict_refs)
|
|
629
663
|
|
|
630
|
-
|
|
664
|
+
return strict_refs, filter_refs, order_refs
|
|
631
665
|
|
|
632
|
-
|
|
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 `
|
|
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
|
-
# ``
|
|
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 ``
|
|
3
|
+
The project defines its clients under ``EASYAPI['OAUTH_CLIENTS']``::
|
|
4
4
|
|
|
5
|
-
|
|
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 ``
|
|
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 ``
|
|
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
|
-
# ``
|
|
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 ``
|
|
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
|
-
``
|
|
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 ``
|
|
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 ``
|
|
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 ``
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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',
|
|
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 ``
|
|
1
|
+
"""Read easyapi configuration from the project's ``EASYAPI`` dict.
|
|
2
2
|
|
|
3
|
-
A single dict named ``
|
|
3
|
+
A single dict named ``EASYAPI`` in ``settings.py`` collects every
|
|
4
4
|
easyapi-owned setting (DRF/Celery-style namespace)::
|
|
5
5
|
|
|
6
|
-
|
|
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
|
|
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, '
|
|
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 ``
|
|
164
|
+
match time. Set to ``[]`` in ``EASYAPI`` to disable.
|
|
133
165
|
|
|
134
|
-
**Override semantics:** ``
|
|
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
|
-
|
|
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.
|
|
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 (`
|
|
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 `
|
|
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
|
-
-
|
|
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 `
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/resources/device_authorization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/templates/zeromcp_oauth/consent.html
RENAMED
|
File without changes
|
{easyapi_django-1.3.1 → easyapi_django-1.3.2}/easyapi/oauth/templates/zeromcp_oauth/device.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|