Signals and logging¶
The package doesn't ship an audit table or a webhook system. It tells you what happened — through Django signals and a logger — and you decide what to do with that.
Signals¶
All four are plain django.dispatch.Signals in
django_tenant_apikeys.signals, sent with your concrete key model as
sender:
| Signal | Sent when | Keyword arguments |
|---|---|---|
api_key_created |
generate_key() (so also rotate()) |
api_key |
api_key_revoked |
revoke(), once per actual revocation |
api_key, reason |
api_key_rotated |
rotate(), after its transaction |
old_key, new_key, grace_period (None = immediate) |
api_key_rejected |
authentication refuses a key, in DRF and Ninja | reason, api_key (or None), client_ip |
api_key_rate_limited |
a key's rate_limit is exceeded, in DRF and Ninja |
api_key, result (a RateLimitResult) |
from django.dispatch import receiver
from django_tenant_apikeys.signals import api_key_rate_limited, api_key_rejected, api_key_revoked
@receiver(api_key_revoked)
def audit_revocation(sender, api_key, reason, **kwargs):
AuditEvent.objects.create(kind="key_revoked", key_prefix=api_key.prefix, detail=reason)
@receiver(api_key_rejected)
def count_rejections(sender, reason, api_key, client_ip, **kwargs):
metrics.incr("api_key.rejected", tags={"reason": reason})
@receiver(api_key_rate_limited)
def count_throttled(sender, api_key, result, **kwargs):
metrics.incr("api_key.rate_limited", tags={"key_prefix": api_key.prefix})
reason is a RejectionReason value: malformed, unknown_key,
bad_secret, inactive, expired, environment, ip_not_allowed,
tenant_not_allowed. It is precise here even though the client is never
told which of the first three it was.
There's no separate "key expired" event. An expired key is rejected the
same way any other invalid key is — api_key_rejected with
reason="expired" — because that's the only place expiry is actually
detected: at the moment a key is presented for authentication, not by a
background job scanning for keys whose expires_at has passed. If you need
to know about expiry before anyone presents the key again, query
expires_at yourself (e.g. a periodic manage.py command over
YourKeyModel.objects.filter(expires_at__lt=timezone.now(), is_active=True));
that's an application-specific report, not something this library runs for
you.
api_key_rate_limited is sent once per request that's actually denied —
never for a request within the limit, and never for a key with no
rate_limit set, so it can't turn a hot endpoint into a signal-dispatch
cost on every call.
Things to know:
- Signals are synchronous. Inside an
atomic()block that later rolls back, a receiver has already run — usetransaction.on_commitin the receiver if that matters. - A receiver that raises never breaks the operation: the error is logged on
the
django_tenant_apikeyslogger and swallowed. (An audit hook that's down shouldn't turn every failed login into a 500.) api_key_rejectedfires for unauthenticated traffic, so keep receivers cheap. There's deliberately no signal for successful authentication — that's your hot path; uselast_used_at/last_used_ip.
Logging¶
Logger name: django_tenant_apikeys. Every rejection is one WARNING:
- The
prefixis logged only once it matched a real key. For a malformed or unknown key the "prefix" is attacker-supplied text (and for a key pasted without its., could be the entire secret), so it's replaced with-. - The secret is never logged, and neither is any part of a key that didn't match a row.
client_ipis repr-escaped and truncated, so a hostile header value can't forge log lines.
A key being rate limited is also one WARNING:
And, separately, the rate-limit cache itself being unreachable (a dropped
Redis connection, a timeout) is its own WARNING — not a rejection or a
rate-limit event, since no limit was actually checked. See
Rate limiting: when the cache is unreachable.