diff --git a/README.md b/README.md index b71a4a4..04a80e5 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ API_USAGE = { "RETENTION_DAYS": 90, "TRACK_CONSUMERS": False, # opt-in; stores hashed callers only "CONSUMER_SALT": "a-rotatable-secret", + "TRACK_WOULD_DENY": False, # opt-in; counts shadow-mode denies as "403*" } ``` diff --git a/src/django_api_usage/buffers.py b/src/django_api_usage/buffers.py index 592e31d..d84d54a 100644 --- a/src/django_api_usage/buffers.py +++ b/src/django_api_usage/buffers.py @@ -47,15 +47,18 @@ def status_class(status_code): return f"{int(status_code) // 100}xx" -def record_hit(dimensions, status_code, consumer=None): +def record_hit(dimensions, status_code=None, consumer=None, as_class=None): """Record a single hit. - ``dimensions`` is a mapping with the keys in :data:`_FIELDS`. + ``dimensions`` is a mapping with the keys in :data:`_FIELDS`. The status class + is derived from ``status_code`` (``204`` -> ``2xx``) unless ``as_class`` is + given, which is how shadow-mode "would deny" hits are recorded (``403*``). """ + klass = as_class or status_class(status_code) if api_settings.BUFFER_BACKEND == "db": - _write_to_db(dimensions, status_class(status_code), 1) + _write_to_db(dimensions, klass, 1) else: - _buffer(dimensions, status_class(status_code)) + _buffer(dimensions, klass) if consumer and api_settings.TRACK_CONSUMERS: _touch_consumer(consumer) diff --git a/src/django_api_usage/conf.py b/src/django_api_usage/conf.py index 841a065..bdfe797 100644 --- a/src/django_api_usage/conf.py +++ b/src/django_api_usage/conf.py @@ -27,6 +27,10 @@ "RETENTION_DAYS": 90, # Opt-in consumer attribution. Only salted hashes are stored; see the docs. "TRACK_CONSUMERS": False, + # Opt-in: also count the requests that shadow mode *would* have denied, as a + # synthetic "403*" status class. Shows who already hits the endpoints you are + # about to protect, without blocking anybody. + "TRACK_WOULD_DENY": False, # Salt used to hash consumer identifiers. Keep it secret and rotatable. "CONSUMER_SALT": "", # Resolvers (dotted path or callable). diff --git a/src/django_api_usage/middleware.py b/src/django_api_usage/middleware.py index 4d2ac94..f66cd46 100644 --- a/src/django_api_usage/middleware.py +++ b/src/django_api_usage/middleware.py @@ -8,6 +8,9 @@ logger = logging.getLogger("django_api_usage") +#: Synthetic status class for requests that shadow mode would have denied. +WOULD_DENY_STATUS_CLASS = "403*" + class ApiUsageMiddleware: """Record one aggregated hit per request and expose the shadow-mode header. @@ -42,11 +45,25 @@ def _record(self, request, response): if api_settings.TRACK_CONSUMERS: consumer = api_settings.CONSUMER_RESOLVER(request) record_hit(dimensions, response.status_code, consumer) + self._record_would_deny(request, dimensions, consumer) except Exception: logger.exception("django-api-usage: could not record request") if not api_settings.FAIL_OPEN: raise + @staticmethod + def _record_would_deny(request, dimensions, consumer): + """Count a shadow-mode deny as a synthetic ``403*`` hit (opt-in). + + ``request._api_usage_would_deny`` is set by the permission classes + (:class:`django_api_usage.drf.HasAPIScope` and friends) when enforcement is + off; the same hook feeds ``X-Api-Would-Deny``. + """ + if not api_settings.TRACK_WOULD_DENY: + return + if getattr(request, "_api_usage_would_deny", None): + record_hit(dimensions, consumer=consumer, as_class=WOULD_DENY_STATUS_CLASS) + @staticmethod def _add_shadow_header(request, response): would_deny = getattr(request, "_api_usage_would_deny", None) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 4b51642..8112cb8 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,4 +1,7 @@ +from django.test import override_settings + from django_api_usage.buffers import flush +from django_api_usage.conf import api_settings from django_api_usage.models import Consumer, Endpoint, EndpointStat from .base import UsageTestCase @@ -44,3 +47,27 @@ def test_raw_ip_is_not_stored(self): self.client.get("/ping/", REMOTE_ADDR="203.0.113.9") consumer = Consumer.objects.get() self.assertNotIn("203.0.113.9", consumer.ref_hash) + + +class WouldDenyTrackingTest(UsageTestCase): + """Shadow-mode denies can be counted as a synthetic ``403*`` status class.""" + + def test_shadow_denies_are_not_counted_by_default(self): + self.client.get("/shadow/") + flush() + + self.assertEqual(EndpointStat.objects.count(), 1) + self.assertEqual(EndpointStat.objects.get().status_class, "2xx") + + def test_shadow_denies_are_counted_when_enabled(self): + current = dict(api_settings.user_settings) + self.addCleanup(api_settings.reload) + + with override_settings(API_USAGE={**current, "TRACK_WOULD_DENY": True}): + api_settings.reload() + self.client.get("/shadow/") + flush() + + self.assertEqual(EndpointStat.objects.get(status_class="403*").count, 1) + # The real hit is still counted, with its own status class. + self.assertEqual(EndpointStat.objects.get(status_class="2xx").count, 1) diff --git a/tests/urls.py b/tests/urls.py index bc924c1..c727d2f 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -11,8 +11,15 @@ def boom(request): return HttpResponse("nope", status=500) +def shadow(request): + """Simulate a permission in shadow mode that would deny the call.""" + request._api_usage_would_deny = {"content:write"} + return HttpResponse("ok") + + urlpatterns = [ path("admin/", admin.site.urls), path("ping/", ping, name="ping"), path("boom/", boom, name="boom"), + path("shadow/", shadow, name="shadow"), ]