Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ cx

# Public regression coverage for the locale runtime ships with the implementation.
!frontend/src/lib/stores/i18n-utils.spec.js
!frontend/src/lib/api/integrations.spec.js
!frontend/src/lib/features/items/zammadPanelContext.spec.js
!frontend/src/lib/features/items/ZammadLinkResolver.spec.js
!internal/repository/zammad_repository_test.go

# Output of the go coverage tool, specifically when used with LiteIDE
*.out
Expand Down
121 changes: 121 additions & 0 deletions docs/zammad-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Zammad integration

Windshift can create a Zammad ticket from an item and keep the remote ticket state visible on the item.
An administrator owns each connection and explicitly grants it to selected workspaces or to all workspaces.

## API and authentication

The routes in this integration use the application API prefix `/api` and the same authenticated session or bearer-token authentication as the rest of the application.
Administration routes require a system administrator.
Workspace and item routes require the caller to have view or edit permission for the referenced workspace or item.

Admin connection endpoints are `GET|POST /api/admin/zammad-connections`, `GET|PUT|DELETE /api/admin/zammad-connections/{id}`, and `POST /api/admin/zammad-connections/{id}/test`.
System-owned OAuth starts through the shared provider endpoint `POST /api/admin/integration-providers/{id}/oauth/start`.
They list, create, inspect, update, delete, test, and authorize connections without returning any token or client secret.
The OAuth callback uses the shared system-provider route `GET /api/integrations/oauth/system/zammad/callback`.
The explicit administrator retry is `POST /api/admin/zammad-ticket-links/{linkId}/retry-create`.
The administrator-only disaster-recovery action is `POST /api/admin/zammad-ticket-links/{linkId}/detach-local`.
`POST /api/admin/zammad-ticket-links/refresh` queues an asynchronous synchronization of every complete ticket link on enabled, authorized connections.
Concurrent system-wide refresh requests are coalesced into the already queued or running job.

Workspace and item routes are `GET /api/workspaces/{workspaceId}/zammad-connections`, `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/metadata`, and `GET /api/workspaces/{workspaceId}/zammad-connections/{id}/owners?group_id={groupId}`.
Ticket routes are `GET /api/items/{id}/zammad-links`, `GET /api/zammad-ticket-links/resolve/{correlationKey}`, `POST /api/items/{id}/zammad-tickets`, `POST /api/items/{id}/zammad-ticket-links`, `PUT /api/zammad-ticket-links/{linkId}`, `DELETE /api/zammad-ticket-links/{linkId}`, and `POST /api/zammad-ticket-links/{linkId}/refresh`.

Create-ticket requests contain `connection_id` and may contain `group_id`.
Link-existing requests contain `connection_id` and `ticket_number`.
Link updates accept optional `state_id`, `group_id`, and `owner_id`, with at least one required.
The create and link responses contain the link ID, ticket identity and URL when known, group and owner snapshot, synchronization state, last remote status, timestamps, and a sanitized error when applicable.

## OAuth callback setup

Register this exact public callback URL in the Zammad OAuth application, replacing only the hostname with the public HTTPS hostname of the Windshift installation:

`https://<public-hostname>/api/integrations/oauth/system/zammad/callback`

Do not append a slash, query string, or fragment.
The URL configured in Zammad must exactly match the URL generated by Windshift, including any configured public path prefix.
Create the connection with `auth_method: "oauth"`, the OAuth client ID, client secret, and a Zammad base URL without `/api/v1`.
Start OAuth from the admin endpoint and complete authorization in Zammad.
The callback intentionally does not require a session because its one-time, short-lived state is bound to the initiating administrator and atomically consumed.
Access and refresh tokens are stored encrypted and never returned by the API.

If the callback fails, the administration UI receives an error result and the connection remains unavailable until authorization succeeds.
Changing OAuth client configuration invalidates an in-flight attempt.

## Zammad service account and permissions

Use a dedicated active Zammad user with the `ticket.agent` role for the integration, rather than a human administrator account.
Give it `read`, `create`, and `change` group access only for the allowed group(s).
This lets Windshift read the required ticket metadata and owners, create tickets, and change state, group, and owner within that boundary.
Do not grant administration, user-management, group-management, delete-ticket, or unrestricted access to other groups.
Zammad role and group permissions must enforce the same boundary as `allowed_groups` in the Windshift connection.
The default group must be active and included in that allowed set.
Store every allowed group as an ID and name pair, for example `{"id":7,"name":"Support"}`.
Windshift persists this small catalog and does not call Zammad's administrator-only `GET /api/v1/groups` endpoint during ticket creation, linking, updating, or synchronization.
The legacy `allowed_group_ids` request field is accepted only as an upgrade bridge; new clients should send `allowed_groups`.
Because the least-privilege service account cannot list all groups, connection tests report the persisted group catalog as unverified and validate only the ticket-state endpoint used at runtime.
During an upgrade, all legacy group IDs remain authorized for existing links, but only a group whose stored name is known can be selected for new ticket creation.
An administrator must confirm and store the `ID:name` pair before creating tickets in a legacy non-default group.

