For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /reference/docs/decisions/0063-zammad-integration-token.md.

0063 — Zammad integration: an agent-only token, no administrator permission

Status: Accepted. Date: 9 October 2026. Amended 13 October 2026 (see the end). A trial against the test Zammad (7.2.1) passed every check below before the adaptor was reworked; its checks are now part of contract_test.go.

Beto calls Zammad with one API token held in its pod. Iteration 1 (decision 0062) used a token with admin.user and ticket.agent and the X-On-Behalf-Of header, so Zammad acted as the customer and kept customers apart. AGENTS.md says administrator credentials never enter runtime containers; no decision covered this.

What we found (Zammad 7.2.1 source, 9 October 2026)

  1. admin.user is full Zammad administration. Acting on behalf of a user needs exactly admin.user (SessionsPolicy#impersonate?); the target may be any user, Admins included (find_on_behalf_user), and the token's own permission limits are dropped while impersonating (UserInfo.current_token = nil). A caller with admin.user may also set any role or group on any user (Service::User::FilterPermissionAssignments). A stolen token could therefore act as an Admin or make any user one. A non-Admin integration user, our first idea, does not change this.
  2. There is no narrower impersonation permission, and admin.ticket is not one: it means "manage ticket settings" (number format, notifications) and gives no ticket access. In Zammad, ticket access comes only from group access or from being the ticket's customer (TicketPolicy); even Admins see no tickets without group access.
  3. X-On-Behalf-Of is deprecated in favour of the From header and logs a warning on every request.
  4. Zammad has no /api/v1/permissions endpoint; roles take permission and group names directly. (The first setup script failed on this.)

Decision

The token acts as an agent with group access and holds no administrator permission. It cannot impersonate, change roles or groups, edit staff accounts (UserPolicy#update? lets agents change customers only), touch settings, or send email from a group without an email address.

  • Integration user and role. A dedicated user holding one role with ticket.agent and user_preferences.access_token (only so the user can create its own token). The token carries ticket.agent only, expires after about 100 days and is rotated every 90 days, and at once on a suspected leak.
  • Group access on the role, which is the list of Pharos groups. New tickets are created only in the intake group, Pharos Support (read, create, change), worked by the front desk. Every group a Pharos ticket may move to (L2, L3, GRNET, …) gets read and change. Access levels follow what Zammad checks: listing uses TicketPolicy::ReadScope, opening read, replying (follow_up?) and closing change, creating create. Not overview (agent screen only), not full (it adds delete).
  • The group list lives in Zammad, not in Pharos. Beto knows only the intake group (ZAMMAD_GROUP). Adding an escalation group is a Zammad administrator granting the role access: no Pharos change, but each addition is reviewed, because the token can read every ticket in every listed group.
  • Beto keeps customers apart. Zammad no longer does it for us. Beto lists a customer's tickets with a customer_id condition (answered from the database, no Elasticsearch lag), checks the customer on every ticket it reads, and posts messages with sender: Customer and origin_by_id of the linked customer (Zammad then shows the customer as the author). The route table (routes.go) requires customer_id, origin_by_id and the list condition to equal the linked customer, never a browser value.
  • Internal notes now arrive in Beto's normal responses. Before, Beto read as the customer and Zammad removed them, so Beto's filter was a second guard. (A stolen admin.user token could also have read them, by impersonating an agent, but no normal request did.) As an agent, Beto receives them in every conversation, so its filter on internal is the only thing hiding them from customers; the contract test always checks that they arrive flagged.
  • Every request is on an allow-list ("Allowed requests" in CONTRACT.md), refused before the network otherwise; the architecture check rejects Zammad requests built outside the checked do.

Options we rejected

OptionWhy not
Keep admin.user with a non-Admin integration userStill full administration (finding 1)
admin.ticket instead of admin.userTicket settings only; no ticket access (finding 2)
One token group shared with the front deskThe front desk receives every channel's tickets, also from non-Pharos users, and usually has an email address
A Pharos twin of every team groupDoubles Zammad's structure, queues and reports for every team
Other teams work Pharos tickets inside the Pharos groupTeams need tickets in their own group so the whole group sees them, and tickets move L2 → L3 → L2
Linked internal tickets in other teams' groupsExtra relay work; kept as an option for a team that cannot be given a Pharos-visible group

Consequences

  • A ticket moved to a group outside the list disappears for Beto (opening answers 403 and it leaves the list). Without stored ticket state Beto cannot tell this from a ticket that was never the user's, so it answers "not found" until the iteration 2 ticket read model can say "being handled outside support". Mark Pharos tickets (for example a pharos tag) so a Core Workflow can offer only listed groups when agents move them; whether Core Workflows also bind API calls is unverified.
  • A stolen token can read and change tickets in the listed groups, internal notes included, and create or edit customers, but not administer Zammad.
  • Escalation groups with an outgoing email address would let a stolen token send email from them; record that per group when the real list is known.
  • Agents may reply to closed tickets (reopening them); Beto still refuses replies on closed tickets (decision 0062).
  • The reworked adaptor and the token change together: the agent token cannot impersonate, and the old admin.user token has no group access. Deploy both at once, then revoke the old token. Development credential tooling refuses a changed ZAMMAD_TOKEN, so the dev-box swap is manual for now.

Trial (9 October 2026)

Against the test Zammad with groups Pharos Support, L2, L3 and Other team, the agent token created, claimed and searched customers; was refused impersonation, the Admin role and staff accounts; created tickets only in Pharos Support, credited to the customer by name; listed them at once without the search index; kept them readable and open to replies and closing through L2 → L3 → L2; saw internal notes flagged internal; could not move a ticket out; lost sight of it (403) once staff moved it to Other team; and could not send email from a group without an address. The first run failed only because Zammad's Elasticsearch was being killed for memory (1 GiB limit, 512 MB heap); with 2 GiB and a 1 GB heap the search passed. The reworked adaptor's contract test then passed against the same Zammad.

Open: the real group list and their email addresses (support lead); an audit log of Beto's calls and production delivery of the token (production identity work).

Amendment (13 October 2026): the team's Zammad

The development Zammad's database was replaced by a copy of the team's production Zammad. Its groups, states and required fields are the real support teams', so the decision changes in three places.

  • Intake is Pharos SA – Front Desk. This reverses the rejected option "one token group shared with the front desk". The team files every channel's tickets there, and its trigger moves every Resolved ticket back there, so Beto needs change access to Front Desk whatever the intake group. Follow groups: Pharos SA – IT and the NCSRD, Athena, GRNET and NTUA partner groups (read, change). Accepted: the token reads every Front Desk ticket, other customers' included (Beto still keeps customers apart); every team group sends email as the service desk, so a stolen token could send email as it, although Beto's route table allows web messages only. Removing that needs a Pharos group without an email address and a change to the team's trigger.
  • The group list is also written in Pharos. The profile gitops/manifests/pharos/base/beto/zammad.env names the intake and follow groups, the ticket type Zammad requires, the close state and the closed states. Zammad still enforces the groups as the role's group access; devops/zammad/setup-integration.py applies the profile and with --check reports any difference. Adding a group is a reviewed change of the profile, then the setup. This replaces "the group list lives in Zammad, not in Pharos".
  • States and fields by exact name. Zammad looks states up case-sensitively and applies required fields to API creates. Beto closes into the profile's close state, shows only the profile's closed states as closed (Resolved stays open) and files with the profile's ticket type; the route table pins both values.

Verified on 13 October 2026: the contract test against the migrated Zammad, and a browser check on dev-iasonas (filing into Front Desk, agent and partner replies live, Resolved open, a user's close and a cancellation shown closed).

Page source

Source snapshot · Revision f928834c8.