> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# 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

| Option                                                  | Why not                                                                                                       |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Keep `admin.user` with a non-Admin integration user     | Still full administration (finding 1)                                                                         |
| `admin.ticket` instead of `admin.user`                  | Ticket settings only; no ticket access (finding 2)                                                            |
| One token group shared with the front desk              | The front desk receives every channel's tickets, also from non-Pharos users, and usually has an email address |
| A Pharos twin of every team group                       | Doubles Zammad's structure, queues and reports for every team                                                 |
| Other teams work Pharos tickets inside the Pharos group | Teams need tickets in their own group so the whole group sees them, and tickets move L2 → L3 → L2             |
| Linked internal tickets in other teams' groups          | Extra 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).

<details class="page-source"><summary>Page source</summary>

<a href="/source/docs/decisions/0063-zammad-integration-token.md.txt">Source snapshot</a> · Revision `f928834c8`.

</details>