Create an active text ticket object attribute named `windshift_item_key`, or configure another valid correlation field name.
Windshift writes a deterministic correlation key to this field and uses it to find an already-created ticket safely.
The field is the idempotency boundary for create retries and must be readable by the service agent.

For a permission-checked return link from Zammad to the current Windshift item, configure this link template on the same attribute:

```text
https://windshift.example/zammad/#{ticket.windshift_item_key}
```

Replace the origin and optional path prefix with the browser-visible Windshift base URL.
If the connection uses a different correlation field, replace `windshift_item_key` in the placeholder too.
The resolver accepts the correlation value whether Zammad leaves it literal or percent-encodes it in the generated URL.
The target keeps working after an item moves because Windshift resolves the stored correlation key to the current workspace and item.
Opening the link requires a Windshift session and `item.view` permission; missing and unauthorized targets are both reported as unavailable.

## Create, link, update, and unlink behavior

Creating a ticket first persists a pending link and searches Zammad by the correlation key.
If the ticket already exists, Windshift links it instead of creating a duplicate.
The item title and description are sent to Zammad, with the initial article created as an internal note.
Ticket titles are limited to 200 characters and descriptions to 20,000 characters before transmission.
After a successful create, the link becomes `linked` and the remote ticket snapshot is stored.

If a create request times out, returns a rate-limit or server error, or its response is otherwise ambiguous, the link becomes `creation_uncertain`.
Automatic retries are search-only and never issue another create request while the outcome is unknown.
An administrator must verify in Zammad whether the ticket exists and call the admin retry endpoint explicitly only if it does not.
That retry searches by correlation key before permitting a new create.

Linking an existing ticket verifies that it exists, belongs to a configured allowed group, and is not already associated with another correlation key.
Updating a link sends only the requested state, group, and owner changes to Zammad.
The selected group must be in the persisted allowlist, the selected state must be active, and the selected owner must be able to change tickets in the resulting group.
Changing the group without selecting an owner resets the ticket to Zammad's unassigned owner.
Unlinking clears the remote correlation field only when it still equals this Windshift link's exact key, removes the local association and visible item link, and never deletes the remote ticket.
If the remote result is ambiguous, the local link remains so the operation can be retried safely.
Deleting a connection is rejected while ticket links still exist, so each link must be safely unlinked before its correlation context can be removed.

If Zammad is permanently unavailable or its credentials can no longer be recovered, a system administrator can use the explicit `detach-local` action.
It removes the typed Zammad link and the generic item link without contacting Zammad, records a distinct audit event, and allows the Windshift item or connection to be removed afterward.
This is a recovery operation, not a normal unlink: the correlation value remains on the remote ticket and must be cleared manually in Zammad before that ticket is linked again.

## Refresh, backoff, and recovery

`POST /api/zammad-ticket-links/{linkId}/refresh` performs an immediate remote read and updates the stored snapshot.
A background scheduler also polls linked tickets approximately every two minutes after the previous run completes.
Successful refreshes and persisted synchronization failures publish an item event so an open item panel reloads its Zammad snapshot without a page refresh.
Failed synchronization records a sanitized error and schedules another attempt after a retry delay.
Fair ordering by the most recent attempt prevents repeatedly failing links from starving healthy links.
Remote response bodies and credentials are never exposed to clients.
If a ticket is moved outside the connection's persisted allowed groups, synchronization records an error and does not update the local snapshot or apply a completion transition.

An OAuth `invalid_grant` response while refreshing an expired access token marks the connection as requiring reauthorization.
The connection is not ready while `reauthorization_required` is true.
Start OAuth again from the admin endpoint after correcting the Zammad OAuth application or grant.
For API-token connections, rotate the token through the admin update endpoint and rerun the connection test.

Disabling a connection stops scheduled synchronization.
A missing or unauthorized remote ticket does not mutate the Windshift item.
Optional closed-state mapping can transition the Windshift item through its normal workflow, subject to the original administrator's item-edit permission.

The outbound client requires HTTPS, rejects credentials/query/fragment components in the base URL, enforces the shared public-address SSRF policy, checks redirects, limits responses, and applies a 30-second timeout.
The integration does not synchronize attachments, comments, or arbitrary ticket fields.
6 changes: 6 additions & 0 deletions frontend/src/lib/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import {
itemIntegrationLinks,
todoistSync,
userIntegrations,
zammadConnections,
zammadTickets,
} from './integrations.js';
// Domain imports
import { items } from './items.js';
Expand Down Expand Up @@ -409,6 +411,10 @@ export const api = {
// Item Integration Links
itemIntegrationLinks,

// Zammad ticket integration
zammadConnections,
zammadTickets,

// OAuth Clients (admin only) — third-party apps registered against the
// generic OAuth 2.0 server (/api/oauth/authorize + /api/oauth/token)
oauthClients,
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/lib/api/integrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,51 @@ export const itemIntegrationLinks = {
`/items/${itemId}/integration-search?q=${encodeURIComponent(query)}&provider_id=${providerId}`
),
};

