Audit API
Aspire name api-audit. An append-only record of what changed, who changed it, and what it looked like before and
after.
Every other service writes here through a shared IAuditHandler, over HTTP.
Endpoints
| Method | Path | Permission |
|---|---|---|
GET | / | AuditList |
POST | /filter | AuditList |
GET | /{id} | AuditRead (→ AuditList) |
POST | / | Anonymous, with its own rule |
DELETE | /{id} | AuditDelete |
An entry
{
"id": 412,
"organizationId": "05cc0c65-…",
"loggedAt": "2026-08-14T09:14:22Z",
"loggedBy": "9c1e7a4d-…",
"version": 2,
"entryType": "Variable",
"action": "Update",
"entityName": "DatabaseConnection",
"entityId": "1",
"beforeState": "{\"name\":\"DatabaseConnection\",\"values\":[…]}",
"afterState": "{\"name\":\"DatabaseConnection\",\"values\":[…]}"
}
beforeState and afterState are JSON strings, serialised by the writing service. A create has no before
state; a delete has no after state.
Listing
GET /?take=25&skip=0&sortField=LoggedAt&sortDescending=true
| Parameter | Default |
|---|---|
take | 10 |
skip | 0 |
sortField | LoggedAt |
sortDescending | false |
{ "total": 1284, "items": [ … ] }
total is counted before paging, so a pager knows how many pages there are.
Filtering
POST /filter takes the same query parameters plus a body of conditions. Conditions are AND-ed.
[
{ "field": "EntryType", "operatorType": "eq", "value": "Variable" },
{ "field": "EntityName", "operatorType": "contains", "value": "Database" }
]
operatorType | |
|---|---|
eq | Exact match |
contains | Substring |
Values are compared as text, which is all either operator needs. A filter with no value narrows nothing. An
unrecognised field or operator is refused with 400.
It is a POST rather than a GET because a filter body carries more structure than a query string does comfortably. Both list endpoints funnel through the same code, so what is visible and how it is counted cannot drift between them.
Reading one
GET /{id} returns the entry with its before and after state — if the caller may read the state. If not, the
entry comes back with the state withheld rather than being refused, so a single page can list an entry and hide
what it changed.
Global entries
Some entries belong to no organization: the logins, logouts and token mints written before a JWT exists. They carry an empty organization ID, and reading them is a separate pair of permissions.
| Organizational entries | Global entries | |
|---|---|---|
| Appear in the list | AuditList | AuditGlobalList |
| Before/after state visible | AuditRead | AuditGlobalRead |
AuditGlobalList widens the list to include global entries alongside the caller's own — it does not replace
the organizational permission. Both global permissions are marked global, so handing them out needs
PermissionsAllGlobal.
Writing
{
"entryType": "Variable",
"action": "Update",
"entityId": "1",
"entityName": "DatabaseConnection",
"version": 2,
"beforeState": "{…}",
"afterState": "{…}"
}
This is the one endpoint that has to accept unauthenticated writes, because login and logout entries are written before there is a token. It keeps its own rule instead of a permission:
- With a JWT — accepted.
organizationIdandloggedByare taken from the token, never from the body. - Without a JWT — accepted only if
entryTypeappears in the service'sAudit:AuthNotRequiredallow-list. Otherwise401.
That allow-list is the security boundary for this endpoint. Keep it to the entry types that genuinely precede a token — see Configuration.
Where id is omitted, the service assigns the next ID for the organization. An entry written with an existing ID
becomes another version of it, which is how the log records successive changes to one entity.
Deleting
DELETE /{id} removes every version of the entry, not just one, and answers with what it removed:
{ "total": 3, "items": [ … ] }
Scoped to the caller's organization. AuditDelete is not a global permission, so it cannot reach global entries.
What gets audited
Every create, update and delete across the services, plus token mints. The one deliberate exception is import, which writes a single entry describing the whole operation with counts rather than one entry per entity — see the import endpoint.
See Audit entries for the entry types and actions in use.