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)
admin.useris full Zammad administration. Acting on behalf of a user needs exactlyadmin.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 withadmin.usermay 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.- There is no narrower impersonation permission, and
admin.ticketis 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. X-On-Behalf-Ofis deprecated in favour of theFromheader and logs a warning on every request.- Zammad has no
/api/v1/permissionsendpoint; 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.agentanduser_preferences.access_token(only so the user can create its own token). The token carriesticket.agentonly, 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, openingread, replying (follow_up?) and closingchange, creatingcreate. Notoverview(agent screen only), notfull(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_idcondition (answered from the database, no Elasticsearch lag), checks the customer on every ticket it reads, and posts messages withsender: Customerandorigin_by_idof the linked customer (Zammad then shows the customer as the author). The route table (routes.go) requirescustomer_id,origin_by_idand 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.usertoken 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 oninternalis 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
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
pharostag) 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.usertoken has no group access. Deploy both at once, then revoke the old token. Development credential tooling refuses a changedZAMMAD_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.envnames 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.pyapplies the profile and with--checkreports 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.