Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude/patterns/notification-fanout.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ frontend. **Verified 2026-08-12** (#625) — re-derive rather than trust these l
| **Email — subject args** | `notifications/internal/strategy/EmailNotificationStrategy.java` | 235 | ⚠️ **silent** — has a `default` |
| **Ticket body** | `notifications/internal/strategy/TicketDescriptionBuilder.java` | 21 | ⚠️ **silent** — has a `default` |
| Dispatcher / channel scope | `notifications/internal/NotificationDispatcher.java` | `resolveChannels` | org-wide events must be listed explicitly |
| PagerDuty / ticketing triggers | `internal/codec/{PagerDutyTrigger,TicketingTrigger}.java` | | absent ⇒ never pages / never opens a ticket (usually right) |
| PagerDuty / ticketing triggers | `internal/codec/{PagerDutyTrigger,TicketingTrigger}.java` | | absent ⇒ never pages / never opens a ticket (usually right). A `TicketingTrigger` may map **several** event types — `DEPLOYMENT_SUBMITTED` (#991) opens on `DEPLOYMENT_SUBMITTED` *or* `DEPLOYMENT_APPROVED` — so check every trigger's event set, not just its name. The ticket headline in `strategy/TicketDescriptionBuilder.java` has explicit deployment cases and a `default` that drops the "on <datasource>" suffix when the context has none (deployment-aware) — a new event that can open a ticket still wants its own case |
| Thymeleaf template | `backend/src/main/resources/templates/email/<event>.html` | | silent |
| i18n subject/body | `i18n/messages.properties` + 6 locales | | `MessagesParityTest` |
| **Frontend — in-app label** | `frontend/src/components/common/NotificationBell.tsx` (`labelFor` switch + `routeForNotification`) | | ⚠️ **silent** — has a `default` returning `notifications.events.fallback` |
Expand Down
6 changes: 3 additions & 3 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/03-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -2047,6 +2047,8 @@ Sensitive `config` fields encrypted with AES-256-GCM and masked on read:

## query_tickets

> See also [`deployment_tickets`](#deployment_tickets) — the deployment change-ticket sibling (#990), owned by `deploygov` rather than core, with one ticket per deployment per channel and a persisted `decision`.

Tickets auto-created in an external ticketing system (ServiceNow incident / Jira issue, AF-453) for workflow events on a query, plus the state last synced back by the signed inbound webhook. Owned by the core module so both notifications (writer) and workflow (query-detail reader) can reach it without a module cycle.

| Column | Type / Notes |
Expand Down Expand Up @@ -3144,7 +3146,7 @@ plus the state last synced back by the signed inbound webhook — the deployment
read and written only through `deploygov.api.DeploymentTicketService`. Rows are written by the
ticketing channels when they open a change ticket (#991, see
[docs/08-notifications.md → Deployment change tickets](08-notifications.md#deployment-change-tickets-991));
the inbound status sync (#992) and the gate that reads `decision` (#993) land in later sub-issues.
the inbound status sync stamps `decision` (#992) and the deployment gate reads it (#993) — see below.

| Column | Type / Notes |
|--------|-------------|
Expand Down
4 changes: 2 additions & 2 deletions docs/04-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -8988,8 +8988,8 @@ land on, validated through the org-scoped datasource lookup so a missing or cros
DATASOURCE_NOT_FOUND`; `null` = deploy-only — #877), and — #741 — `tags` (string array, ≤10 tags
of ≤32 chars each; free-form labels with no fixed semantics — customer, region, tier. The service
trims, drops blanks and de-duplicates), and — #990 — `changeTicketRequired` (default `false`;
marks an environment whose deployments must carry an approved external change ticket — stored
only for now, the gate does not read it yet). Environment responses include `tags`,
marks an environment whose deployments must carry an approved external change ticket — since
#993 the gate refuses to release until one is linked; see "Deployment gate" below). Environment responses include `tags`,
`datasourceId` and `changeTicketRequired`; on the update body `changeTicketRequired = null` (or
omitted) leaves it unchanged.
On the **update** body `tags = null` (or omitted) leaves the tag list unchanged; an explicit `[]`
Expand Down
2 changes: 1 addition & 1 deletion docs/05-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -1830,7 +1830,7 @@ imports `apigov.events` and `deploygov.events` for the two break-glass retro-rev
importing `workflow.api` back would close a Modulith cycle — and widening one shared enum with values
that are `SKIP` for two kinds out of three would report "this did not happen" where the truth is
"this does not exist here". See [docs/17 §7](17-api-governance.md#7-decision-trace-af-967) and
[docs/18 §10](18-deployment-governance.md#10-decision-trace-af-967).
[docs/18 §11](18-deployment-governance.md#11-decision-trace-af-967).

`AiOutcome` (`COMPLETED` / `SKIPPED` / `FAILED`) is what turns the three listener branches into one
parameter. `FAILED` short-circuits before any lookup, exactly as the live listener does — routing is
Expand Down
14 changes: 14 additions & 0 deletions docs/07-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The `SecurityConfiguration` `permitAll()` list is short and intentional. Every o
- `/api-docs/**`, `/swagger-ui/**`, `/actuator/health`, `/actuator/info` — docs + probes.
- `/ws` — WebSocket upgrade (auth happens in `JwtHandshakeInterceptor`, not here).
- `/api/v1/integrations/slack/actions`, `/api/v1/integrations/slack/commands` — inbound Slack callbacks. JWT-exempt because Slack cannot attach an `Authorization` header to these server-to-server posts; they are authenticated instead by the `X-Slack-Signature` HMAC inside the controller (see [Slack request verification](#slack-request-verification-af-362)). The self-service linking endpoints (`/api/v1/integrations/slack/link-codes`, `/api/v1/integrations/slack/link`) stay JWT-authenticated.
- `/api/v1/integrations/servicenow/webhook/{channelId}`, `/api/v1/integrations/jira/webhook/{channelId}` — inbound ticket status callbacks (AF-453). JWT-exempt for the same reason; authenticated inside the controller by `X-AccessFlow-Signature: sha256=HMAC-SHA256(webhook_secret, "v1:{timestamp}:{rawBody}")` over the channel's encrypted secret, compared in constant time, with `X-AccessFlow-Timestamp` held to `accessflow.notifications.ticketing.signature-tolerance` (default `PT5M`) and a Redis replay guard (`TicketingReplayGuard`) refusing a second sighting of a signature. See [08-notifications.md → Ticketing inbound webhooks](08-notifications.md#ticketing-inbound-webhooks--bi-directional-sync-af-453).

### Two-factor authentication (TOTP)

Expand Down Expand Up @@ -1746,6 +1747,19 @@ the freeze at submission. The rollback follow-up worklist
deployment's submitter can never acknowledge their own rollback — the same "never the submitter"
rule as break-glass, enforced in the service.

**Change tickets (epic #989).** A deployment's ServiceNow / Jira change ticket syncs back through
the **same** inbound webhook as a query ticket — same endpoint, HMAC signature, timestamp
tolerance, replay guard and JWT exemption; the `external_id` is matched against query tickets
first, then deployment tickets. The gate's change-ticket conjunct reads only the persisted
`deployment_tickets.decision`, never live channel configuration, and fails closed (no ticket,
an unresolved ticket or a lookup error is *not releasable*). One control boundary is documented
rather than enforced: the callback's `actor` is free text that AccessFlow cannot map to a user, so
**the never-approve-your-own-request rule does not cover a ticket approval** — a submitter who
approves their own change ticket is not stopped. The actor is recorded in the audit row; the
compensating control is `bidirectional_sync = false` on the channel, under which a ticket can
satisfy the gate but only an AccessFlow reviewer decides the deployment. See
[18-deployment-governance.md → 7. Change tickets](18-deployment-governance.md#7-change-tickets-epic-989).

---

## Schema change promotion security (#880)
Expand Down
6 changes: 4 additions & 2 deletions docs/08-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,9 @@ A ServiceNow or Jira channel with the `DEPLOYMENT_SUBMITTED` trigger opens a **c
- **One ticket per deployment per channel.** The dedupe key is `(channel, deployment request)` with no event dimension, so the second opening event is a no-op. The link is persisted in `deployment_tickets` through `deploygov.api.DeploymentTicketService`; its `trigger_event` records which event opened it (`DEPLOYMENT_SUBMITTED` or `DEPLOYMENT_APPROVED`). The dedupe check runs before the first HTTP attempt, so if the submit-time create is still in its retry schedule when the approval arrives, both can reach the ticketing system: the link table keeps the first ticket that lands and the other stays unlinked in ServiceNow / Jira.
- **Content.** Headline `[AccessFlow] Deployment <version> to <environment> awaiting approval (<pipeline>)` (or `… approved (…)`); the description lists the pipeline, environment, version, commit, artifact, CI run URL, submitter, AI risk and score, the freeze status (the active freeze window's reason, its behavior — `HOLD` / `REJECT` — when the window has no reason, or `none`), the justification, and the `/deployments/{id}` link. ServiceNow sets `correlation_id=accessflow-deployment-<deploymentRequestId>`; Jira adds the `deployment` label.
- **Audit.** A successful create writes `TICKET_CREATED` against resource type `deployment_ticket`, with `deployment_request_id` in the metadata.
- **Outbound only.** Nothing reads the ticket back yet: inbound status sync onto a deployment (#992) and the gate that waits on an approved ticket (#993) are separate changes.
- **Create-only.** AccessFlow never updates or closes the ticket: a deployment that is cancelled, rejected or timed out leaves it open. Status flows the other way only — the signed inbound callback stamps the ticket's verdict and can decide the deployment (#992, [below](#ticketing-inbound-webhooks--bi-directional-sync-af-453)), and the deployment gate waits for an approved ticket (#993).

The end-to-end picture — the environment flag, the gate conjunct, break-glass and the limits — is [docs/18-deployment-governance.md → 7. Change tickets](18-deployment-governance.md#7-change-tickets-epic-989).

---

Expand Down Expand Up @@ -453,7 +455,7 @@ POST /api/v1/integrations/jira/webhook/{channelId}

**Sync semantics.** A verified update always refreshes the linked `query_tickets` row (status + resolution) and writes a `TICKET_STATUS_SYNCED` audit row. When the channel has `bidirectional_sync=true` and the inbound `resolution`/`status` matches (case-insensitively) the channel's `reject_statuses` (checked first) or `approve_statuses`, and the query is still `PENDING_REVIEW`, the workflow's `ExternalDecisionService` force-applies the decision: `PENDING_REVIEW → APPROVED` or `→ REJECTED`, attributed to the external system (no `review_decisions` row — mirroring the review-timeout path) with the ticket key and actor recorded in the audit reason. Defaults when the lists are omitted: approve = `resolved, closed, done, approved, complete`; reject = `rejected, declined, cancelled, canceled, won't do`. Queries already in a terminal state only get the ticket-metadata update — a late ticket change never reopens or flips a decided query.

**Deployment change tickets (#992).** The `external_id` is resolved against `query_tickets` first; only when no query ticket matches is it looked up in `deployment_tickets` (the change tickets opened at the first of `DEPLOYMENT_SUBMITTED` / `DEPLOYMENT_APPROVED`, #991). Neither matches → `ignored`. For a deployment ticket:
**Deployment change tickets (#992; overview in [docs/18 §7](18-deployment-governance.md#7-change-tickets-epic-989)).** The `external_id` is resolved against `query_tickets` first; only when no query ticket matches is it looked up in `deployment_tickets` (the change tickets opened at the first of `DEPLOYMENT_SUBMITTED` / `DEPLOYMENT_APPROVED`, #991). Neither matches → `ignored`. For a deployment ticket:

- The mapped decision (`APPROVED` / `REJECTED` from the same `reject_statuses` / `approve_statuses` lists, or `null` when the status matches neither) is **stamped on the `deployment_tickets` row on every verified callback**, whether or not `bidirectional_sync` is on. An `APPROVED` stamp is **sticky**: a later callback whose status matches neither list leaves it in place, so a change moving on after approval (*Implement*, *Closed*) keeps the gate open, and only a `reject_statuses` match overrides it; a `REJECTED` stamp is reset to `null` (and `decided_at` cleared) by such a callback. The stamp is the input for the change-ticket gate conjunct (#993), which reads the row, never the channel config. The `TICKET_STATUS_SYNCED` audit row is written against resource type `deployment_ticket` with `deployment_request_id` and `decision` in its metadata.
- Only when the channel has `bidirectional_sync=true` and the mapping is non-null is the still-`PENDING_REVIEW` deployment decided (`→ APPROVED` / `→ REJECTED`) through `ExternalDecisionService.applyDeploymentTicketDecision`, which delegates to `deploygov.api.DeploymentExternalDecisionService`. The decision is system-attributed: no `deployment_review_decisions` row, a `DEPLOYMENT_APPROVED` / `DEPLOYMENT_REJECTED` audit row with a null actor and `trigger=ticket` (plus `ticket_system`, `external_key`, `channel_id`, `actor`), and the submitter is notified as for any other system verdict. A deployment that a manual decision already moved on answers `synced`, never an error.
Expand Down
2 changes: 2 additions & 0 deletions docs/12-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@

**Theme:** Deployments accountable end to end — change tickets, DORA/SLA metrics, API replay & diff, verified no-AI operation, custom SQL review rules.

- **Deployment change tickets** — an environment can require an approved change ticket before anything is released to it. A ServiceNow or Jira channel carrying the new *Deployment submitted* trigger opens one ticket per deployment when it reaches review, or when it is auto-approved; the ticket's signed status callback records the verdict on the link and — when the channel has bi-directional sync on — approves or rejects the deployment itself, attributed to the ticketing system in the audit log. The fail-closed deployment gate gains a fourth condition: on a flagged environment it answers releasable only once a linked ticket is approved and none is rejected, with break-glass bypassing it under an audit record. The deployment page shows a Change ticket card and the list a Ticket column. AccessFlow never writes back to the ticket, and because a ticket's approver cannot be matched to a user, keeping sync off is how to stop a submitter approving their own change (epic #989 — #990–#995) — see [docs/18-deployment-governance.md](18-deployment-governance.md#7-change-tickets-epic-989)

- **Custom SQL review rules** — admins write rules of their own next to the eighteen built-ins, without code: a rule is a condition over the parsed statement (statement type, referenced tables and columns by glob, functions called, the presence of `WHERE` / a row limit / `ORDER BY`, an always-true `WHERE`, a join without a condition, a leading-wildcard `LIKE`, whether the statement is inside a transaction) or a bounded regular expression over its normalised text, with a message that names the tables and functions it matched. Drafts are tested against a piece of SQL before they are switched on; once stored a custom rule is configured per environment in every ruleset exactly like a built-in, so a `BLOCK` sends the query to a reviewer and never rejects it. At most 50 per organisation, audited on every change, managed on the **Custom rules** tab of `/admin/sql-review` and by the `accessflow_sql_review_rule` Terraform resource (epic #1008 — #1009–#1012) — see [docs/19-sql-review.md](19-sql-review.md#custom-rules-1009-epic-1008)

---
Expand Down
2 changes: 1 addition & 1 deletion docs/17-api-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ connector, carrying the simulated user, the AI outcome, the risk level and the s
deliberately no request path, headers or body.

Full request/response contract: [docs/04-api-spec.md](04-api-spec.md#decision-traces-for-api-calls-and-deployments-af-967).
The deployment sibling is [§10 of docs/18](18-deployment-governance.md#10-decision-trace-af-967);
The deployment sibling is [§11 of docs/18](18-deployment-governance.md#11-decision-trace-af-967);
the query one is the [AF-859 access explainer](05-backend.md#access-explainer-af-859).

---
Expand Down
Loading
Loading