Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*"
}
```

Expand Down
11 changes: 7 additions & 4 deletions src/django_api_usage/buffers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions src/django_api_usage/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 17 additions & 0 deletions src/django_api_usage/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
7 changes: 7 additions & 0 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
Loading