Skip to main content

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

MethodPathPermission
GET/AuditList
POST/filterAuditList
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
ParameterDefault
take10
skip0
sortFieldLoggedAt
sortDescendingfalse
200 OK
{ "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.

POST /filter?take=25&sortDescending=true
[
{ "field": "EntryType", "operatorType": "eq", "value": "Variable" },
{ "field": "EntityName", "operatorType": "contains", "value": "Database" }
]
operatorType
eqExact match
containsSubstring

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 entriesGlobal entries
Appear in the listAuditListAuditGlobalList
Before/after state visibleAuditReadAuditGlobalRead

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

POST /
{
"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. organizationId and loggedBy are taken from the token, never from the body.
  • Without a JWT — accepted only if entryType appears in the service's Audit:AuthNotRequired allow-list. Otherwise 401.

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.