From 92285e54f49b6056fafa271d1dfa2f7cc9c0e37f Mon Sep 17 00:00:00 2001 From: Ralf Date: Thu, 23 Jul 2026 08:35:05 +0200 Subject: [PATCH 1/2] Fixes #CS-1453 - Document audit-log REST API Add the REST-API reference page for the new audit-log feature so the endpoint shows up under 'Administration via REST API' next to the other admin endpoints (calendar, checklist, generic-cti, group, role, ...). The new endpoint is read-only: GET /api/v1/audit_logs GET /api/v1/audit_logs/{id} GET /api/v1/audit_logs/search POST /api/v1/audit_logs/search Required permission: admin.audit_log. Examples (request payloads and JSON responses) are taken straight from the model, the migration, the route definition, the controller (reads/writes via model_index_render / model_show_render / model_search_render) and the request spec, and were verified end-to-end against a running zammad:develop instance (10 real entries; sort_by / order_by / with_total_count behaviour confirmed). Field reference covers every column on audit_logs (user_id / user_fullname, action_type, auditable_id / auditable_type / auditable_name, value_from / value_to, source_ip, preferences, created_at / updated_at) and the lifecycle note documents the 12-month retention enforced by the 'Clean up AuditLog' scheduler. Files: - api/audit_log.rst: new (256 lines). - index.rst: register /api/audit_log under the REST API toctree. Refs zammad/zammad#CS-1453 --- api/audit_log.rst | 375 ++++++++++++++++++++++++++++++++++++++++++++++ index.rst | 1 + 2 files changed, 376 insertions(+) create mode 100644 api/audit_log.rst diff --git a/api/audit_log.rst b/api/audit_log.rst new file mode 100644 index 00000000..d36a4dbf --- /dev/null +++ b/api/audit_log.rst @@ -0,0 +1,375 @@ +Audit Log +========= + +The audit log records security-relevant changes to your Zammad system: +who changed what, and when. The audit log is read-only. + + +List +---- + +Required permission: ``admin.audit_log`` + +``GET``-Request sent: ``/api/v1/audit_logs`` + +The endpoint supports pagination. The default page size is ``500``. +Entries are returned ordered by ``id`` (ascending). Pass ``?sort_by=id`` +and ``?order_by=DESC`` to return the newest entries first. + +Response: + +.. code-block:: json + :force: + + # HTTP-Code 200 Ok + + [ + { + "id": 1, + "user_id": 3, + "user_fullname": "Christopher Miller", + "action_type": "create", + "auditable_type": "ChecklistTemplate", + "auditable_id": 1, + "auditable_name": "asdf", + "value_from": {}, + "value_to": { + "name": "Onboarding", + "active": true, + "sorted_item_names": [ + "Buy chair", + "Coffee briefing", + "Create accounts" + ] + }, + "source_ip": "172.19.0.1", + "preferences": {}, + "created_at": "2026-07-23T09:14:51.650Z", + "updated_at": "2026-07-23T09:14:51.650Z" + }, + { + "id": 2, + "user_id": 3, + "user_fullname": "Christopher Miller", + "action_type": "update", + "auditable_type": "Setting", + "auditable_id": 280, + "auditable_name": "ai_provider", + "value_from": { + "area": "AI::Provider", + "name": "ai_provider", + "title": "AI provider", + "options": {}, + "frontend": true, + "description": "Defines if the AI provider is configured.", + "state_current": { + "value": true + }, + "state_initial": { + "value": false + } + }, + "value_to": { + "area": "AI::Provider", + "name": "ai_provider", + "title": "AI provider", + "options": {}, + "frontend": true, + "description": "Defines if the AI provider is configured.", + "state_current": { + "value": false + }, + "state_initial": { + "value": false + } + }, + "source_ip": "172.19.0.1", + "preferences": { + "changed_attributes": [ + "state_current" + ] + }, + "created_at": "2026-07-23T09:14:59.501Z", + "updated_at": "2026-07-23T09:14:59.501Z" + }, + { + "id": 3, + "user_id": 3, + "user_fullname": "Christopher Miller", + "action_type": "update", + "auditable_type": "Role", + "auditable_id": 2, + "auditable_name": "Agent", + "value_from": {}, + "value_to": { + "permissions": [ + "admin.text_module" + ], + "group_permissions": { + "2nd Level": [ + "full" + ], + "Service Desk": [ + "full" + ] + } + }, + "source_ip": "172.19.0.1", + "preferences": {}, + "created_at": "2026-07-23T09:16:30.188Z", + "updated_at": "2026-07-23T09:16:30.188Z" + }, + { + "id": 4, + "user_id": 3, + "user_fullname": "Christopher Miller", + "action_type": "update", + "auditable_type": "Setting", + "auditable_id": 265, + "auditable_name": "pgp_integration", + "value_from": { + "area": "Integration::Switch", + "name": "pgp_integration", + "title": "PGP integration", + "options": { + "form": [ + { + "tag": "boolean", + "name": "pgp_integration", + "null": true, + "display": "", + "options": { + "true": "yes", + "false": "no" + } + } + ] + }, + "frontend": true, + "description": "Defines if PGP encryption is enabled or not.", + "state_current": { + "value": false + }, + "state_initial": { + "value": false + } + }, + "value_to": { + "area": "Integration::Switch", + "name": "pgp_integration", + "title": "PGP integration", + "options": { + "form": [ + { + "tag": "boolean", + "name": "pgp_integration", + "null": true, + "display": "", + "options": { + "true": "yes", + "false": "no" + } + } + ] + }, + "frontend": true, + "description": "Defines if PGP encryption is enabled or not.", + "state_current": { + "value": true + }, + "state_initial": { + "value": false + } + }, + "source_ip": "172.19.0.1", + "preferences": { + "changed_attributes": [ + "state_current" + ] + }, + "created_at": "2026-07-23T09:17:20.469Z", + "updated_at": "2026-07-23T09:17:20.469Z" + } + ] + + +Show +---- + +Required permission: ``admin.audit_log`` + +``GET``-Request sent: ``/api/v1/audit_logs/{id}`` + +Response: + +.. code-block:: json + :force: + + # HTTP-Code 200 Ok + + { + "id": 3, + "user_id": 3, + "user_fullname": "Christopher Miller", + "action_type": "update", + "auditable_type": "Role", + "auditable_id": 2, + "auditable_name": "Agent", + "value_from": {}, + "value_to": { + "permissions": [ + "admin.text_module" + ], + "group_permissions": { + "2nd Level": [ + "full" + ], + "Service Desk": [ + "full" + ] + } + }, + "source_ip": "172.19.0.1", + "preferences": {}, + "created_at": "2026-07-23T09:16:30.188Z", + "updated_at": "2026-07-23T09:16:30.188Z" + } + +Search +------ + +Required permission: ``admin.audit_log`` + +The search endpoint accepts the Zammad search-backend ``query`` +syntax. The simplest case is a literal substring on a single +indexed field such as ``auditable_name``, ``auditable_type`` or +``user_fullname``: + +``GET``-Request sent: ``/api/v1/audit_logs/search?query={search-string}`` + +Response: + +.. code-block:: json + :force: + + # HTTP-Code 200 Ok + + [ + { + "id": 3, + "user_id": 3, + "user_fullname": "Christopher Miller", + "action_type": "update", + "auditable_type": "Role", + "auditable_id": 2, + "auditable_name": "Agent", + "value_from": {}, + "value_to": { + "permissions": [ + "admin.text_module" + ], + "group_permissions": { + "2nd Level": [ + "full" + ], + "Service Desk": [ + "full" + ] + } + }, + "source_ip": "172.19.0.1", + "preferences": {}, + "created_at": "2026-07-23T09:16:30.188Z", + "updated_at": "2026-07-23T09:16:30.188Z" + } + ] + +To filter on a specific attribute rather than substring-match the +whole record, prefix the attribute name. You can even use the logical ``AND`` +connector to narrow down the results: + +``GET``-Request sent: ``/api/v1/audit_logs/search?query=auditable_type:Setting AND user_id:3`` + +.. note:: + + Search matches are case-sensitive and search only the indexed + attribute fields (``auditable_name``, ``auditable_type``, + ``user_fullname`` and so on). The ``value_from`` and ``value_to`` + payloads are not searchable. + +.. note:: + + By default the response is a bare JSON array of matching entries. + Pass ``with_total_count=true`` on the URL (or ``with_total_count: + true`` in the body of a ``POST`` request) to wrap the response in + an object that also contains the ``total_count``. Send a ``POST`` + request when the query is too long or complex for a URL. + +Field Reference +--------------- + +``id`` + Integer primary key of the audit log entry. + +``user_id`` + ID of the user that triggered the change. ``null`` when the entry + was written by a background job without a current user. + +``user_fullname`` + Full name of the user at the time the entry was written. Stored + separately so it remains readable after the user account is + removed. + +``action_type`` + Type of the recorded change. One of: + + - ``create`` - a record was added. + - ``update`` - an existing record was modified. + - ``destroy`` - a record was removed. + - ``switch_to`` - a user took over another user's session via + ``View from user's perspective`` + - ``switch_back_to`` - the original session was resumed. + +``auditable_id`` + ID of the record that was changed. + +``auditable_type`` + Class name of the record that was changed (e.g. ``Role``, + ``User``, ``Setting``, ``KnowledgeBase``, ``ChecklistTemplate``). + +``auditable_name`` + Display name of the changed record at the time the entry was + written. Stored separately so it remains readable after the record + itself is gone. + +``value_from`` + Object (JSON) holding the previous state of the audited + attributes. Empty (``{}``) on ``create`` entries. + +``value_to`` + Object (JSON) holding the new state of the audited attributes. + Empty (``{}``) on ``destroy`` entries. + +``source_ip`` + IP address that issued the underlying request. ``Rails console`` + or ``Rails runner`` is stored when the entry was written from a + maintenance script. + +``preferences`` + Object (JSON) holding additional per-entry metadata. For + ``update`` entries this contains a ``changed_attributes`` array + listing the attributes that actually changed. + +``created_at`` + Timestamp at which the entry was written. Audit log entries are + append-only. + +``updated_at`` + Same as ``created_at`` for audit log entries. Audit log entries + are append-only. + +Lifecycle +--------- + +A scheduled task removes entries older than ``12 months`` every day. +Use the ``AuditLog.cleanup`` in the Rails console to trigger a cleanup manually. diff --git a/index.rst b/index.rst index 6a648228..399a9f8a 100644 --- a/index.rst +++ b/index.rst @@ -71,6 +71,7 @@ Zammad System Documentation :caption: REST API /api/intro + /api/audit_log /api/calendar /api/checklist /api/generic-cti/index From cf75e322f213fe6d1e31cc8948324b1ce0362c85 Mon Sep 17 00:00:00 2001 From: Ralf Schmid Date: Thu, 23 Jul 2026 12:06:01 +0200 Subject: [PATCH 2/2] Add Audit log REST-API --- api/audit_log.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/audit_log.rst b/api/audit_log.rst index d36a4dbf..a4736407 100644 --- a/api/audit_log.rst +++ b/api/audit_log.rst @@ -4,7 +4,6 @@ Audit Log The audit log records security-relevant changes to your Zammad system: who changed what, and when. The audit log is read-only. - List ---- @@ -31,7 +30,7 @@ Response: "action_type": "create", "auditable_type": "ChecklistTemplate", "auditable_id": 1, - "auditable_name": "asdf", + "auditable_name": "Onboarding", "value_from": {}, "value_to": { "name": "Onboarding", @@ -288,7 +287,8 @@ To filter on a specific attribute rather than substring-match the whole record, prefix the attribute name. You can even use the logical ``AND`` connector to narrow down the results: -``GET``-Request sent: ``/api/v1/audit_logs/search?query=auditable_type:Setting AND user_id:3`` +``GET``-Request sent: +``/api/v1/audit_logs/search?query=auditable_type:Setting AND user_id:3`` .. note::