Security audit log¶
GET /api/org/audit-log/ lets an org's admins read its security audit log: sign-ins, org switches,
refused requests, record merges, paused webhooks, API token and calendar feed changes and the like.
The view is
SecurityAuditLogListView (backend/common/views/audit_log_views.py), routed in
common/urls.py; the events are written by AuditLogger in common/audit_log.py. See
Conventions for limit and offset, and Errors for the
refusal shapes.
Who may read it¶
Org admins only (IsOrgAdmin: the ADMIN role, or a Django superuser's profile in the org). A
member is refused with 403:
It is read from a signed-in session and nothing else. /api/org/audit-log/ is on the credential
deny-list in common/scopes.py, beside /api/webhooks/ and the token routes, so a personal access
token or the organization API key is refused before the view runs, whatever scopes it carries (see
Tokens and API keys). A leaked
token cannot read what the org has noticed about it.
Only the caller's own org's rows are returned. The table has no row-level security policy on
purpose (its org is empty for events such as a failed sign-in), so the view's
org=request.profile.org filter is the barrier, and a row with no org never matches it.
Query parameters¶
| Parameter | Meaning |
|---|---|
event_type |
One event type, by value (LOGIN_SUCCESS, WEBHOOK_PAUSED, ...). An unknown value is a 400. |
include_token_refresh |
true to list TOKEN_REFRESH rows alongside everything else. Any other value, or none, leaves them out. |
actor |
The user id of whoever the event is about. Not a UUID is a 400. |
from, to |
Days, YYYY-MM-DD, inclusive, in the org's timezone. from after to is a 400. |
limit, offset |
Paging: 25 rows by default, at most 100. |
TOKEN_REFRESH rows (a signed-in client quietly renewing its session) are most of what the table
holds, so they are left out of the list, and out of count, unless the caller asks for them: with
include_token_refresh=true, or with event_type=TOKEN_REFRESH, which lists only those. They are
still recorded either way.
Rows come newest first. For example, GET /api/org/audit-log/?event_type=LOGIN_SUCCESS&from=2026-09-01
lists this month's sign-ins to the org. LOGIN_FAILURE is accepted as a filter but lists nothing:
see Failed sign-ins.
The response¶
The usual limit/offset envelope, plus the event types the log can hold, so a client can offer
them as a filter without a list of its own:
{
"count": 213,
"next": "https://api.example.com/api/org/audit-log/?limit=25&offset=25",
"previous": null,
"results": [
{
"id": "7d0e...",
"event_type": "WEBHOOK_PAUSED",
"event_label": "Webhook Paused",
"created_at": "2026-09-26T09:12:44.018+00:00",
"success": true,
"actor": {"id": "3b1f...", "name": "Dana Reyes", "email": "dana@example.com"},
"ip_address": null,
"user_agent": "",
"request_method": "",
"request_path": "",
"details": {"endpoint_id": "91ac...", "creator_id": "3b1f...", "pause_reason": "..."}
}
],
"event_types": [{"value": "LOGIN_SUCCESS", "label": "Login Success"}]
}
actor is null for an event with no user.
What each row returns is chosen field by field rather than copied from the table:
- No
description. AnORG_SWITCHrow's text names the org the user switched from, which is another tenant's name. detailsholds only known-safe keys (SAFE_DETAILS:action,resource,deleted_count,endpoint_id,creator_id,pause_reason,previous_creator_id,changed). Those are ids, counts and sentences the server wrote. Everything else in the stored metadata can hold what a caller supplied (the email a failed login tried, an API key prefix, free-text details of suspicious activity) and is left out.- A merge row also carries both records (
EVENT_DETAILS): aRECORD_MERGEDrow addsentity(lead,contactoraccount),kept_id,kept_name,merged_idandmerged_name. The merged record is deleted, so this is the only place its name survives. Those keys are allowed on that event only; the same key on any other row is left out. - An API token row also names the token (
EVENT_DETAILS):API_TOKEN_CREATEDandAPI_TOKEN_REVOKEDrows addtoken_id,token_prefix,token_name,scopes,owner_idandowner_name. See Credential changes. Allowed on those two events only. - A module change also carries its keys (
EVENT_DETAILS): aMODULES_CHANGEDrow addsswitched_offandswitched_on(lists of module keys) andreordered(a boolean). See Module changes. Allowed on that event only. - An app change names the app (
EVENT_DETAILS):APP_INSTALLED,APP_UPGRADEDandAPP_REMOVEDrows addapp_idandversion. See App changes. Allowed on those three events only. - Paths under
/api/public/are cut to that prefix. Some public links carry a token in the URL (a satisfaction-survey link, for one), and the log should not repeat it.
Credential changes¶
From django-crm 1.13.0 the two credentials a member can mint for themselves are audited, because each one keeps working until someone takes it away. Every row is written against the acting org, so the org's admins see it here, with the caller's client IP, user agent, method and path like any other row.
event_type |
Label | Written by | actor |
|---|---|---|---|
CALENDAR_FEED_ENABLED |
Calendar Feed Enabled | POST /api/profile/calendar-feed/ when the member had no feed |
the member |
CALENDAR_FEED_REGENERATED |
Calendar Feed Regenerated | POST /api/profile/calendar-feed/ when it replaced a feed (the old URL stops working) |
the member |
CALENDAR_FEED_DISABLED |
Calendar Feed Disabled | DELETE /api/profile/calendar-feed/ when a feed existed |
the member |
API_TOKEN_CREATED |
API Token Created | POST /api/profile/tokens/ |
the owner |
API_TOKEN_REVOKED |
API Token Revoked | DELETE /api/profile/tokens/<id>/ (the owner) or DELETE /api/org/tokens/<id>/ (an admin) |
whoever revoked it |
A calendar feed row carries no details. An API token row carries:
| Key | Value |
|---|---|
token_id |
The token's id |
token_prefix |
The display prefix the token list already shows (bcrm_pat_ and four characters) |
token_name |
The name its owner gave it |
scopes |
Its scopes; an empty list is a token with its owner's full access |
owner_id, owner_name |
The user whose token it is, so an admin's revoke of someone else's token names both people |
No row holds the feed URL, a raw token or a token's hash. A no-op writes nothing: disabling a feed
that is already off, or revoking a token that is already revoked (on either endpoint), still answers
200 but adds no row, so the log holds one revoke per token. A refused call (a 400 create, a 403
member on /api/org/tokens/, a 404 token that is not yours or not in your org) writes nothing
either. If the audit row cannot be written the action still succeeds; the failure goes to the
security.audit logger, as for every other event.
Module changes¶
PUT /api/org/modules/ writes a MODULES_CHANGED row (label Modules Changed) when an admin
switches a module off or on, or reorders the modules, with the admin as actor. A request that
changes nothing, or is refused, writes nothing. See Modules.
| Key | Value |
|---|---|
switched_off |
Keys of the modules this change switched off |
switched_on |
Keys of the modules this change switched on |
reordered |
true when the order changed |
App changes¶
Installing, updating and removing an industry app each write a row,
with the admin as actor. A refused call writes nothing.
event_type |
Label | Written by |
|---|---|---|
APP_INSTALLED |
App Installed | POST /api/apps/<id>/install/, and POST /api/packs/<id>/apply/ when it installs |
APP_UPGRADED |
App Updated | POST /api/apps/<id>/upgrade/ |
APP_REMOVED |
App Removed | POST /api/apps/<id>/remove/ |
| Key | Value |
|---|---|
app_id |
The app's id, such as real-estate |
version |
The app's version after the change |
Failed sign-ins¶
From django-crm 1.13.0 a refused staff sign-in writes a LOGIN_FAILURE row (AuditLogger.login_failure,
called from common/views/auth_views.py). The caller has no org yet, so the row has none, and this
endpoint never returns it: failed sign-ins are for the operator, read from the security_audit_log
table or the security.audit logger (security_audit.log), not from an org's viewer.
metadata holds the claimed email (cut to 254 characters, empty when the attempt named none) and a
reason; user is set when that email belongs to a user. The token or code that was tried is never
stored. The reasons:
reason |
When |
|---|---|
google_unreachable |
The Google token exchange could not be reached |
google_code_rejected |
Google refused the authorization code |
google_invalid_id_token |
The code flow returned no ID token, or one that would not parse |
google_invalid_token |
The phone's Google ID token failed verification |
google_no_email |
The Google token carried no email |
google_email_unverified |
Google does not vouch for the email |
account_disabled |
The user exists but is deactivated (Google or email sign-in) |
magic_link_invalid |
A sign-in link that is unknown, used or expired; the email is the link's own when it exists |
code_not_found |
No unused, unexpired sign-in code for that email |
code_wrong |
The code did not match |
code_attempts_exhausted |
The code did not match and that was the last attempt, so the code is spent |
Anonymous callers write these rows, so each client IP gets at most 20 per hour
(LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR in common/audit_log.py, counted in the default cache from the
first failure). Attempts past the cap are refused exactly as before; they leave no row and no log
line.