# 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).