// Zammad system connections and item ticket links
export const zammadConnections = {
getAll: () => fetchAPI('/admin/zammad-connections'),
get: (id) => fetchAPI(`/admin/zammad-connections/${id}`),
create: (data) =>
fetchAPI('/admin/zammad-connections', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) =>
fetchAPI(`/admin/zammad-connections/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id) => fetchAPI(`/admin/zammad-connections/${id}`, { method: 'DELETE' }),
test: (id) => fetchAPI(`/admin/zammad-connections/${id}/test`, { method: 'POST' }),
refreshAllTickets: () => fetchAPI('/admin/zammad-ticket-links/refresh', { method: 'POST' }),
startOAuth: (id) =>
fetchAPI(`/admin/integration-providers/${id}/oauth/start`, { method: 'POST' }),
forWorkspace: (workspaceId) => fetchAPI(`/workspaces/${workspaceId}/zammad-connections`),
metadata: (workspaceId, id) =>
fetchAPI(`/workspaces/${workspaceId}/zammad-connections/${id}/metadata`),
owners: (workspaceId, id, groupId) =>
fetchAPI(
`/workspaces/${workspaceId}/zammad-connections/${id}/owners?group_id=${encodeURIComponent(groupId)}`
),
};

export const zammadTickets = {
resolve: (correlationKey) =>
fetchAPI(`/zammad-ticket-links/resolve/${encodeURIComponent(correlationKey)}`),
forItem: (itemId) => fetchAPI(`/items/${itemId}/zammad-links`),
create: (itemId, data) =>
fetchAPI(`/items/${itemId}/zammad-tickets`, {
method: 'POST',
body: JSON.stringify(data),
}),
link: (itemId, data) =>
fetchAPI(`/items/${itemId}/zammad-ticket-links`, {
method: 'POST',
body: JSON.stringify(data),
}),
update: (linkId, data) =>
fetchAPI(`/zammad-ticket-links/${linkId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (linkId) => fetchAPI(`/zammad-ticket-links/${linkId}`, { method: 'DELETE' }),
refresh: (linkId) => fetchAPI(`/zammad-ticket-links/${linkId}/refresh`, { method: 'POST' }),
};
157 changes: 157 additions & 0 deletions frontend/src/lib/api/integrations.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { zammadConnections, zammadTickets } from './integrations.js';

describe('zammadConnections.startOAuth', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('starts the system connection OAuth flow with an empty POST body', async () => {
const authURL = 'https://zammad.example.test/oauth/authorize?state=state-value';
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ auth_url: authURL }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

await expect(zammadConnections.startOAuth('connection-id')).resolves.toEqual({
auth_url: authURL,
});

expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/admin/integration-providers/connection-id/oauth/start');
expect(options.method).toBe('POST');
expect(options.body).toBeUndefined();
});
});

describe('zammadConnections.refreshAllTickets', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('starts a system-wide ticket refresh', async () => {
const result = { started: true };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(result), {
status: 202,
headers: { 'content-type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

await expect(zammadConnections.refreshAllTickets()).resolves.toEqual(result);

expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/admin/zammad-ticket-links/refresh');
expect(options.method).toBe('POST');
expect(options.body).toBeUndefined();
});
});

describe('Zammad ticket-link API', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('resolves a Zammad correlation key to its current item', async () => {
const destination = { workspace_id: 7, item_id: 42 };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(destination), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

await expect(zammadTickets.resolve('windshift:provider:TST-42')).resolves.toEqual(destination);

expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/zammad-ticket-links/resolve/windshift%3Aprovider%3ATST-42'
);
});

it('loads assignable owners for the selected group', async () => {
const owners = [
{ id: 1, name: 'Not assigned' },
{ id: 7, name: 'Ada Lovelace' },
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(owners), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

await expect(zammadConnections.owners(23, 'connection-id', 42)).resolves.toEqual(owners);

expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/workspaces/23/zammad-connections/connection-id/owners?group_id=42'
);
});

it('links an existing ticket by number', async () => {
const response = { id: 'link-id', ticket_number: '12345', sync_state: 'linked' };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(response), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

await expect(
zammadTickets.link(99, { connection_id: 'connection-id', ticket_number: '12345' })
).resolves.toEqual(response);

expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/items/99/zammad-ticket-links');
expect(options).toMatchObject({
method: 'POST',
body: JSON.stringify({ connection_id: 'connection-id', ticket_number: '12345' }),
});
});

it('updates only the selected ticket fields', async () => {
const response = { id: 'link-id', group_id: 42, owner_id: 7 };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(response), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

await expect(zammadTickets.update('link-id', { group_id: 42, owner_id: 7 })).resolves.toEqual(
response
);

expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/zammad-ticket-links/link-id');
expect(options).toMatchObject({
method: 'PUT',
body: JSON.stringify({ group_id: 42, owner_id: 7 }),
});
});

it('removes only the Zammad ticket assignment', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

await expect(zammadTickets.delete('link-id')).resolves.toBeNull();

expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/zammad-ticket-links/link-id');
expect(options.method).toBe('DELETE');
});
});
Loading
Loading