179 lines
9.4 KiB
Markdown
179 lines
9.4 KiB
Markdown
# 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](conventions.md#pagination) for `limit` and `offset`, and [Errors](errors.md) 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`:
|
|
|
|
```json
|
|
{"detail": "You must be an organization administrator to perform this action."}
|
|
```
|
|
|
|
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](tokens-and-api-keys.md#what-no-token-may-do-whatever-its-scopes)). 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](#failed-sign-ins).
|
|
|
|
```json
|
|
{"event_type": ["Unknown event type."]}
|
|
```
|
|
|
|
## 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:
|
|
|
|
```json
|
|
{
|
|
"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`.** An `ORG_SWITCH` row's text names the org the user switched from, which is
|
|
another tenant's name.
|
|
- **`details` holds 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`): a `RECORD_MERGED` row adds `entity`
|
|
(`lead`, `contact` or `account`), `kept_id`, `kept_name`, `merged_id` and `merged_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_CREATED` and
|
|
`API_TOKEN_REVOKED` rows add `token_id`, `token_prefix`, `token_name`, `scopes`, `owner_id` and
|
|
`owner_name`. See [Credential changes](#credential-changes). Allowed on those two events only.
|
|
- **A module change also carries its keys** (`EVENT_DETAILS`): a `MODULES_CHANGED` row adds
|
|
`switched_off` and `switched_on` (lists of module keys) and `reordered` (a boolean). See
|
|
[Module changes](#module-changes). Allowed on that event
|
|
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](modules.md).
|
|
|
|
| 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 |
|
|
|
|
## 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.
|