diff --git a/.github/actions/deployment-gate/deployment-gate.sh b/.github/actions/deployment-gate/deployment-gate.sh index 13365984c..9c02d11ca 100755 --- a/.github/actions/deployment-gate/deployment-gate.sh +++ b/.github/actions/deployment-gate/deployment-gate.sh @@ -244,6 +244,10 @@ while [ "$SECONDS" -lt "$deadline" ]; do fi scheduled_for="$(jq -r '.scheduled_for // empty' <<<"$gate")" if [ -n "$scheduled_for" ]; then waiting+=" scheduled_for=$scheduled_for"; fi + # The environment requires an approved change ticket (#993): say which one we wait on. + if [ "$(jq -r '.change_ticket_required // false' <<<"$gate")" = "true" ]; then + waiting+=" change_ticket=$(jq -r 'if .change_ticket then "\(.change_ticket.external_key) (\(.change_ticket.status // "unknown")\(if .change_ticket.decision then ", \(.change_ticket.decision)" else "" end))" else "none linked yet" end' <<<"$gate")" + fi echo "$waiting, waiting ${interval_s}s…" sleep "$interval_s" done diff --git a/.github/actions/tests/ci-templates-test.sh b/.github/actions/tests/ci-templates-test.sh index ab4728d7f..23ef1611f 100644 --- a/.github/actions/tests/ci-templates-test.sh +++ b/.github/actions/tests/ci-templates-test.sh @@ -153,6 +153,17 @@ assert_body_field 1 '.external_run_id' "$CI_PIPELINE_ID" assert_body_field 1 '.run_url' "$CI_PIPELINE_URL" assert_body_field 1 '.commit_sha' "$CI_COMMIT_SHA" +scenario "gitlab-gate-logs-the-change-ticket-wait" +resp 1 202 '{"id":"req-ct","status":"PENDING_AI"}' +resp 2 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true}' +resp 3 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"external_key":"CHG-7","decision":"REJECTED"}}' +resp 4 200 '{"request_id":"req-ct","status":"APPROVED","releasable":true}' +resp 5 200 '{"id":"req-ct","status":"EXECUTED"}' +run "$scripts/gitlab-deployment-gate.sh" +assert_eq "exit code" 0 "$run_rc" +assert_log_contains "change_ticket=none linked yet" +assert_log_contains "change_ticket=CHG-7 REJECTED" + scenario "gitlab-gate-404-fails-closed" resp 1 202 '{"id":"req-2","status":"PENDING_AI"}' resp 2 404 '{"title":"Not Found"}' @@ -243,6 +254,15 @@ assert_log_contains "rate limited (HTTP 429), retrying submission in 1s" assert_log_contains "rate limited (HTTP 429), retrying in 1s" assert_log_contains "confirm rate limited (HTTP 429), retrying in 1s" +scenario "azure-gate-logs-the-change-ticket-wait" +resp 1 202 '{"id":"req-ct","status":"PENDING_AI"}' +resp 2 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"external_key":"CHG-8"}}' +resp 3 200 '{"request_id":"req-ct","status":"APPROVED","releasable":true}' +resp 4 200 '{"id":"req-ct","status":"EXECUTED"}' +run "$scripts/azure-deployment-gate.sh" +assert_eq "exit code" 0 "$run_rc" +assert_log_contains "change_ticket=CHG-8" + scenario "azure-gate-404-fails-closed" resp 1 202 '{"id":"req-7","status":"PENDING_AI"}' resp 2 404 '{"title":"Not Found"}' diff --git a/.github/actions/tests/deployment-gate-test.sh b/.github/actions/tests/deployment-gate-test.sh index 4562a1728..d0f90a51b 100755 --- a/.github/actions/tests/deployment-gate-test.sh +++ b/.github/actions/tests/deployment-gate-test.sh @@ -51,6 +51,20 @@ assert_body_field 1 '.justification' "ship it" assert_body_field 1 '.metadata.changelog' "fix things" assert_body_field 1 '.break_glass // "absent"' "absent" +scenario "change-ticket-wait-is-logged" +resp 1 202 '{"id":"req-ct","status":"PENDING_AI"}' +resp 2 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true}' +resp 3 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"system":"SERVICENOW","external_key":"CHG0012345","status":"Assess"}}' +resp 4 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"external_key":"CHG0012346","status":"Closed","decision":"REJECTED"}}' +resp 5 200 '{"request_id":"req-ct","status":"APPROVED","releasable":true,"change_ticket_required":true,"change_ticket":{"external_key":"CHG0012345","status":"Implement","decision":"APPROVED"}}' +resp 6 200 '{"id":"req-ct","status":"EXECUTED"}' +run "$gate_script" +assert_eq "exit code" 0 "$run_rc" +assert_eq "confirm-execution calls" 1 "$(confirm_calls)" +assert_log_contains "change_ticket=none linked yet" +assert_log_contains "change_ticket=CHG0012345 (Assess)" +assert_log_contains "change_ticket=CHG0012346 (Closed, REJECTED)" + scenario "rejected-fails-without-confirming" resp 1 202 '{"id":"req-2","status":"PENDING_AI"}' resp 2 200 '{"request_id":"req-2","status":"PENDING_REVIEW","releasable":false}' diff --git a/CLAUDE.md b/CLAUDE.md index 134450425..c09e5a291 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,7 +139,7 @@ com.bablsoft.accessflow/ │ ├── api/ │ ├── events/ │ └── internal/ # persistence, client (per-protocol exec + auth + prober), schema (parsers), routing, scheduled, web -├── deploygov/ # Deployment approval governance (epic AF-682): gate CI/CD deployments behind approval workflows. #684 landed the persistence foundation — pipelines/environments/freeze windows/requests/decisions/routing/grants (reusing query_status, submission_reason, decision, api_routing_action) + DEPLOYMENT_PIPELINE_MANAGE / DEPLOYMENT_REVIEW permissions; #688 adds the admin surface — pipeline/environment/permission/freeze-window CRUD services + controllers, the effective-permission resolver (most-permissive union of direct + unexpired group grants), and the fail-closed FreezeWindowEvaluator; #691 adds the submission half — the API-key-authenticated trigger endpoint (idempotent on the CI run), `ai.api.DeploymentAnalyzer` + the deploygov-side analysis listener, the typed-conditions DeploymentRoutingPolicyEngine with admin CRUD, and the transition-validating DeploymentRequestStateService; #692 adds the human half — the review decision endpoints (`/api/v1/deployment-reviews`, single-stage, idempotent, self-approval → 409, plan-approver eligibility without delegation), deployment break-glass (can_break_glass grant AND environment allow_break_glass, no admin bypass; force-approve, freeze bypass audited via DEPLOYMENT_BREAK_GLASS_EXECUTED, mandatory retro-review in break_glass_events via the synchronous workflow listener), and the plan-driven DeploymentTimeoutJob (`accessflow.deploygov.timeout-check`); #693 adds the machine contract — the fail-closed gate (`GET /api/v1/deployment-gate`, one pure releasable function: APPROVED, no active freeze window (HOLD or REJECT; break-glass exempt), scheduled_for passed; any error → not releasable; 404-never-403 visibility), confirm-execution (`APPROVED → EXECUTED`, releasable-gated, audited DEPLOYMENT_EXECUTED trigger=pipeline), idempotent outcome reporting (conflict → 409 DEPLOYMENT_OUTCOME_CONFLICT; FAILED flips EXECUTED → FAILED; ROLLED_BACK on a require_review environment opens a deployment_rollback_reviews follow-up the submitter can never acknowledge), and the notify-only ScheduledDeploymentReleaseJob (`accessflow.deploygov.release-check`, one-shot DeploymentReleasableEvent via release_notified_at); #694 adds the CI wrappers — the `.github/actions/deployment-gate` + `deployment-outcome` composite actions, the `.accessflow_deployment_gate`/`_outcome` GitLab hidden jobs (`ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml`), the Azure step template (`ci-templates/azure/`), and the generic curl walkthrough — all submit-idempotently-by-run-id → poll-gate → confirm → report-outcome, fail-closed (gate 404 or any non-retryable error fails the job), pipeline identified by UUID (a trigger-only key cannot resolve names), offline-tested via the fake-curl harness in `.github/actions/tests/`; #695 adds the human fan-out — five `DEPLOYMENT_*` NotificationEventType values (submitted-on-PENDING_REVIEW → eligible reviewers, approved/rejected → submitter, outcome-failed → the granting approvers, break-glass → all admins + PagerDuty via the existing BREAK_GLASS trigger), `notifications/internal/DeploymentNotificationListener` + `deploygov/api/DeploymentNotificationLookupService`, `user_notifications.deployment_request_id` (V155), audit actions for every remaining transition (SUBMITTED/APPROVED/REJECTED/TIMED_OUT/CANCELLED + DEPLOYMENT_BREAK_GLASS_REVIEWED; system rows carry null actor + `trigger=routing|freeze|timeout|environment_policy`), and no ticketing (queries never ticket on submission either; admin-CRUD audit moved to a follow-up); #696 adds the web UI — `/deployments`, `/deployments/:id`, `/deployment-reviews` (with the rollback worklist; since #772 both are tabs of the unified `/reviews` hub — `?tab=deployments|rollbacks` — and the old URL redirects), `/admin/deployment-pipelines` and its settings tabs (environments, permissions, freeze windows, routing policies, CI snippet panel), all `t()`-keyed under `deploygov.*`; #697 is the docs/website sweep and adds [`docs/18-deployment-governance.md`](docs/18-deployment-governance.md) — read that chapter before touching the module; #741 adds multi-environment version tracking — free-form `tags` on deployment_environments (≤10, ≤32 chars, the query_templates.tags precedent) and the one-row-per-environment `deployment_environment_versions` read model maintained by the module-private DeploymentVersionTrackerService inside the EXECUTED/outcome transactions (shift-on-execute, single-level revert on FAILED/ROLLED_BACK for the current request, non-current outcomes no-op; never feeds gate/approval/routing; UI follows in #743); #742 adds the read-only version inventory & drift API — `deploygov/api/DeploymentVersionInventoryService` behind three endpoints (per-pipeline matrix, org-wide filterable matrix, per-environment history straight from deployment_requests), drift computed at read time only (no job, no notifications, no semver — plain string inequality vs the pipeline's newest successful deploy over the UNFILTERED row set), timed by `deployment_requests.executed_at` (V157, stamped at the state-service EXECUTED chokepoint); per-pipeline visibility = MANAGE/REVIEW/QUERY_ADMIN/effective can_trigger failing 404-never-403, org-wide matrix functional-permission-only; #967 adds the decision trace — a pure `DeploymentDecisionEvaluator` split out of the review state machine, and `POST /admin/deployment-simulations`, which walks the trigger grant, freeze window, routing, environment policy, approvers, schedule and the gate's own `releasable` function at an optional hypothetical instant `at`; #877 (epic #870, schema change sets) makes environments schema-promotion targets — nullable `deployment_environments.datasource_id` (bare UUID, no FK, validated via `core.api.DatasourceAdminService.getForAdmin` → the existing `404 DATASOURCE_NOT_FOUND`; `clearDatasource` unbinds) and a **real ladder**: V177 backfills `sort_order` to distinct contiguous values per pipeline and adds `UNIQUE (pipeline_id, sort_order)`; the service appends an omitted position at `max+1`, pre-checks an explicit duplicate and translates the raced unique violation to `409 DEPLOYMENT_ENVIRONMENT_SORT_ORDER_CONFLICT` (never default `sort_order` to `0` again — a "lower-ordered environments applied" gate over all-zero rows evaluates the empty set and fails open); two narrow api lookups — `DeploymentEnvironmentLookupService` (ladder + one-by-id, not org-scoped: resolve the pipeline first) and `DeploymentFreezeLookupService` (pure delegation to the package-private `FreezeWindowEvaluator`, preserving fail-closed `HOLD` / most-specific / `REJECT`-beats-`HOLD`) — exist so `schemachange` never imports `deploygov.internal`; #992 adds ticket-driven decisions — `api.DeploymentExternalDecisionService` (system-attributed: no decision row, null actor, `trigger=ticket`, `false` on a lost race) reached only through `workflow.api.ExternalDecisionService.applyDeploymentTicketDecision`, because `workflow → deploygov` already exists and the reverse is a cycle; the inbound webhook stamps `deployment_tickets.decision` on every verified callback and decides only with `bidirectional_sync` +├── deploygov/ # Deployment approval governance (epic AF-682): gate CI/CD deployments behind approval workflows. #684 landed the persistence foundation — pipelines/environments/freeze windows/requests/decisions/routing/grants (reusing query_status, submission_reason, decision, api_routing_action) + DEPLOYMENT_PIPELINE_MANAGE / DEPLOYMENT_REVIEW permissions; #688 adds the admin surface — pipeline/environment/permission/freeze-window CRUD services + controllers, the effective-permission resolver (most-permissive union of direct + unexpired group grants), and the fail-closed FreezeWindowEvaluator; #691 adds the submission half — the API-key-authenticated trigger endpoint (idempotent on the CI run), `ai.api.DeploymentAnalyzer` + the deploygov-side analysis listener, the typed-conditions DeploymentRoutingPolicyEngine with admin CRUD, and the transition-validating DeploymentRequestStateService; #692 adds the human half — the review decision endpoints (`/api/v1/deployment-reviews`, single-stage, idempotent, self-approval → 409, plan-approver eligibility without delegation), deployment break-glass (can_break_glass grant AND environment allow_break_glass, no admin bypass; force-approve, freeze bypass audited via DEPLOYMENT_BREAK_GLASS_EXECUTED, mandatory retro-review in break_glass_events via the synchronous workflow listener), and the plan-driven DeploymentTimeoutJob (`accessflow.deploygov.timeout-check`); #693 adds the machine contract — the fail-closed gate (`GET /api/v1/deployment-gate`, one pure releasable function: APPROVED, no active freeze window (HOLD or REJECT; break-glass exempt), scheduled_for passed; any error → not releasable; 404-never-403 visibility), confirm-execution (`APPROVED → EXECUTED`, releasable-gated, audited DEPLOYMENT_EXECUTED trigger=pipeline), idempotent outcome reporting (conflict → 409 DEPLOYMENT_OUTCOME_CONFLICT; FAILED flips EXECUTED → FAILED; ROLLED_BACK on a require_review environment opens a deployment_rollback_reviews follow-up the submitter can never acknowledge), and the notify-only ScheduledDeploymentReleaseJob (`accessflow.deploygov.release-check`, one-shot DeploymentReleasableEvent via release_notified_at); #694 adds the CI wrappers — the `.github/actions/deployment-gate` + `deployment-outcome` composite actions, the `.accessflow_deployment_gate`/`_outcome` GitLab hidden jobs (`ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml`), the Azure step template (`ci-templates/azure/`), and the generic curl walkthrough — all submit-idempotently-by-run-id → poll-gate → confirm → report-outcome, fail-closed (gate 404 or any non-retryable error fails the job), pipeline identified by UUID (a trigger-only key cannot resolve names), offline-tested via the fake-curl harness in `.github/actions/tests/`; #695 adds the human fan-out — five `DEPLOYMENT_*` NotificationEventType values (submitted-on-PENDING_REVIEW → eligible reviewers, approved/rejected → submitter, outcome-failed → the granting approvers, break-glass → all admins + PagerDuty via the existing BREAK_GLASS trigger), `notifications/internal/DeploymentNotificationListener` + `deploygov/api/DeploymentNotificationLookupService`, `user_notifications.deployment_request_id` (V155), audit actions for every remaining transition (SUBMITTED/APPROVED/REJECTED/TIMED_OUT/CANCELLED + DEPLOYMENT_BREAK_GLASS_REVIEWED; system rows carry null actor + `trigger=routing|freeze|timeout|environment_policy`), and no ticketing (queries never ticket on submission either; admin-CRUD audit moved to a follow-up); #696 adds the web UI — `/deployments`, `/deployments/:id`, `/deployment-reviews` (with the rollback worklist; since #772 both are tabs of the unified `/reviews` hub — `?tab=deployments|rollbacks` — and the old URL redirects), `/admin/deployment-pipelines` and its settings tabs (environments, permissions, freeze windows, routing policies, CI snippet panel), all `t()`-keyed under `deploygov.*`; #697 is the docs/website sweep and adds [`docs/18-deployment-governance.md`](docs/18-deployment-governance.md) — read that chapter before touching the module; #741 adds multi-environment version tracking — free-form `tags` on deployment_environments (≤10, ≤32 chars, the query_templates.tags precedent) and the one-row-per-environment `deployment_environment_versions` read model maintained by the module-private DeploymentVersionTrackerService inside the EXECUTED/outcome transactions (shift-on-execute, single-level revert on FAILED/ROLLED_BACK for the current request, non-current outcomes no-op; never feeds gate/approval/routing; UI follows in #743); #742 adds the read-only version inventory & drift API — `deploygov/api/DeploymentVersionInventoryService` behind three endpoints (per-pipeline matrix, org-wide filterable matrix, per-environment history straight from deployment_requests), drift computed at read time only (no job, no notifications, no semver — plain string inequality vs the pipeline's newest successful deploy over the UNFILTERED row set), timed by `deployment_requests.executed_at` (V157, stamped at the state-service EXECUTED chokepoint); per-pipeline visibility = MANAGE/REVIEW/QUERY_ADMIN/effective can_trigger failing 404-never-403, org-wide matrix functional-permission-only; #967 adds the decision trace — a pure `DeploymentDecisionEvaluator` split out of the review state machine, and `POST /admin/deployment-simulations`, which walks the trigger grant, freeze window, routing, environment policy, approvers, schedule and the gate's own `releasable` function at an optional hypothetical instant `at`; #877 (epic #870, schema change sets) makes environments schema-promotion targets — nullable `deployment_environments.datasource_id` (bare UUID, no FK, validated via `core.api.DatasourceAdminService.getForAdmin` → the existing `404 DATASOURCE_NOT_FOUND`; `clearDatasource` unbinds) and a **real ladder**: V177 backfills `sort_order` to distinct contiguous values per pipeline and adds `UNIQUE (pipeline_id, sort_order)`; the service appends an omitted position at `max+1`, pre-checks an explicit duplicate and translates the raced unique violation to `409 DEPLOYMENT_ENVIRONMENT_SORT_ORDER_CONFLICT` (never default `sort_order` to `0` again — a "lower-ordered environments applied" gate over all-zero rows evaluates the empty set and fails open); two narrow api lookups — `DeploymentEnvironmentLookupService` (ladder + one-by-id, not org-scoped: resolve the pipeline first) and `DeploymentFreezeLookupService` (pure delegation to the package-private `FreezeWindowEvaluator`, preserving fail-closed `HOLD` / most-specific / `REJECT`-beats-`HOLD`) — exist so `schemachange` never imports `deploygov.internal`; #992 adds ticket-driven decisions — `api.DeploymentExternalDecisionService` (system-attributed: no decision row, null actor, `trigger=ticket`, `false` on a lost race) reached only through `workflow.api.ExternalDecisionService.applyDeploymentTicketDecision`, because `workflow → deploygov` already exists and the reverse is a cycle; the inbound webhook stamps `deployment_tickets.decision` on every verified callback and decides only with `bidirectional_sync`; #993 makes the ticket a control — a fourth conjunct of the gate's pure `releasable` function on `change_ticket_required` environments, read from `deployment_tickets` rows only (≥ 1 `APPROVED`, none `REJECTED`; no row, unresolved rows or a lookup error → not releasable, so a flagged environment with no ticketing channel carrying the trigger never releases), skipped for break-glass and audited on `DEPLOYMENT_EXECUTED` as `change_ticket_key` / `change_ticket_bypassed=break_glass`; an `APPROVED` stamp is sticky in `DefaultDeploymentTicketService.updateStatus` (an unmapped later status keeps it, only a `REJECTED` mapping overrides it — never revert to "null always replaces", or a change moving to *Implement* closes an opened gate); the decision trace always answers not releasable there (a hypothetical request has no ticket) │ ├── api/ │ ├── events/ │ └── internal/ # persistence, services, resolver/evaluator, scheduled, web diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentGateView.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentGateView.java index 5424ee9a6..f12e929cc 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentGateView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentGateView.java @@ -12,6 +12,8 @@ * fail-closed pure function; {@code frozen}/{@code freezeReason} describe the winning freeze * window when one is active (always {@code false}/{@code null} for a break-glass request — the * freeze check is skipped). {@code aiRiskLevel} is null when no analysis produced a risk. + * {@code changeTicketRequired} mirrors the environment flag (#993); {@code changeTicket} is the + * linked ticket the gate judged by — rejected first, else approved, else the newest — or null. */ public record DeploymentGateView( UUID requestId, @@ -23,7 +25,9 @@ public record DeploymentGateView( boolean frozen, String freezeReason, Instant scheduledFor, - RiskLevel aiRiskLevel) { + RiskLevel aiRiskLevel, + boolean changeTicketRequired, + DeploymentTicketView changeTicket) { public DeploymentGateView { decisions = decisions == null ? List.of() : List.copyOf(decisions); diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentTicketService.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentTicketService.java index 791e12751..23e2e5159 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentTicketService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentTicketService.java @@ -21,9 +21,11 @@ public interface DeploymentTicketService { /** * Applies an inbound status update to the ticket identified by {@code (channelId, externalId)}. * {@code decision} ({@code APPROVED} / {@code REJECTED} / null) is the caller's mapping of the - * status onto the channel's approve/reject lists and always replaces the stored one; - * {@code decidedAt} is stamped when the decision changes to a non-null value, kept when the - * same decision is re-sent, and cleared with a null decision. Returns the updated view, or + * status onto the channel's approve/reject lists and replaces the stored one, except that an + * {@code APPROVED} decision is sticky: a null decision leaves it (and {@code decidedAt}) in + * place, so only a {@code REJECTED} mapping overrides an approval (#993). {@code decidedAt} is + * stamped when the decision changes to a non-null value, kept when the same decision is + * re-sent, and cleared when a null decision clears a {@code REJECTED} one. Returns the updated view, or * empty when no such ticket is linked (unknown tickets are ignored, not an error). */ Optional updateStatus(UUID channelId, String externalId, String status, diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java index 70769d8eb..c9dfa9142 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java @@ -18,16 +18,18 @@ import com.bablsoft.accessflow.deploygov.api.DeploymentRequestPermissionException; import com.bablsoft.accessflow.deploygov.api.DeploymentRequestView; import com.bablsoft.accessflow.deploygov.api.DeploymentReviewDecisionView; +import com.bablsoft.accessflow.deploygov.api.DeploymentTicketView; import com.bablsoft.accessflow.deploygov.api.EffectiveDeploymentPermission; import com.bablsoft.accessflow.deploygov.api.IllegalDeploymentRequestStateException; import com.bablsoft.accessflow.deploygov.events.DeploymentReleasableEvent; -import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentEnvironmentEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentPipelineEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentRequestEntity; +import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentTicketEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentEnvironmentRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentPipelineRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentRequestRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentReviewDecisionRepository; +import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentTicketRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -36,28 +38,35 @@ import java.time.Clock; import java.time.Instant; -import java.util.Map; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Set; import java.util.UUID; /** * The deployment gate (#693). Releasability is computed by the single pure function - * {@link #releasable(QueryStatus, boolean, Instant, Instant)} — default answer not-releasable — - * and every freeze/schedule evaluation is wrapped so that any lookup or evaluation error - * fails closed. {@code frozen} means any active window, {@code HOLD} or {@code REJECT}: a + * {@link #releasable(QueryStatus, boolean, Instant, Instant, boolean, boolean)} — default answer + * not-releasable — and every freeze/schedule/ticket evaluation is wrapped so that any + * lookup or evaluation error fails closed. {@code frozen} means any active window, {@code HOLD} or {@code REJECT}: a * {@code REJECT} window normally auto-rejects at submission, but a request approved before the * window started must not sail through mid-freeze. Break-glass requests skip the freeze check — - * they already bypassed it at submission. + * they already bypassed it at submission — and skip the change-ticket conjunct (#993) too. */ @Service @RequiredArgsConstructor @Slf4j public class DefaultDeploymentGateService implements DeploymentGateService { + /** {@code deployment_tickets.decision} values — strings, the enum lives in notifications. */ + private static final String APPROVED = "APPROVED"; + private static final String REJECTED = "REJECTED"; + private final DeploymentRequestRepository requestRepository; private final DeploymentPipelineRepository pipelineRepository; private final DeploymentEnvironmentRepository environmentRepository; private final DeploymentReviewDecisionRepository decisionRepository; + private final DeploymentTicketRepository ticketRepository; private final EffectiveDeploymentPermissionResolver permissionResolver; private final FreezeWindowEvaluator freezeWindowEvaluator; private final DeploymentRequestStateService stateService; @@ -117,16 +126,22 @@ public DeploymentRequestView confirmExecution(UUID requestId, UUID organizationI } stateService.apply(request, QueryStatus.EXECUTED); // Metadata keys mirror DEPLOYMENT_BREAK_GLASS_EXECUTED (#692): `environment` is the name. - var environmentName = environmentRepository.findById(request.getEnvironmentId()) - .map(DeploymentEnvironmentEntity::getName) - .orElse(request.getEnvironmentId().toString()); + var metadata = new LinkedHashMap(); + metadata.put("trigger", "pipeline"); + metadata.put("pipeline_id", request.getPipelineId().toString()); + metadata.put("environment", evaluation.environmentName()); + metadata.put("version", request.getVersion()); + if (evaluation.ticketRequired()) { + metadata.put("change_ticket_required", true); + if (request.getSubmissionReason() == SubmissionReason.EMERGENCY_ACCESS) { + metadata.put("change_ticket_bypassed", "break_glass"); + } else { + // Releasable implies an APPROVED and no REJECTED ticket, so chooseTicket is non-null. + metadata.put("change_ticket_key", evaluation.changeTicket().externalKey()); + } + } auditWriter.record(AuditAction.DEPLOYMENT_EXECUTED, AuditResourceType.DEPLOYMENT_REQUEST, - request.getId(), organizationId, callerId, - Map.of("trigger", "pipeline", - "pipeline_id", request.getPipelineId().toString(), - "environment", environmentName, - "version", request.getVersion()), - ipAddress); + request.getId(), organizationId, callerId, metadata, ipAddress); return requestService.detailView(request); } @@ -157,43 +172,97 @@ public boolean markReleasable(UUID requestId) { /** * The one pure releasability function. The default answer is not-releasable: only an - * {@code APPROVED}, unfrozen request whose scheduled moment (if any) has passed opens the gate. + * {@code APPROVED}, unfrozen request whose scheduled moment (if any) has passed — and, on a + * {@code change_ticket_required} environment, whose change ticket is approved — opens the gate. */ static boolean releasable(QueryStatus status, boolean frozen, Instant scheduledFor, - Instant now) { - if (status != QueryStatus.APPROVED || frozen) { + Instant now, boolean ticketRequired, boolean ticketSatisfied) { + if (status != QueryStatus.APPROVED || frozen || (ticketRequired && !ticketSatisfied)) { return false; } return scheduledFor == null || !scheduledFor.isAfter(now); } /** - * Freeze + schedule evaluation, fail closed: any {@code RuntimeException} answers - * not-releasable rather than propagating an accidental "yes". + * Freeze + schedule + change-ticket evaluation, fail closed: any {@code RuntimeException} + * answers not-releasable rather than propagating an accidental "yes". */ GateEvaluation evaluate(DeploymentRequestEntity request, Instant now) { try { + var environment = environmentRepository.findById(request.getEnvironmentId()) + .orElse(null); + if (environment == null) { + // A deleted environment is a steady state, not a fault: no stack trace per tick. + log.warn("Environment {} of deployment request {} no longer exists — answering not" + + " releasable", request.getEnvironmentId(), request.getId()); + return NOT_RELEASABLE; + } + boolean ticketRequired = environment.isChangeTicketRequired(); boolean frozen = false; String freezeReason = null; + boolean ticketSatisfied = true; + DeploymentTicketView changeTicket = null; if (request.getSubmissionReason() != SubmissionReason.EMERGENCY_ACCESS) { var freeze = freezeWindowEvaluator.evaluate(request.getOrganizationId(), request.getPipelineId(), request.getEnvironmentId()); frozen = freeze.isPresent(); freezeReason = freeze.map(FreezeWindowEvaluator.ActiveFreeze::reason).orElse(null); + if (ticketRequired) { + var tickets = ticketRepository.findAllByDeploymentRequestId(request.getId()); + ticketSatisfied = ticketApproved(tickets); + changeTicket = chooseTicket(tickets); + } } return new GateEvaluation( - releasable(request.getStatus(), frozen, request.getScheduledFor(), now), - frozen, freezeReason); + releasable(request.getStatus(), frozen, request.getScheduledFor(), now, + ticketRequired, ticketSatisfied), + frozen, freezeReason, ticketRequired, changeTicket, environment.getName()); } catch (RuntimeException ex) { log.error("Gate evaluation failed for deployment request {} — answering not releasable", request.getId(), ex); - return new GateEvaluation(false, false, null); + return NOT_RELEASABLE; } } - record GateEvaluation(boolean releasable, boolean frozen, String freezeReason) { + /** At least one linked ticket approved and none rejected; unresolved tickets count for nothing. */ + static boolean ticketApproved(List tickets) { + boolean approved = false; + for (var ticket : tickets) { + if (REJECTED.equals(ticket.getDecision())) { + return false; + } + approved |= APPROVED.equals(ticket.getDecision()); + } + return approved; } + /** + * The ticket that explains the verdict: the newest rejected one (it blocks the gate), else the + * newest approved one, else the newest linked ticket, else null. + */ + private static DeploymentTicketView chooseTicket(List tickets) { + var newestFirst = Comparator.comparing(DeploymentTicketEntity::getCreatedAt, + Comparator.nullsFirst(Comparator.naturalOrder())).reversed(); + return tickets.stream() + .filter(t -> REJECTED.equals(t.getDecision())) + .min(newestFirst) + .or(() -> tickets.stream().filter(t -> APPROVED.equals(t.getDecision())) + .min(newestFirst)) + .or(() -> tickets.stream().min(newestFirst)) + .map(DefaultDeploymentTicketService::toView) + .orElse(null); + } + + /** {@code environmentName} is only read on the releasable path, where the row was found. */ + record GateEvaluation(boolean releasable, boolean frozen, String freezeReason, + boolean ticketRequired, DeploymentTicketView changeTicket, + String environmentName) { + } + + /** The error path makes no claim: not frozen, no ticket requirement reported. */ + private static final GateEvaluation NOT_RELEASABLE = + new GateEvaluation(false, false, null, false, null, null); + private DeploymentGateView toGateView(DeploymentRequestEntity request) { var evaluation = evaluate(request, clock.instant()); var decisions = decisionRepository @@ -209,7 +278,7 @@ private DeploymentGateView toGateView(DeploymentRequestEntity request) { return new DeploymentGateView(request.getId(), request.getStatus(), evaluation.releasable(), request.getRequiredApprovals(), granted, decisions, evaluation.frozen(), evaluation.freezeReason(), request.getScheduledFor(), - aiRiskLevel); + aiRiskLevel, evaluation.ticketRequired(), evaluation.changeTicket()); } /** diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationService.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationService.java index 457743f41..bf0e9f392 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationService.java @@ -113,9 +113,11 @@ public DeploymentSimulationResult simulate(UUID organizationId, steps.add(scheduleStep(input.scheduledFor(), at)); // The gate's own function, not a copy: a HOLD window and a REJECT window both count as // frozen at release time, which is why `freeze != null` rather than a behaviour check. + // A hypothetical request has no linked change ticket yet, so the ticket conjunct is unmet. boolean releasable = DefaultDeploymentGateService.releasable(decision.nextStatus(), - freeze != null, input.scheduledFor(), at); - steps.add(gateStep(releasable, decision.nextStatus(), freeze != null, input.scheduledFor())); + freeze != null, input.scheduledFor(), at, environment.changeTicketRequired(), false); + steps.add(gateStep(releasable, decision.nextStatus(), freeze != null, input.scheduledFor(), + environment.changeTicketRequired())); steps.add(breakGlassStep(permission, environment)); return new DeploymentSimulationResult(steps, decision.nextStatus(), releasable, at, List.copyOf(caveats)); @@ -334,12 +336,13 @@ private static DecisionTraceStep scheduleStep(Instant scheduledFor, Instant at) // ── 8. The gate ─────────────────────────────────────────────────────────── private static DecisionTraceStep gateStep(boolean releasable, QueryStatus status, boolean frozen, - Instant scheduledFor) { + Instant scheduledFor, boolean changeTicketRequired) { var details = new LinkedHashMap(); details.put("releasable", releasable); details.put("status", status == null ? null : status.name()); details.put("frozen", frozen); details.put("scheduled_for", scheduledFor); + details.put("change_ticket_required", changeTicketRequired); return DecisionTraceStep.of(DeploymentDecisionStepKind.GATE_RELEASABILITY, releasable ? StepOutcome.ALLOW : StepOutcome.DENY, releasable ? "deploygov.simulation.gate.releasable" diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java index fe50c5b65..568026105 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java @@ -19,6 +19,8 @@ @RequiredArgsConstructor class DefaultDeploymentTicketService implements DeploymentTicketService { + private static final String APPROVED = "APPROVED"; + private final DeploymentTicketRepository repository; private final Clock clock; @@ -58,6 +60,12 @@ public Optional updateStatus(UUID channelId, String extern var now = clock.instant(); entity.setStatus(status); entity.setResolution(resolution); + if (decision == null && APPROVED.equals(entity.getDecision())) { + // Sticky approval (#993): a change keeps moving after CAB approval + // (Scheduled → Implement → Closed), and the gate must not close behind it. + entity.setUpdatedAt(now); + return toView(repository.save(entity)); + } if (decision == null) { entity.setDecidedAt(null); } else if (!Objects.equals(decision, entity.getDecision())) { @@ -81,7 +89,7 @@ public List listByDeploymentRequest(UUID deploymentRequest .toList(); } - private static DeploymentTicketView toView(DeploymentTicketEntity entity) { + static DeploymentTicketView toView(DeploymentTicketEntity entity) { return new DeploymentTicketView( entity.getId(), entity.getOrganizationId(), diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java index fa4e515d5..427edf5cc 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java @@ -3,6 +3,7 @@ import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.deploygov.api.DeploymentGateView; +import com.bablsoft.accessflow.deploygov.api.DeploymentTicketView; import java.time.Instant; import java.util.List; @@ -18,15 +19,27 @@ public record DeploymentGateResponse( boolean frozen, String freezeReason, Instant scheduledFor, - RiskLevel aiRiskLevel) { + RiskLevel aiRiskLevel, + boolean changeTicketRequired, + ChangeTicket changeTicket) { record Approvals(int required, int granted) { } + record ChangeTicket(String system, String externalKey, String url, String status, + String resolution, String decision) { + + static ChangeTicket from(DeploymentTicketView view) { + return view == null ? null : new ChangeTicket(view.ticketSystem(), view.externalKey(), + view.url(), view.status(), view.resolution(), view.decision()); + } + } + static DeploymentGateResponse from(DeploymentGateView view) { return new DeploymentGateResponse(view.requestId(), view.status(), view.releasable(), new Approvals(view.requiredApprovals(), view.grantedApprovals()), view.decisions().stream().map(DeploymentReviewDecisionResponse::from).toList(), - view.frozen(), view.freezeReason(), view.scheduledFor(), view.aiRiskLevel()); + view.frozen(), view.freezeReason(), view.scheduledFor(), view.aiRiskLevel(), + view.changeTicketRequired(), ChangeTicket.from(view.changeTicket())); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java index abcb9a82d..5c04928d4 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java @@ -21,6 +21,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -105,10 +106,19 @@ void recordsReloadsAndStampsTheDecisionThroughTheService() { assertThat(updated.decision()).isEqualTo("APPROVED"); assertThat(updated.decidedAt()).isNotNull(); + // Sticky approval: a later unmapped status (null decision) keeps APPROVED and decidedAt. + var progressed = ticketService.updateStatus(channelId, recorded.externalId(), "Review", + null, null).orElseThrow(); + assertThat(progressed.status()).isEqualTo("Review"); + assertThat(progressed.decision()).isEqualTo("APPROVED"); + // Postgres keeps microseconds; the first view may carry the clock's nanoseconds (Linux). + assertThat(progressed.decidedAt().truncatedTo(ChronoUnit.MICROS)) + .isEqualTo(updated.decidedAt().truncatedTo(ChronoUnit.MICROS)); + var listed = ticketService.listByDeploymentRequest(request.getId(), organizationId); assertThat(listed).singleElement().satisfies(view -> { assertThat(view.externalKey()).isEqualTo("CHG0030012"); - assertThat(view.status()).isEqualTo("Implement"); + assertThat(view.status()).isEqualTo("Review"); assertThat(view.decision()).isEqualTo("APPROVED"); }); assertThat(ticketService.listByDeploymentRequest(request.getId(), UUID.randomUUID())) diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateServiceTest.java index 14126555b..e4fa0d0c0 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateServiceTest.java @@ -20,10 +20,12 @@ import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentEnvironmentEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentPipelineEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentRequestEntity; +import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentTicketEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentEnvironmentRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentPipelineRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentRequestRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentReviewDecisionRepository; +import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentTicketRepository; import com.bablsoft.accessflow.core.api.AiAnalysisLookupService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,6 +35,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Optional; @@ -57,6 +60,7 @@ class DefaultDeploymentGateServiceTest { private DeploymentPipelineRepository pipelineRepository; private DeploymentEnvironmentRepository environmentRepository; private DeploymentReviewDecisionRepository decisionRepository; + private DeploymentTicketRepository ticketRepository; private EffectiveDeploymentPermissionResolver permissionResolver; private FreezeWindowEvaluator freezeWindowEvaluator; private DeploymentRequestStateService stateService; @@ -72,6 +76,7 @@ void setUp() { pipelineRepository = mock(DeploymentPipelineRepository.class); environmentRepository = mock(DeploymentEnvironmentRepository.class); decisionRepository = mock(DeploymentReviewDecisionRepository.class); + ticketRepository = mock(DeploymentTicketRepository.class); permissionResolver = mock(EffectiveDeploymentPermissionResolver.class); freezeWindowEvaluator = mock(FreezeWindowEvaluator.class); stateService = mock(DeploymentRequestStateService.class); @@ -80,31 +85,59 @@ void setUp() { aiAnalysisLookupService = mock(AiAnalysisLookupService.class); eventPublisher = mock(ApplicationEventPublisher.class); service = new DefaultDeploymentGateService(requestRepository, pipelineRepository, - environmentRepository, decisionRepository, permissionResolver, + environmentRepository, decisionRepository, ticketRepository, permissionResolver, freezeWindowEvaluator, stateService, requestService, auditWriter, aiAnalysisLookupService, eventPublisher, Clock.fixed(NOW, ZoneOffset.UTC)); + // Any environment the evaluation looks up defaults to an unflagged one. + when(environmentRepository.findById(any())).thenAnswer(invocation -> + Optional.of(environment(UUID.randomUUID()))); } // --- the pure function ------------------------------------------------------------------- @Test void releasableOnlyForAnUnfrozenApprovedRequestPastItsSchedule() { - assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, false, null, NOW)) - .isTrue(); - assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, false, - NOW.minusSeconds(1), NOW)).isTrue(); - assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, false, NOW, NOW)) - .isTrue(); + assertThat(releasable(QueryStatus.APPROVED, false, null)).isTrue(); + assertThat(releasable(QueryStatus.APPROVED, false, NOW.minusSeconds(1))).isTrue(); + assertThat(releasable(QueryStatus.APPROVED, false, NOW)).isTrue(); + assertThat(releasable(QueryStatus.PENDING_REVIEW, false, null)).isFalse(); + assertThat(releasable(QueryStatus.REJECTED, false, null)).isFalse(); + assertThat(releasable(QueryStatus.EXECUTED, false, null)).isFalse(); + assertThat(releasable(QueryStatus.APPROVED, true, null)).isFalse(); + assertThat(releasable(QueryStatus.APPROVED, false, NOW.plusSeconds(1))).isFalse(); + } + + @Test + void releasableRequiresASatisfiedTicketOnlyWhenTheEnvironmentRequiresOne() { + // Flag off: the satisfied argument is irrelevant. + assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, false, null, NOW, + false, false)).isTrue(); + assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, false, null, NOW, + true, false)).isFalse(); + assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, false, null, NOW, + true, true)).isTrue(); + // A satisfied ticket never overrides another conjunct. + assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, true, null, NOW, + true, true)).isFalse(); assertThat(DefaultDeploymentGateService.releasable(QueryStatus.PENDING_REVIEW, false, null, - NOW)).isFalse(); - assertThat(DefaultDeploymentGateService.releasable(QueryStatus.REJECTED, false, null, NOW)) - .isFalse(); - assertThat(DefaultDeploymentGateService.releasable(QueryStatus.EXECUTED, false, null, NOW)) - .isFalse(); - assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, true, null, NOW)) + NOW, true, true)).isFalse(); + } + + @Test + void ticketApprovedNeedsOneApprovalAndNoRejection() { + assertThat(DefaultDeploymentGateService.ticketApproved(List.of())).isFalse(); + assertThat(DefaultDeploymentGateService.ticketApproved(tickets("APPROVED"))).isTrue(); + assertThat(DefaultDeploymentGateService.ticketApproved(tickets(null, "APPROVED"))) + .isTrue(); + assertThat(DefaultDeploymentGateService.ticketApproved(tickets("APPROVED", "REJECTED"))) .isFalse(); - assertThat(DefaultDeploymentGateService.releasable(QueryStatus.APPROVED, false, - NOW.plusSeconds(1), NOW)).isFalse(); + assertThat(DefaultDeploymentGateService.ticketApproved(tickets(null, null))).isFalse(); + assertThat(DefaultDeploymentGateService.ticketApproved(tickets("REJECTED"))).isFalse(); + } + + private static boolean releasable(QueryStatus status, boolean frozen, Instant scheduledFor) { + return DefaultDeploymentGateService.releasable(status, frozen, scheduledFor, NOW, false, + false); } // --- gate by tuple ----------------------------------------------------------------------- @@ -341,6 +374,177 @@ void gateSurfacesTheAiRiskLevel() { .isEqualTo(RiskLevel.HIGH); } + // --- change ticket (#993) ---------------------------------------------------------------- + + @Test + void gateWithFlagOffReportsNoTicketAndNeverLooksOne() { + var request = approvedById(); + + var view = service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()); + + assertThat(view.releasable()).isTrue(); + assertThat(view.changeTicketRequired()).isFalse(); + assertThat(view.changeTicket()).isNull(); + verify(ticketRepository, never()).findAllByDeploymentRequestId(any()); + } + + @Test + void gateWithFlagOnAndNoTicketIsNotReleasable() { + var request = approvedById(); + requireTicket(request); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())).thenReturn(List.of()); + + var view = service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()); + + assertThat(view.releasable()).isFalse(); + assertThat(view.changeTicketRequired()).isTrue(); + assertThat(view.changeTicket()).isNull(); + assertThat(view.frozen()).isFalse(); + } + + @Test + void gateWithFlagOnAndAnApprovedTicketIsReleasableAndSurfacesIt() { + var request = approvedById(); + requireTicket(request); + var pending = ticket(null, "CHG-2", NOW); + var approved = ticket("APPROVED", "CHG-1", NOW.minusSeconds(60)); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())) + .thenReturn(List.of(pending, approved)); + + var view = service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()); + + assertThat(view.releasable()).isTrue(); + assertThat(view.changeTicket().externalKey()).isEqualTo("CHG-1"); + assertThat(view.changeTicket().decision()).isEqualTo("APPROVED"); + } + + @Test + void gateWithFlagOnAndAnApprovedPlusRejectedTicketIsNotReleasable() { + var request = approvedById(); + requireTicket(request); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())).thenReturn(List.of( + ticket("REJECTED", "CHG-2", NOW.minusSeconds(60)), ticket("APPROVED", "CHG-1", NOW))); + + var view = service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()); + + assertThat(view.releasable()).isFalse(); + // The rejected ticket is the blocker, so it is the one surfaced. + assertThat(view.changeTicket().externalKey()).isEqualTo("CHG-2"); + } + + @Test + void gateWithFlagOnAndOnlyUnresolvedTicketsIsNotReleasableAndShowsTheNewest() { + var request = approvedById(); + requireTicket(request); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())).thenReturn(List.of( + ticket(null, "CHG-OLD", NOW.minusSeconds(60)), ticket(null, "CHG-NEW", NOW))); + + var view = service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()); + + assertThat(view.releasable()).isFalse(); + assertThat(view.changeTicket().externalKey()).isEqualTo("CHG-NEW"); + } + + @Test + void gateFailsClosedWhenTheTicketLookupThrows() { + var request = approvedById(); + requireTicket(request); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())) + .thenThrow(new IllegalStateException("boom")); + + var view = service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()); + + assertThat(view.releasable()).isFalse(); + assertThat(view.frozen()).isFalse(); + assertThat(view.changeTicketRequired()).isFalse(); + assertThat(view.changeTicket()).isNull(); + } + + @Test + void gateFailsClosedWhenTheEnvironmentIsGone() { + var request = approvedById(); + when(environmentRepository.findById(request.getEnvironmentId())).thenReturn(Optional.empty()); + + assertThat(service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()) + .releasable()).isFalse(); + } + + @Test + void breakGlassSkipsTheTicketConjunct() { + var request = approvedById(); + request.setSubmissionReason(SubmissionReason.EMERGENCY_ACCESS); + requireTicket(request); + + var view = service.gateByRequestId(request.getId(), ORG, request.getSubmittedBy(), Set.of()); + + assertThat(view.releasable()).isTrue(); + assertThat(view.changeTicketRequired()).isTrue(); + assertThat(view.changeTicket()).isNull(); + verify(ticketRepository, never()).findAllByDeploymentRequestId(any()); + } + + @Test + void confirmExecutionAuditsTheApprovedTicketKey() { + var request = approvedById(); + requireTicket(request); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())) + .thenReturn(List.of(ticket("APPROVED", "CHG-1", NOW))); + + service.confirmExecution(request.getId(), ORG, request.getSubmittedBy(), Set.of(), null); + + assertThat(executedMetadata(request)) + .containsEntry("trigger", "pipeline") + .containsEntry("change_ticket_required", true) + .containsEntry("change_ticket_key", "CHG-1") + .doesNotContainKey("change_ticket_bypassed"); + } + + @Test + void confirmExecutionAuditsTheBreakGlassTicketBypass() { + var request = approvedById(); + request.setSubmissionReason(SubmissionReason.EMERGENCY_ACCESS); + requireTicket(request); + + service.confirmExecution(request.getId(), ORG, request.getSubmittedBy(), Set.of(), null); + + assertThat(executedMetadata(request)) + .containsEntry("change_ticket_required", true) + .containsEntry("change_ticket_bypassed", "break_glass") + .doesNotContainKey("change_ticket_key"); + } + + @Test + void confirmExecutionRefusesAFlaggedRequestWithoutAnApprovedTicket() { + var request = approvedById(); + requireTicket(request); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())) + .thenReturn(List.of(ticket(null, "CHG-1", NOW))); + + assertThatThrownBy(() -> service.confirmExecution(request.getId(), ORG, + request.getSubmittedBy(), Set.of(), null)) + .isInstanceOf(DeploymentNotReleasableException.class); + verify(stateService, never()).apply(any(), any()); + } + + @Test + void markReleasableAnnouncesOnceAfterTheTicketFlips() { + var request = request(QueryStatus.APPROVED); + when(requestRepository.findById(request.getId())).thenReturn(Optional.of(request)); + stubNoFreeze(request); + requireTicket(request); + when(ticketRepository.findAllByDeploymentRequestId(request.getId())) + .thenReturn(List.of(ticket(null, "CHG-1", NOW))) + .thenReturn(List.of(ticket("APPROVED", "CHG-1", NOW))); + + assertThat(service.markReleasable(request.getId())).isFalse(); + assertThat(request.getReleaseNotifiedAt()).isNull(); + assertThat(service.markReleasable(request.getId())).isTrue(); + assertThat(service.markReleasable(request.getId())).isFalse(); + + verify(eventPublisher).publishEvent(any(DeploymentReleasableEvent.class)); + assertThat(request.getReleaseNotifiedAt()).isEqualTo(NOW); + } + // --- gate by request id ------------------------------------------------------------------ @Test @@ -379,7 +583,8 @@ void confirmExecutionMovesAnApprovedReleasableRequestToExecuted() { verify(auditWriter).record(eq(AuditAction.DEPLOYMENT_EXECUTED), eq(AuditResourceType.DEPLOYMENT_REQUEST), eq(request.getId()), eq(ORG), eq(request.getSubmittedBy()), metadata.capture(), eq("1.2.3.4")); - assertThat(metadata.getValue()).containsEntry("trigger", "pipeline"); + assertThat(metadata.getValue()).containsEntry("trigger", "pipeline") + .doesNotContainKey("change_ticket_required"); verify(requestService).detailView(request); } @@ -542,6 +747,46 @@ private DeploymentRequestEntity request(QueryStatus status) { return entity; } + private DeploymentRequestEntity approvedById() { + var request = request(QueryStatus.APPROVED); + when(requestRepository.findByIdAndOrganizationId(request.getId(), ORG)) + .thenReturn(Optional.of(request)); + stubNoFreeze(request); + return request; + } + + private void requireTicket(DeploymentRequestEntity request) { + var environment = environment(request.getPipelineId()); + environment.setId(request.getEnvironmentId()); + environment.setChangeTicketRequired(true); + when(environmentRepository.findById(request.getEnvironmentId())) + .thenReturn(Optional.of(environment)); + } + + private static DeploymentTicketEntity ticket(String decision, String key, Instant createdAt) { + var ticket = new DeploymentTicketEntity(); + ticket.setId(UUID.randomUUID()); + ticket.setTicketSystem("SERVICENOW"); + ticket.setExternalKey(key); + ticket.setStatus("Assess"); + ticket.setDecision(decision); + ticket.setCreatedAt(createdAt); + return ticket; + } + + private static List tickets(String... decisions) { + return Arrays.stream(decisions).map(d -> ticket(d, "CHG", NOW)).toList(); + } + + @SuppressWarnings("unchecked") + private Map executedMetadata(DeploymentRequestEntity request) { + var metadata = ArgumentCaptor.forClass(Map.class); + verify(auditWriter).record(eq(AuditAction.DEPLOYMENT_EXECUTED), + eq(AuditResourceType.DEPLOYMENT_REQUEST), eq(request.getId()), eq(ORG), + eq(request.getSubmittedBy()), metadata.capture(), any()); + return metadata.getValue(); + } + private void stubNoFreeze(DeploymentRequestEntity request) { when(freezeWindowEvaluator.evaluate(request.getOrganizationId(), request.getPipelineId(), request.getEnvironmentId())).thenReturn(Optional.empty()); diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationServiceTest.java index 6f89a965b..fb8b9d7fa 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentSimulationServiceTest.java @@ -519,6 +519,28 @@ void aPastScheduleOnAnApprovedUnfrozenDeploymentOpensTheGate() { var gate = step(result.steps(), DeploymentDecisionStepKind.GATE_RELEASABILITY); assertThat(gate.outcome()).isEqualTo(StepOutcome.ALLOW); assertThat(gate.details()).containsEntry("releasable", true) + .containsEntry("status", "APPROVED") + .containsEntry("frozen", false) + .containsEntry("change_ticket_required", false); + } + + @Test + void aChangeTicketEnvironmentNeverSimulatesReleasable() { + // A hypothetical request has no linked ticket, so the #993 conjunct is unmet. + var flagged = new DeploymentEnvironmentView(environmentId, pipelineId, "production", 1, + true, null, null, false, null, List.of(), null, true); + when(pipelineLookupService.findEnvironment(pipelineId, environmentId)) + .thenReturn(Optional.of(flagged)); + when(decisionEvaluator.evaluate(any())).thenReturn(new DeploymentDecision( + DeploymentDecisionKind.ENVIRONMENT_APPROVED, QueryStatus.APPROVED, null, null, + decision(QueryStatus.APPROVED).trace())); + + var result = service.simulate(organizationId, input()); + + assertThat(result.releasable()).isFalse(); + var gate = step(result.steps(), DeploymentDecisionStepKind.GATE_RELEASABILITY); + assertThat(gate.outcome()).isEqualTo(StepOutcome.DENY); + assertThat(gate.details()).containsEntry("change_ticket_required", true) .containsEntry("status", "APPROVED") .containsEntry("frozen", false); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketServiceTest.java index 71ba8724d..7356e376f 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketServiceTest.java @@ -136,12 +136,29 @@ void updateStatusRestampsAChangedDecision() { } @Test - void updateStatusWithNullDecisionClearsIt() { + void updateStatusWithNullDecisionKeepsAnApproval() { var entity = ticket("APPROVED", EARLIER); when(repository.findByChannelIdAndExternalId(channelId, "sys-id-1")) .thenReturn(Optional.of(entity)); when(repository.save(entity)).thenReturn(entity); + var view = service.updateStatus(channelId, "sys-id-1", "Implement", null, null) + .orElseThrow(); + + assertThat(view.status()).isEqualTo("Implement"); + assertThat(view.resolution()).isNull(); + assertThat(view.decision()).isEqualTo("APPROVED"); + assertThat(view.decidedAt()).isEqualTo(EARLIER); + assertThat(view.updatedAt()).isEqualTo(NOW); + } + + @Test + void updateStatusWithNullDecisionClearsARejection() { + var entity = ticket("REJECTED", EARLIER); + when(repository.findByChannelIdAndExternalId(channelId, "sys-id-1")) + .thenReturn(Optional.of(entity)); + when(repository.save(entity)).thenReturn(entity); + var view = service.updateStatus(channelId, "sys-id-1", "In Progress", null, null) .orElseThrow(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java index 42e8a7087..68b069976 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java @@ -421,7 +421,7 @@ void gateResponseNestsApprovalsAndMapsDecisions() { DecisionType.APPROVED, "lgtm", 1, Instant.parse("2026-08-24T11:00:00Z")); var view = new DeploymentGateView(requestId, QueryStatus.APPROVED, true, 2, 1, List.of(decision), true, "change freeze", - Instant.parse("2026-08-24T12:00:00Z"), RiskLevel.LOW); + Instant.parse("2026-08-24T12:00:00Z"), RiskLevel.LOW, false, null); var response = DeploymentGateResponse.from(view); @@ -434,12 +434,14 @@ void gateResponseNestsApprovalsAndMapsDecisions() { assertThat(response.frozen()).isTrue(); assertThat(response.freezeReason()).isEqualTo("change freeze"); assertThat(response.aiRiskLevel()).isEqualTo(RiskLevel.LOW); + assertThat(response.changeTicketRequired()).isFalse(); + assertThat(response.changeTicket()).isNull(); } @Test void gateViewDefendsItsDecisionsList() { var view = new DeploymentGateView(UUID.randomUUID(), QueryStatus.APPROVED, false, 1, 0, - null, false, null, null, null); + null, false, null, null, null, false, null); assertThat(view.decisions()).isEmpty(); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java index 29c94ff6a..7da19a044 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java @@ -12,10 +12,13 @@ import com.bablsoft.accessflow.deploygov.api.DeploymentOutcome; import com.bablsoft.accessflow.deploygov.api.DeploymentOutcomeService; import com.bablsoft.accessflow.deploygov.api.DeploymentRequestView; +import com.bablsoft.accessflow.deploygov.api.DeploymentTicketView; import com.bablsoft.accessflow.security.api.JwtClaims; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.security.core.Authentication; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.json.JsonMapper; import java.time.Instant; import java.util.List; @@ -23,6 +26,8 @@ import java.util.Set; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; @@ -60,6 +65,49 @@ void gateByTupleDelegatesWithTheCallerContext() { assertThat(response.aiRiskLevel()).isEqualTo(RiskLevel.LOW); } + @Test + void gateResponseCarriesTheChangeTicket() { + var ticket = new DeploymentTicketView(UUID.randomUUID(), orgId, requestId, UUID.randomUUID(), + "SERVICENOW", "DEPLOYMENT_SUBMITTED", "sys-1", "CHG0012345", + "https://acme.example/chg", "Implement", null, "APPROVED", null, null, null); + when(gateService.gateByRequestId(requestId, orgId, userId, Set.of())) + .thenReturn(gateView(true, true, ticket)); + + var response = controller.gate(null, null, null, requestId, auth()); + + assertThat(response.changeTicketRequired()).isTrue(); + assertThat(response.changeTicket()).isEqualTo(new DeploymentGateResponse.ChangeTicket( + "SERVICENOW", "CHG0012345", "https://acme.example/chg", "Implement", null, + "APPROVED")); + } + + @Test + void gateWireShapeKeepsThePreTicketFieldsAndAddsTheTicketOnes() { + // Mirrors spring.jackson: SNAKE_CASE naming, nulls omitted. + var mapper = JsonMapper.builder() + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion( + JsonInclude.Include.NON_NULL)) + .build(); + when(gateService.gateByRequestId(requestId, orgId, userId, Set.of())) + .thenReturn(gateView(false, true, null)); + + var json = mapper.readTree(mapper.writeValueAsString( + controller.gate(null, null, null, requestId, auth()))); + + assertThat(json.get("request_id").asString()).isEqualTo(requestId.toString()); + assertThat(json.get("status").asString()).isEqualTo("APPROVED"); + assertThat(json.get("releasable").asBoolean()).isFalse(); + assertThat(json.get("approvals").get("required").asInt()).isEqualTo(2); + assertThat(json.get("approvals").get("granted").asInt()).isEqualTo(2); + assertThat(json.get("decisions").isArray()).isTrue(); + assertThat(json.get("frozen").asBoolean()).isFalse(); + assertThat(json.get("scheduled_for").asString()).isEqualTo("2026-08-24T12:00:00Z"); + assertThat(json.get("ai_risk_level").asString()).isEqualTo("LOW"); + assertThat(json.get("change_ticket_required").asBoolean()).isTrue(); + assertThat(json.has("change_ticket")).isFalse(); + } + @Test void gateByRequestIdDelegates() { when(gateService.gateByRequestId(requestId, orgId, userId, Set.of())) @@ -128,8 +176,14 @@ private Authentication auth() { } private DeploymentGateView gateView(boolean releasable) { + return gateView(releasable, false, null); + } + + private DeploymentGateView gateView(boolean releasable, boolean ticketRequired, + DeploymentTicketView ticket) { return new DeploymentGateView(requestId, QueryStatus.APPROVED, releasable, 2, 2, List.of(), - false, null, Instant.parse("2026-08-24T12:00:00Z"), RiskLevel.LOW); + false, null, Instant.parse("2026-08-24T12:00:00Z"), RiskLevel.LOW, ticketRequired, + ticket); } private DeploymentRequestView requestView() { diff --git a/ci-templates/azure/accessflow-deployment.yml b/ci-templates/azure/accessflow-deployment.yml index fd3ead12d..4e4246945 100644 --- a/ci-templates/azure/accessflow-deployment.yml +++ b/ci-templates/azure/accessflow-deployment.yml @@ -142,7 +142,8 @@ steps: if [ "$cc" = "429" ]; then d=$(delay); echo " confirm rate limited (HTTP 429), retrying in ${d}s..."; sleep "$d"; continue; fi echo "##vso[task.logissue type=error]confirm-execution failed HTTP $cc: $cb"; exit 1 fi - echo " status=$st releasable=false, waiting ${int}s..."; sleep "$int" + tk=$(jq -r 'if .change_ticket_required == true then (if .change_ticket then .change_ticket.external_key + (if .change_ticket.decision then " " + .change_ticket.decision else "" end) else "none linked yet" end) else empty end' <<<"$gate") + echo " status=$st releasable=false${tk:+ change_ticket=$tk}, waiting ${int}s..."; sleep "$int" done echo "##vso[task.logissue type=error]timed out after $AF_WAIT_TIMEOUT waiting for deployment request $rid"; exit 1 displayName: "AccessFlow deployment gate" diff --git a/ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml b/ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml index 3303afd8f..a11cb9780 100644 --- a/ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml +++ b/ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml @@ -114,7 +114,8 @@ [ "$cc" = "429" ] && { d=$(delay); echo " confirm rate limited (HTTP 429), retrying in ${d}s…"; sleep "$d"; continue; } echo "confirm-execution failed HTTP $cc: $cb"; exit 1 fi - echo " status=$st releasable=false, waiting ${int}s…"; sleep "$int" + tk=$(jq -r "if .change_ticket_required == true then (if .change_ticket then .change_ticket.external_key + (if .change_ticket.decision then \" \" + .change_ticket.decision else \"\" end) else \"none linked yet\" end) else empty end" <<<"$gate") + echo " status=$st releasable=false${tk:+ change_ticket=$tk}, waiting ${int}s…"; sleep "$int" done echo "Timed out after $AF_WAIT_TIMEOUT waiting for deployment request $rid"; exit 1 ' diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 118f40691..f521da3f7 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -1948,7 +1948,7 @@ The hash chain (added in V26) is per organization. Inserts are serialized by a P | `REQUEST_GROUP_SUBMITTED` / `REQUEST_GROUP_APPROVED` / `REQUEST_GROUP_REJECTED` / `REQUEST_GROUP_EXECUTED` / `REQUEST_GROUP_PARTIALLY_EXECUTED` / `REQUEST_GROUP_FAILED` / `REQUEST_GROUP_TIMED_OUT` / `REQUEST_GROUP_CANCELLED` | A grouped request (AF-501) is submitted / approved / rejected / fully executed / stopped mid-sequence / failed / timed out / cancelled. Resource: `request_group` (resource id = the group id). Recorded alongside each member's own query/API audit row, so the group **and** each step are independently auditable. | | `AUDIT_SINK_CREATED` / `AUDIT_SINK_UPDATED` / `AUDIT_SINK_DELETED` | Admin creates / updates / deletes an external audit sink via the `/admin/audit-sinks` CRUD endpoints (#628). Resource: `audit_sink`. Written best-effort; metadata carries the sink name + type, never secrets. Deliveries themselves are deliberately **not** audited per batch — that would feed back into the stream being drained. | | `DEPLOYMENT_BREAK_GLASS_EXECUTED` | A break-glass deployment is force-approved (`PENDING_AI → APPROVED`, #692). Resource: `deployment_request`. Metadata: `pipeline_id`, `environment`, `version`, and the bypassed freeze window (`bypassed_freeze_window_id`, `bypassed_freeze_behavior`) when one was active. | -| `DEPLOYMENT_EXECUTED` | The pipeline confirms it proceeded once the gate answered releasable (`APPROVED → EXECUTED`, #693). Resource: `deployment_request`. Metadata: `trigger: "pipeline"`, `pipeline_id`, `environment`, `version`. | +| `DEPLOYMENT_EXECUTED` | The pipeline confirms it proceeded once the gate answered releasable (`APPROVED → EXECUTED`, #693). Resource: `deployment_request`. Metadata: `trigger: "pipeline"`, `pipeline_id`, `environment`, `version`; on a `change_ticket_required` environment also `change_ticket_required: true` and either `change_ticket_key` (the approved ticket's external key) or, for a break-glass request, `change_ticket_bypassed: "break_glass"` (#993). | | `DEPLOYMENT_OUTCOME_REPORTED` | The pipeline reports the post-execution outcome (#693). Resource: `deployment_request`. Metadata: `outcome`, `trigger: "pipeline"`, and `rollback_review_opened: true` when a `ROLLED_BACK` outcome opened a follow-up review. | | `DEPLOYMENT_ROLLBACK_REVIEWED` | A reviewer acknowledges a rollback follow-up review (#693). Resource: `deployment_rollback_review`. Metadata: `deployment_request_id`, `pipeline_id`, `submitted_by`. | | `DEPLOYMENT_SUBMITTED` | A governed deployment is triggered (#695). Resource: `deployment_request`. Actor = the submitter, `ip_address` = the trigger's caller IP. Metadata: `pipeline_id`, `environment`, `version`. Written for every non-replay, non-break-glass submission — including ones a freeze window rejects a moment later. | @@ -2982,7 +2982,9 @@ entity and no fixed semantics on tag values. shape as `allow_break_glass`. It marks an environment whose deployments must carry an approved external change ticket (see `deployment_tickets` below). Since #991 the flag makes a ServiceNow / Jira channel carrying the `DEPLOYMENT_SUBMITTED` trigger open a change ticket for the deployment; -the inbound decision sync (#992) and the gate conjunct (#993) land in later sub-issues of the epic. +since #992 the inbound decision sync stamps `deployment_tickets.decision`, and since #993 the +deployment gate refuses to release a flagged environment's request until one linked ticket is +`APPROVED` and none is `REJECTED` (break-glass exempt, audited). ### deployment_freeze_windows @@ -3168,9 +3170,13 @@ for unambiguous inbound resolution; `idx_deployment_tickets_request` on `deploym **Decision stamping.** "Approved ticket" is a persisted fact on the row, not a live evaluation of channel config: every status update writes `decision` as mapped by the caller from the channel's -approve/reject status lists (a status in neither list writes `NULL`). `decided_at` is set to the -update time when `decision` changes to a non-null value, kept when the same decision is re-sent, -and cleared with it — a reopened ticket no longer counts as decided. +approve/reject status lists (a status in neither list maps to `NULL`). **An `APPROVED` stamp is +sticky** (#993): a later status that maps to `NULL` leaves it — and `decided_at` — in place, because +a change keeps moving after approval (*Scheduled* → *Implement* → *Closed*) and the gate must not +close behind it; only a `REJECTED` mapping overrides an approval. `decided_at` is set to the update +time when `decision` changes to a non-null value, kept when the same decision is re-sent, and +cleared when a `NULL` mapping clears a `REJECTED` stamp — a reopened rejected ticket no longer counts +as decided. --- diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 72d226b1b..1fe3167d1 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -5866,7 +5866,7 @@ rule as the other two endpoints. | | `effective_min_approvals` | only when the deployment would go to review — the auto-approved and routing-decided paths resolve no count | | `ELIGIBLE_REVIEWERS` | `submitter_excluded`, `reviewers[]` or `plan_approvers[]` | when the request would reach review | | `SCHEDULED_RELEASE` | `scheduled_for`, `evaluated_at` | always (`scheduled_for` omitted when none was supplied) | -| `GATE_RELEASABILITY` | `releasable`, `status`, `frozen`, `scheduled_for` | always | +| `GATE_RELEASABILITY` | `releasable`, `status`, `frozen`, `scheduled_for`, `change_ticket_required` | always | | `BREAK_GLASS` | `can_break_glass`, `environment_allows_break_glass` | always | **`FREEZE_WINDOW` reports the submission-time effect; `GATE_RELEASABILITY` reports the release-time @@ -5876,10 +5876,13 @@ withholds releasability at the second. An unevaluable window definition counts a fail-closed — and is reported as such rather than as "no freeze". **`GATE_RELEASABILITY` is the real gate function.** The step calls the same -`releasable(status, frozen, scheduled_for, at)` that +`releasable(status, frozen, scheduled_for, at, change_ticket_required, ticket_approved)` that `GET /api/v1/deployment-gate` blocks on — not a reimplementation — so the top-level `releasable` in the response is what the pipeline would actually see at `at`. It is `false` for any request that has not reached `APPROVED`, which is the common case in a trace and is not an error. +A hypothetical request has no linked change ticket yet, so on an environment with +`change_ticket_required = true` the trace always answers `releasable: false` (#993) — exactly what a +freshly triggered request reads at the gate until its ticket is approved. **No delegation.** `ELIGIBLE_REVIEWERS` reports the plan's approver rules or the `DEPLOYMENT_REVIEW` holders, minus the submitter. Review delegation (#622) deliberately does not extend to deployments, so @@ -9122,7 +9125,7 @@ caller for every rule below. | Method | Path | Description | |--------|------|-------------| | `GET` | `/deployment-gate` | Poll releasability. Query params (snake_case): either `request_id=` **or** all three of `pipeline=`, `version=`, `environment=` (any other combination is a `400`). Tuple resolution is case-insensitive on the pipeline and environment names and picks the **newest** request for the tuple. `404 DEPLOYMENT_PIPELINE_NOT_FOUND` / `DEPLOYMENT_ENVIRONMENT_NOT_FOUND` / `DEPLOYMENT_REQUEST_NOT_FOUND` for an unknown pipeline, environment, tuple or id — and also when the request exists but the caller may not see it (never `403`; the CI wrappers treat any `404` as **not releasable**). Visibility: the submitter, a `can_trigger` grant holder on the pipeline, or a `DEPLOYMENT_REVIEW` / `QUERY_ADMIN` holder. | -| `POST` | `/deployment-requests/{id}/confirm-execution` | The pipeline acknowledges it proceeded once the gate answered `releasable: true`; transitions `APPROVED → EXECUTED` (`200`, the full request body; audit action `DEPLOYMENT_EXECUTED`, metadata `trigger=pipeline`). Only the submitter, a `can_trigger` holder on the pipeline, or `QUERY_ADMIN` may confirm (`403 DEPLOYMENT_REQUEST_PERMISSION_DENIED`); only from `APPROVED` (`409 DEPLOYMENT_REQUEST_INVALID_STATE`) **and** currently releasable (`409 DEPLOYMENT_NOT_RELEASABLE`). Idempotent: confirming an already-`EXECUTED` request returns `200` and changes nothing. | +| `POST` | `/deployment-requests/{id}/confirm-execution` | The pipeline acknowledges it proceeded once the gate answered `releasable: true`; transitions `APPROVED → EXECUTED` (`200`, the full request body; audit action `DEPLOYMENT_EXECUTED`, metadata `trigger=pipeline`; on a `change_ticket_required` environment also `change_ticket_required=true` and either `change_ticket_key` or, for break-glass, `change_ticket_bypassed=break_glass` — #993). Only the submitter, a `can_trigger` holder on the pipeline, or `QUERY_ADMIN` may confirm (`403 DEPLOYMENT_REQUEST_PERMISSION_DENIED`); only from `APPROVED` (`409 DEPLOYMENT_REQUEST_INVALID_STATE`) **and** currently releasable (`409 DEPLOYMENT_NOT_RELEASABLE`). Idempotent: confirming an already-`EXECUTED` request returns `200` and changes nothing. | | `POST` | `/deployment-requests/{id}/outcome` | Report what happened after execution. Body `{ "outcome": "SUCCEEDED" \| "FAILED" \| "ROLLED_BACK", "detail": "…" }` (`detail` optional, ≤4000). Same actor rule as confirm-execution. Only after `EXECUTED` (`409 DEPLOYMENT_REQUEST_INVALID_STATE`). **Idempotent**: repeating the same `outcome` returns `200` and changes nothing; a different `outcome` is `409 DEPLOYMENT_OUTCOME_CONFLICT`. A `FAILED` outcome also flips the request `EXECUTED → FAILED`. Audit action `DEPLOYMENT_OUTCOME_REPORTED`. | **Gate response:** @@ -9137,12 +9140,37 @@ caller for every rule below. "frozen": false, "freeze_reason": null, "scheduled_for": null, - "ai_risk_level": "LOW" + "ai_risk_level": "LOW", + "change_ticket_required": true, + "change_ticket": { + "system": "SERVICENOW", + "external_key": "CHG0012345", + "url": "https://acme.service-now.com/change_request.do?sys_id=…", + "status": "Implement", + "decision": "APPROVED" + } } ``` +`change_ticket_required` and `change_ticket` (#993) are additive — a pre-#993 CI wrapper that +branches on `releasable` alone keeps working. `change_ticket_required` mirrors the environment's +`change_ticket_required` flag (it reads `false` on the fail-closed error path, which makes no +claim — like `frozen`); `change_ticket` is the linked ticket the gate judged by — the +newest rejected one when one exists (it is the blocker), else the newest approved one, otherwise the +newest — and is omitted when none is linked, when the +flag is off, or for a break-glass request (nulls are not serialised). `decision` is `APPROVED`, +`REJECTED` or absent (not yet resolved). + **Releasability is one pure, fail-closed function**: -`releasable = status == APPROVED && !frozen && (scheduled_for == null || scheduled_for <= now)`. +`releasable = status == APPROVED && !frozen && (scheduled_for == null || scheduled_for <= now) +&& (!change_ticket_required || ticket_approved)`. +`ticket_approved` (#993) holds when **at least one** linked change ticket has +`decision = APPROVED` and **none** has `decision = REJECTED`; it is computed from the +`deployment_tickets` rows only, never from a channel's configuration. No linked ticket, only +unresolved tickets, or a failed lookup all answer `releasable: false` — so a flagged environment +whose request no ticketing channel opened a ticket for **never releases**. Break-glass requests skip +this conjunct exactly as they skip the freeze check, and the bypass is recorded on the +`DEPLOYMENT_EXECUTED` audit row (`change_ticket_bypassed = break_glass`). `frozen` is true when **any** enabled freeze window (`HOLD` *or* `REJECT` behaviour) is active for the request's `(pipeline, environment)` — deliberately stricter than "HOLD only": a `REJECT` window normally auto-rejects at submission, but a request approved *before* the window started diff --git a/docs/05-backend.md b/docs/05-backend.md index e1a1488c5..c6eeb8288 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -5488,11 +5488,15 @@ swallowed so one poisoned row cannot abort the batch. `FreezeWindowEvaluator` and `EffectiveDeploymentPermissionResolver`) answers `GET /api/v1/deployment-gate`. Releasability is one **pure, fail-closed function** (`DefaultDeploymentGateService.releasable`): -`status == APPROVED && !frozen && (scheduled_for == null || scheduled_for <= now)`. `frozen` means +`status == APPROVED && !frozen && (scheduled_for == null || scheduled_for <= now) +&& (!change_ticket_required || ticket_approved)`. The fourth conjunct (#993) is read from the +environment's `change_ticket_required` flag and the request's `deployment_tickets` rows only — at +least one `decision = APPROVED`, none `REJECTED`; no row or only unresolved rows is not releasable — +and never from a channel's configuration. `frozen` means **any** active freeze window — `HOLD` *or* `REJECT`, deliberately stricter than "HOLD only", because a request approved *before* a `REJECT` window started must not sail through mid-freeze — except for break-glass requests (`submission_reason = EMERGENCY_ACCESS`), which skip the freeze -lookup entirely. The whole evaluation is wrapped so any `RuntimeException` answers not-releasable: +lookup and the ticket conjunct entirely. The whole evaluation is wrapped so any `RuntimeException` answers not-releasable: no code path answers "releasable" by default. Tuple resolution (pipeline name → environment name → newest request, case-insensitive) 404s at every miss, and so does the visibility predicate (submitter ∨ `can_trigger` ∨ `DEPLOYMENT_REVIEW` ∨ `QUERY_ADMIN`) — never a 403, so an @@ -5501,7 +5505,8 @@ under-permissioned poll reads exactly like an unknown tuple and the CI wrappers `POST /deployment-requests/{id}/confirm-execution` is the pipeline's acknowledgment: `APPROVED → EXECUTED` through the state service, gated on the same releasable function (`409 DEPLOYMENT_NOT_RELEASABLE` on an approved-but-gated request), idempotent on a repeat, and -audited as `DEPLOYMENT_EXECUTED` with `trigger=pipeline`. The mutating endpoints use an **actor** +audited as `DEPLOYMENT_EXECUTED` with `trigger=pipeline` (plus `change_ticket_required` and +`change_ticket_key`, or `change_ticket_bypassed=break_glass`, on a flagged environment). The mutating endpoints use an **actor** rule (submitter ∨ `can_trigger` holder ∨ `QUERY_ADMIN`) with a `403` — unlike the gate read, acting on a request is a permission matter, not an existence probe. diff --git a/docs/08-notifications.md b/docs/08-notifications.md index c388edd01..89bdc49fe 100644 --- a/docs/08-notifications.md +++ b/docs/08-notifications.md @@ -455,7 +455,7 @@ POST /api/v1/integrations/jira/webhook/{channelId} **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: -- 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 — a later callback whose status matches neither list resets it to `null` and clears `decided_at`. The stamp is the input for the change-ticket gate conjunct (#993, not yet built), which will read 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. +- 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. - **The ticket `actor` is free text** that AccessFlow cannot map to a user, so a submitter who approves their own change ticket in ServiceNow or Jira is **not** stopped by the self-approval rule. Organisations that need that guarantee should keep `bidirectional_sync=false` on the channel and decide deployments in AccessFlow. diff --git a/docs/18-deployment-governance.md b/docs/18-deployment-governance.md index ab98b3b18..4cbbb48df 100644 --- a/docs/18-deployment-governance.md +++ b/docs/18-deployment-governance.md @@ -268,12 +268,15 @@ construction**. Ask it either by `request_id`, or by the full tuple `pipeline` + Releasability is one pure function whose default return is *not* releasable: ```java -if (status != QueryStatus.APPROVED || frozen) { return false; } +if (status != QueryStatus.APPROVED || frozen || (ticketRequired && !ticketSatisfied)) { + return false; +} return scheduledFor == null || !scheduledFor.isAfter(now); ``` -Three conjuncts, all required: the status is **exactly `APPROVED`**, **no freeze window is -active**, and **`scheduled_for` is null or already passed**. Everything else answers +Four conjuncts, all required: the status is **exactly `APPROVED`**, **no freeze window is +active**, **`scheduled_for` is null or already passed**, and — on an environment with +`change_ticket_required = true` — **an approved change ticket is linked** (#993). Everything else answers `releasable: false` — a `PENDING_REVIEW` request, a rejected one, an unknown pipeline / version / environment tuple, a caller who may not see the request, and **any internal error**: the freeze evaluation is wrapped so that a thrown exception is logged and degrades to *not releasable*, never @@ -281,6 +284,30 @@ to an accidental yes. Note the error path reports `frozen: false` with no `freez makes no claim about a window it could not evaluate — so `releasable`, not `frozen`, is the field a pipeline branches on. +**The change-ticket conjunct.** It is computed from the request's `deployment_tickets` rows only, +never from a channel's configuration: satisfied when **at least one** linked ticket has +`decision = APPROVED` and **none** has `decision = REJECTED`. No linked ticket, tickets that are +still unresolved, or a failed lookup all answer *not releasable*. The honest consequence: a flagged +environment whose deployments no ServiceNow / Jira channel opens a ticket for — no ticketing +channel, or none carrying the `DEPLOYMENT_SUBMITTED` trigger — **never releases**, and a request +approved before its ticket flips simply waits (the release job announces it once the ticket is +approved). Break-glass requests skip this conjunct exactly as they skip the freeze check, and the +bypass is recorded on the `DEPLOYMENT_EXECUTED` audit row as `change_ticket_bypassed = break_glass`; +a normal confirmation on a flagged environment records `change_ticket_key` instead. The response +adds `change_ticket_required` and `change_ticket` (`system`, `external_key`, `url`, `status`, +`resolution`, `decision` — a rejected ticket first, else an approved one, else the newest; omitted when +none is linked), and the CI wrappers add `change_ticket=` plus the ticket's decision once it has one (the +GitHub action also prints the ticket status) or `change_ticket=none linked yet` to their waiting line. AccessFlow only learns a +ticket's verdict from the ticketing system's signed callback (#992), so without that callback a +linked ticket never reads as approved, and a `REJECTED` ticket blocks the request for good (the +response then shows the rejected ticket). An approval is **sticky**: a later callback whose status +matches neither the channel's approve nor reject list (a change moving on to *Scheduled* / +*Implement* / *Closed*) keeps the ticket `APPROVED`, so a release that opened stays open; only a +status in the reject list overrides it, and that closes the gate again. The environment +row is read before the break-glass shortcut, so a request whose environment was deleted is not +releasable — break-glass included. A [decision trace](#10-decision-trace-af-967) for a flagged environment always reports *not releasable*: +a hypothetical request has no ticket yet. + **`frozen` means any active window, `HOLD` or `REJECT`.** A `REJECT` window normally auto-rejects at submission, but a request approved *before* the window opened must not sail through mid-freeze. Break-glass requests skip the freeze check entirely — they already bypassed it, audibly, at @@ -289,8 +316,8 @@ submission. **Visibility failures are `404`, never `403`.** An under-permissioned poll must be indistinguishable from an unknown tuple, and the CI wrappers treat both identically: not releasable. The response carries `status`, `releasable`, `approvals: { required, granted }`, the -decisions, `frozen` + `freeze_reason`, `scheduled_for` and `ai_risk_level` — enough for a job to -log *why* it is still waiting. +decisions, `frozen` + `freeze_reason`, `scheduled_for`, `ai_risk_level` and the change-ticket +fields — enough for a job to log *why* it is still waiting. Two resolution details matter when a name is ambiguous. Pipeline names are unique per organization **case-sensitively**, so `deploy-api` and `Deploy-API` may coexist; the gate looks up @@ -527,11 +554,13 @@ either. A `REJECT` window auto-rejects at the trigger and denies the first; a `H trigger through and withholds releasability at the second. An unevaluable window definition counts as an active `HOLD` — fail-closed — and is reported as such rather than as "no freeze". -**The gate stage calls the real function.** `releasable(status, frozen, scheduled_for, at)` is the -same package-private static that [`GET /deployment-gate`](#5-the-deployment-gate) blocks on, not a -reimplementation, so the response's top-level `releasable` is what the CI job would actually see. It -is `false` for anything short of an approved, unfrozen, due release — the common case in a trace, and -not an error. +**The gate stage calls the real function.** `releasable(status, frozen, scheduled_for, at, +change_ticket_required, ticket_approved)` is the same package-private static that +[`GET /deployment-gate`](#5-the-deployment-gate) blocks on, not a reimplementation, so the response's +top-level `releasable` is what the CI job would actually see. It is `false` for anything short of an +approved, unfrozen, due release — the common case in a trace, and not an error. A hypothetical request +has no linked change ticket, so on a `change_ticket_required` environment it is always `false`; the +step's `change_ticket_required` detail says why. **Read-only, structurally.** No `deployment_requests` row, no event, no notification, no AI call, and no audit row of its own beyond the `ACCESS_SIMULATION_RUN` one the endpoint writes against the @@ -553,7 +582,10 @@ Every transition lands in the INSERT-only `audit_log`: `DEPLOYMENT_SUBMITTED`, `DEPLOYMENT_APPROVED`, `DEPLOYMENT_REJECTED`, `DEPLOYMENT_TIMED_OUT`, `DEPLOYMENT_CANCELLED`, `DEPLOYMENT_EXECUTED`, `DEPLOYMENT_OUTCOME_REPORTED`, `DEPLOYMENT_BREAK_GLASS_EXECUTED`, `DEPLOYMENT_BREAK_GLASS_REVIEWED` and `DEPLOYMENT_ROLLBACK_REVIEWED`, over the resource types -`deployment_request` and `deployment_rollback_review`. Reviewer decisions are audited **per +`deployment_request` and `deployment_rollback_review`. Change tickets add `TICKET_CREATED` and +`TICKET_STATUS_SYNCED` rows over the `deployment_ticket` resource type (#991, #992), and on a +`change_ticket_required` environment `DEPLOYMENT_EXECUTED` also carries `change_ticket_required` +plus `change_ticket_key` — or `change_ticket_bypassed = break_glass` (#993). Reviewer decisions are audited **per verdict, before quorum**, so the record shows who decided what and when, not merely the outcome. System-attributed rows carry a **null actor** plus a `trigger` key naming the mechanism — `routing` (with `policy_id`), `environment_policy`, `freeze` (with `freeze_window_id`), `timeout`, diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index 90ff7567e..b696783bb 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -291,7 +291,7 @@ {"id":"982d6456c058e3bc","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-rowsecurity","anchor":"guide-datasource-rowsecurity","title":"5. Limit which rows come back","section":"Guides","order":0,"tokens":617,"text":"AccessFlow Docs > Guides > Add your first datasource > 5. Limit which rows come back\n\nThe Row security tab filters rows rather than columns. A policy names a\ntable and a column, an operator, and a value to compare against — either a literal, or a\nvariable resolved per user such as their region attribute, their role, or their groups.\n\nAccessFlow applies it by rewriting the query: a SELECT gets its table wrapped in a\nfiltered subquery, an UPDATE or DELETE gets the predicate added to its WHERE clause. The\ncomparison value is always bound as a parameter, never concatenated into the SQL.\n\nRow security fails closed, in both directions. If the per-user value\ncannot be resolved — the attribute is missing, the list is empty — the policy matches\nnothing and the user sees zero rows rather than everything. And a query whose shape\ncannot be safely rewritten, such as one using set operations or common table expressions\nover a protected table, is rejected rather than run unfiltered. Expect\nsome legitimate queries to be refused; that is the trade being made.\n\nNon-relational engines each fail closed in their own way. Redis, for instance, cannot\nexpress row security at all, so a policy over it denies rather than degrades; Cassandra\nrefuses policies on non-key columns instead of quietly scanning the whole table.\n\nDatasource settings → Row security — a per-table predicate compared against a per-user value.\n\nUnlike masking, row security applies to everyone by default: leaving\nApplies to empty means the policy is in force for all users. Narrow it\nto specific roles or groups if that is not what you want.\n\nTo limit how many rows come back rather than which, use the\nRow limits tab. A row limit caps the rows a query may return from one\ntable, for everyone or for chosen roles, groups or people, so the orders\ntable can stop at 200 rows while customer stops at 1,000. Limits only ever\ntighten: the datasource's own maximum still applies, a query across several limited\ntables gets the lowest limit, and a query that leaves the schema off the table name, or\nadds the database name, is still caught."} {"id":"7f8bb8096e66cddd","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-operate","anchor":"guide-datasource-operate","title":"6. Keep an eye on it","section":"Guides","order":0,"tokens":162,"text":"AccessFlow Docs > Guides > Add your first datasource > 6. Keep an eye on it\n\nDatasource health in the admin section shows connection-pool use, query\nvolume, error counts and execution latency per datasource over the last day. It is the\nfirst place to look when queries start timing out — an exhausted pool looks very\ndifferent from a slow database, and the page distinguishes them.\n\nField-by-field reference for every engine, the full policy options and the tuning env\nvars: Datasources. Engine-specific\nbehaviour and install detail: Connectors."} {"id":"f03f4ea29c27aa1d","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-approval","anchor":"guide-deployment-approval","title":"What you are building","section":"Guides","order":0,"tokens":262,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > What you are building\n\nBy the end of this guide a deploy job stops and waits. It asks AccessFlow for\npermission to release, a human approves or rejects it, and only then does the job run\nhelm upgrade — or whatever your deploy step actually is. Every release\nleaves a request, a named approver and an audit trail behind it.\n\nThe eight steps below are the order an operator actually performs them in, and several\nof them are not guessable from the\nreference\npage. Budget about twenty minutes. You need an admin account on a running\nAccessFlow instance, and a CI pipeline you can edit.\n\nNo Deployments section in your sidebar? Your organization has\ndeployment governance switched off — the default unless someone turned it on during\nsetup. An admin turns it back on at\n/admin/governance-domains\n(Organizations &\nquotas), and the menus appear immediately."} -{"id":"f032bfb2677b59df","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/","anchor":"","title":"How do I gate a deployment behind approval?","section":"Guides","order":0,"tokens":242,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > How do I gate a deployment behind approval?\n\nCreate a service account for the CI job, give it an API key and a can-trigger grant on\na deployment pipeline, attach a review plan that says who may approve, then add the two\nAccessFlow steps to your workflow around your existing deploy step. The first step\nblocks until someone approves; the second reports how the deploy went.\n\nThe gate is fail-closed. A deployment is releasable only when its\nstatus is APPROVED, no freeze window is active, and any scheduled time has\npassed. Anything else — an error, an unreachable instance, a request the key cannot see\n— answers not releasable, and the CI wrappers fail the job. That is the point,\nbut it has a consequence worth accepting before you start: if AccessFlow is\ndown, your deployments stop."} +{"id":"f032bfb2677b59df","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/","anchor":"","title":"How do I gate a deployment behind approval?","section":"Guides","order":0,"tokens":269,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > How do I gate a deployment behind approval?\n\nCreate a service account for the CI job, give it an API key and a can-trigger grant on\na deployment pipeline, attach a review plan that says who may approve, then add the two\nAccessFlow steps to your workflow around your existing deploy step. The first step\nblocks until someone approves; the second reports how the deploy went.\n\nThe gate is fail-closed. A deployment is releasable only when its\nstatus is APPROVED, no freeze window is active, any scheduled time has\npassed, and — on an environment that requires a change ticket — a linked change ticket\nhas been approved. Anything else — an error, an unreachable instance, a request the key cannot see\n— answers not releasable, and the CI wrappers fail the job. That is the point,\nbut it has a consequence worth accepting before you start: if AccessFlow is\ndown, your deployments stop."} {"id":"798f9e2db8350c58","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-account","anchor":"guide-deployment-account","title":"1. Create the CI service account","section":"Guides","order":0,"tokens":371,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 1. Create the CI service account\n\nThe API key's owner is the submitter of every deployment it opens, and\nnobody can approve their own deployment. So the CI identity must be an account that\nnever approves — not your admin account. AccessFlow has a dedicated kind of account for\nthis: a service account, which has no password, can never sign in, and\nonly ever authenticates with an API key.\n\n- Open the service accounts page. Sidebar → Security &\nAccess → Identity → Service accounts.\n\n- Create it. Click Create service account and fill\nEmail (an identifier only — nothing is sent to it) and\nDisplay name.\n\n- Keep the role narrow. Leave Role on\nRead-only — the right to trigger deployments comes from the\nper-pipeline grant in step 5, not from the role.\n\n- Optionally name an owner. The person accountable for this pipeline,\nso a reviewer knows who to ask.\n\nManaging everything as code? Steps 1 and 2 can be declared instead,\nwith bootstrap.serviceAccounts[] — it creates the same kind of account and\nimports a key you generated yourself. Always set its role: when\nomitted it defaults to Admin. A later change is applied on the next restart. See Automate\nwith Terraform, or service-account API\nkeys for the full property list."} {"id":"d30a17c09c26c45a","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-key","anchor":"guide-deployment-key","title":"2. Issue its API key","section":"Guides","order":0,"tokens":270,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 2. Issue its API key\n\nCreating the account opens its settings page. You issue the key from there, as the\nadmin — nobody signs in as the service account.\n\n- Open its keys. The API keys tab →\nIssue key.\n\n- Name it and copy the value. Call it after the consumer, for example\ngithub-actions. The raw af_… key is shown once and never\nagain. Paste it straight into your CI secret store.\n\nThink twice before giving a pipeline key an expiry. The form's\noptional Expires field takes a date and time — the picker greys out anything in\nthe past — but the gate is fail-closed, so on the day it passes every deployment job\nstops until someone issues a new key. Leave it empty and the key lasts until you revoke\nit. To change it on a schedule, use Rotate instead: the old key keeps\nworking for a grace period (24 hours by default) while you swap the CI secret."} {"id":"ea71c3bac43f7d75","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-pipeline","anchor":"guide-deployment-pipeline","title":"3. Create the pipeline","section":"Guides","order":0,"tokens":284,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 3. Create the pipeline\n\nBack on your admin account: sidebar → Connections → Deployments →\nDeployment Pipelines → Add pipeline. Give it a name and pick the\nprovider that matches your CI system — GitHub Actions, GitLab CI, Azure Pipelines,\nJenkins, CircleCI, Bitbucket Pipelines, or generic.\n\nCopy the pipeline id now. Creating a pipeline lands you on its settings\npage, where the UUID sits under the pipeline name with a copy button beside it — that\nUUID is the pipeline id your CI job needs. The pipeline list carries the\nsame id in an ID column, shortened for the table but copying the whole UUID, so\nyou never have to open a pipeline or read the address bar just to grab it.\n\nThe pipeline is identified by that UUID rather than by name because a trigger-only API\nkey is not allowed to list pipelines, so it has nothing to resolve a name against.\n\n/admin/deployment-pipelines — one row per pipeline. Open one to reach its seven settings tabs."} @@ -300,7 +300,7 @@ {"id":"bf0199709e9562c8","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-approvers","anchor":"guide-deployment-approvers","title":"6. Restrict who may approve","section":"Guides","order":0,"tokens":614,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 6. Restrict who may approve\n\nDo not skip this step. The permission that lets somebody approve a\ndeployment is held by both ADMIN and REVIEWER. On any instance where\nreviewers exist who should not be shipping releases, a review plan that scopes approval\nis the only thing standing between them and your production deploy. It is mandatory,\nnot optional.\n\n- Create the plan. Sidebar → Security & Access →\nReview plans → Add review plan. Set\nMinimum approvals.\n\n- Add one approver row. Set Role to\nAdmin and Stage to 1.\n\n- Attach it. On the pipeline's General tab, pick it\nunder Review plan.\n\nThree things about approver rows regularly catch people out:\n\n- A plan with no approver rows restricts nothing. Attaching an empty\nplan looks like configuration but leaves the request open to everyone who holds the\ndeployment-review permission.\n\n- Deployment review is single-stage. Only rows at stage\n1 are consulted. A row at stage 2 or higher is ignored silently — no\nwarning, no error.\n\n- Review override bypasses the approver list entirely. It is an\nadmin-only permission and it is meant to work this way, which means the rule that\nultimately protects your pipeline is simply: do not make someone an admin unless they\nshould be allowed to ship.\n\nTo pin approval to one named person rather than to every admin, put their user id in\nthe row's User box. It is a plain text field expecting a UUID, and user\nids are not shown anywhere in the users screen — read one from\nGET /api/v1/admin/users.\n\nWhatever you configure, one rule cannot be configured away: the submitter can never\napprove their own deployment, admins and override holders included. That is why step 1\ninsisted on a separate account. If your pipeline also names the engineer who started it\n(the X-AccessFlow-On-Behalf-Of header, once they have agreed to it — an admin\nrecords that on the service account's On-behalf-of principals tab), that engineer cannot approve\nthe deployment either. The CI steps in this guide do not send that header; add it to your\nown request if you need it."} {"id":"ccb310152697cd07","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-ci","anchor":"guide-deployment-ci","title":"7. Add the gate to your pipeline","section":"Guides","order":0,"tokens":652,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 7. Add the gate to your pipeline\n\nThe pipeline's CI setup tab carries ready-made snippets with the\npipeline id already filled in. Copy from there rather than retyping the UUID. It offers\nfour, chosen from a toggle that always opens on GitHub Actions — it does not preselect\nthe provider you set in step 3: GitHub Actions, GitLab\nCI, Azure Pipelines and curl. Pipelines set\nto Jenkins, CircleCI, Bitbucket Pipelines or generic use the curl sequence.\n\nEvery provider follows the same four beats:\n\n- Submit a deployment request. It is idempotent on the CI run id, so\na retried job never opens a second review.\n\n- Poll the gate until it answers releasable.\n\n- Confirm execution, immediately before deploying.\n\n- Report the outcome once the deploy finishes.\n\n.github/workflows/deploy.yml\n\n- id: gate\nuses: bablsoft/accessflow/.github/actions/deployment-gate@v1\nwith:\naccessflow-url: https://accessflow.example.com\napi-key: ${{ secrets.ACCESSFLOW_API_KEY }}\npipeline-id: 1f0c9d02-1c2a-4a19-9f0e-6b2f8f1a4c77\nenvironment: production\nversion: ${{ github.ref_name }}\ncommit-sha: ${{ github.sha }}\nwait-timeout: 30m\n\n- run: ./deploy.sh\n\n- uses: bablsoft/accessflow/.github/actions/deployment-outcome@v1\nif: always()\nwith:\naccessflow-url: https://accessflow.example.com\napi-key: ${{ secrets.ACCESSFLOW_API_KEY }}\nrequest-id: ${{ steps.gate.outputs.request-id }}\njob-status: ${{ job.status }}\n\nThe outcome step is separate, and runs under if: always(), because a\ncomposite action has no post-run hook to report from. Pass an explicit\noutcome: ROLLED_BACK when you roll back — on an environment that requires\nreview that opens a follow-up the submitter is not allowed to close.\n\nGitLab CI, Azure Pipelines and the plain-curl sequence for anything else are documented\nalongside the full input reference in\nthe deployment gate in CI.\n\nPipeline settings → CI setup — snippets for GitHub Actions, GitLab CI, Azure Pipelines and curl, each carrying the pipeline id the job gates on."} {"id":"886799162c54ef43","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-run","anchor":"guide-deployment-run","title":"8. Your first gated release","section":"Guides","order":0,"tokens":257,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 8. Your first gated release\n\nRun the workflow. The gate step opens a request and then waits, printing its poll\nattempts. In AccessFlow the request appears for reviewers on the\nDeployments tab of the review queue (/reviews?tab=deployments,\nthe same page that holds query and API-request reviews), carrying the version, the\ntarget environment, the commit and the AI release-risk verdict if you enabled analysis. It never appears there\nfor the account that submitted it.\n\nAn approver opens it and approves. Within a poll interval the gate answers releasable,\nthe CI job unblocks, your deploy step runs, and the outcome step records how it went.\nThe submitter can watch the whole thing at /deployments.\n\nThe Deployments tab of the review queue — where an approver picks the release up. A deployment never appears here for its own submitter."} -{"id":"5b67475c4c415276","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/","anchor":"","title":"If the job fails instead","section":"Guides","order":0,"tokens":495,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > If the job fails instead\n\n- The gate returns 404. The key cannot see that request — usually a\nwrong pipeline id, or a key belonging to an account with no can-trigger grant.\nAccessFlow answers 404 rather than 403 on purpose, so a key cannot probe for pipelines\nit has no business knowing about.\n\n- It waits and then times out. Nobody approved inside\nwait-timeout, or a freeze window is holding the release. Check the\nrequest at /deployments — a held release resumes on its own when the\nwindow closes, with no re-approval.\n\n- Approved, but still not releasing. Approval alone is not a green\nlight. The job branches on releasable, which also requires no active freeze\nwindow and any scheduled time to have passed.\n\n- The log says \"rate limited (HTTP 429), retrying\". Not a failure.\nEvery API key has a per-identity request cap (120 per minute by default; a service\naccount can be given its own), and the wrappers simply wait the number of seconds\nthe server asks for before trying again. A pipeline that hits this constantly is\npolling too fast — lengthen the poll interval, or have an admin raise the account's\nlimit on its Limits tab.\n\nA real worked example. The AccessFlow public demo deploys itself\nthrough its own gate. The workflow, the exact one-time setup it needed and the recovery\npath when the instance is down are written up in\ndocs/09-deployment.md\n→ The public demo environment.\n\nOnce a few releases have gone out this way, /deployment-versions becomes\nuseful: it lists every environment you deploy to, the version each is running now, and\nhow far behind the newest release it has fallen — filterable by the tags you set in\nstep 4."} +{"id":"5b67475c4c415276","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/","anchor":"","title":"If the job fails instead","section":"Guides","order":0,"tokens":645,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > If the job fails instead\n\n- The gate returns 404. The key cannot see that request — usually a\nwrong pipeline id, or a key belonging to an account with no can-trigger grant.\nAccessFlow answers 404 rather than 403 on purpose, so a key cannot probe for pipelines\nit has no business knowing about.\n\n- It waits and then times out. Nobody approved inside\nwait-timeout, or a freeze window is holding the release. Check the\nrequest at /deployments — a held release resumes on its own when the\nwindow closes, with no re-approval.\n\n- Approved, but still not releasing. Approval alone is not a green\nlight. The job branches on releasable, which also requires no active freeze\nwindow and any scheduled time to have passed. If the environment requires a change\nticket, the waiting line includes change_ticket=…. A ticket key means the\nrelease waits until AccessFlow hears that the ticket was approved — which only happens\nthrough the ticketing system's callback, so set up\nbi-directional status sync on the\nchannel. A rejected ticket blocks the release for good; submit a new deployment.\nnone linked yet means no ticket was opened — check that a ServiceNow or\nJira channel carries the deployment trigger, or the release will never go out.\n\n- The log says \"rate limited (HTTP 429), retrying\". Not a failure.\nEvery API key has a per-identity request cap (120 per minute by default; a service\naccount can be given its own), and the wrappers simply wait the number of seconds\nthe server asks for before trying again. A pipeline that hits this constantly is\npolling too fast — lengthen the poll interval, or have an admin raise the account's\nlimit on its Limits tab.\n\nA real worked example. The AccessFlow public demo deploys itself\nthrough its own gate. The workflow, the exact one-time setup it needed and the recovery\npath when the instance is down are written up in\ndocs/09-deployment.md\n→ The public demo environment.\n\nOnce a few releases have gone out this way, /deployment-versions becomes\nuseful: it lists every environment you deploy to, the version each is running now, and\nhow far behind the newest release it has fallen — filterable by the tags you set in\nstep 4."} {"id":"e4ac3107700652aa","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-simulation","anchor":"guide-deployment-simulation","title":"Asking \"would this release be held?\" without a release","section":"Guides","order":0,"tokens":330,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > Asking \"would this release be held?\" without a release\n\nA deployment is blocked by the gate, not only by the decision — so knowing a\nrequest would be approved does not tell you it would ship. The pipeline's\nSimulate tab (or POST /api/v1/admin/deployment-simulations)\nreplays one hypothetical deployment through\nthe very evaluator and the very releasable function the CI job blocks on, and answers\nstage by stage: the trigger grant, the freeze window, routing policies, the environment's\nown policy, who could approve, the deferred-release moment and the gate's verdict. It\naccepts an optional at, so \"would a release this Friday evening be held?\" is\nanswerable now rather than on Friday evening, and the tab puts the gate's releasable\nverdict above the step-by-step trace. Nothing is created, nothing is notified,\nnothing reaches the pipeline; it needs the same permission that manages pipelines and\nevery run is recorded in the audit log.\n\nFrom here: deployment\npipelines covers freeze windows, routing policies, break-glass and rollback reviews\nin full."} {"id":"f687c17692f24406","path":"website/docs/guides/first-query/index.html","url":"https://accessflow.io/docs/guides/first-query/#guide-first-query","anchor":"guide-first-query","title":"What you are building","section":"Guides","order":0,"tokens":163,"text":"AccessFlow Docs > Guides > Run your first governed query > What you are building\n\nA query that a person writes, an AI scores, a human approves, and AccessFlow then runs\nagainst a real database — with the whole sequence recorded. It is the shape every other\npart of the product is built on, so it is worth doing once end to end before you\nconfigure anything else.\n\nAllow about half an hour. You need AccessFlow running (the\ninstall chapter covers Docker Compose, Helm and\nrunning from source) and a database you are willing to point it at — a scratch one is\nideal."} {"id":"b3a6ad0551ecec96","path":"website/docs/guides/first-query/index.html","url":"https://accessflow.io/docs/guides/first-query/","anchor":"","title":"How does a query get approved in AccessFlow?","section":"Guides","order":0,"tokens":209,"text":"AccessFlow Docs > Guides > Run your first governed query > How does a query get approved in AccessFlow?\n\nA user picks a datasource and writes SQL. AccessFlow parses it, checks it against that\nuser's permissions, optionally has an AI model score its risk, and then holds it for\nwhoever the datasource's review plan names as an approver. Once approved it runs through\nthe proxy, under masking, row limits and any row-level rules that apply.\n\nYou will need two accounts. Nobody can approve their own query — not\neven an admin. So this walkthrough uses your admin account as the approver and creates a\nsecond, ordinary account to submit from. That is not a quirk of the demo; it is the rule\nthe whole product rests on."} @@ -445,7 +445,7 @@ {"id":"98112acc14d1fbdc","path":"website/features/database-access-governance/index.html","url":"https://accessflow.io/features/database-access-governance/","anchor":"","title":"Which team is this for","section":"Features","order":0,"tokens":173,"text":"AccessFlow Docs > Features > Database access governance — every query reviewed before it runs > Which team is this for\n\nThe same checkpoint answers a different question depending on who is standing at it — a platform team removing shared credentials, an on-call engineer who needs production access at 03:00, a DBA drowning in review requests, or a privacy team that has to prove erasure happened. Platform engineering, SRE and on-call, DBAs and data platform, and privacy and data protection each get the long version. For where the credentials live and how the audit chain is built, see the security architecture."} {"id":"ffaa9605eb681cec","path":"website/features/deployment-governance/index.html","url":"https://accessflow.io/features/deployment-governance/","anchor":"","title":"Deployment approval governance — gate every CI/CD release","section":"Features","order":0,"tokens":167,"text":"AccessFlow Docs > Features > Deployment approval governance — gate every CI/CD release\n\nCapabilities\n\nAccessFlow puts a CI/CD release request through the same machinery as a database query —\nAI analysis, routing policies, named approvers, an audit trail — and gives your CI job a\ngate to block on that answers not releasable whenever it is unsure. Shipping to\nproduction is an access decision, and most pipelines record it as a protected-branch rule\nor a chat message, which is neither reviewable nor auditable after the fact.\n\nGet AccessFlow\n\nDeployment governance reference"} {"id":"ad46f113fc11f37c","path":"website/features/deployment-governance/index.html","url":"https://accessflow.io/features/deployment-governance/","anchor":"","title":"Pipelines, environments and who may trigger them","section":"Features","order":0,"tokens":444,"text":"AccessFlow Docs > Features > Deployment approval governance — gate every CI/CD release > Pipelines, environments and who may trigger them\n\nA deployment pipeline is the governed unit — a name, a provider (GitHub Actions, GitLab CI, Azure Pipelines, Jenkins, CircleCI, Bitbucket Pipelines, or generic), an optional repository reference, a review plan, and its own AI switch. Under it sit ordered environments: dev, staging, production. Each environment can require review, override the required approval count and the review plan, and opt in to break-glass, which is off by default.\n\nWho may trigger a release is a per-pipeline grant rather than a global role: a non-admin needs one, and an org admin short-circuits that check — but never the break-glass one. Grants exist per user and per group, each carrying trigger and break-glass flags and an optional expiry, and the effective answer is the most-permissive union of a user's direct grant and their unexpired group grants. The trigger endpoint, the gate and the outcome endpoint all resolve permission through that one path, so they cannot disagree. Administration needs a deployment-pipeline permission, reviewing needs a deployment-review permission, and every read is organization-scoped: an id from another organization returns 404, never 403, so no endpoint can be used to probe for what exists.\n\nDeleting a pipeline removes its configuration but not its history — past requests, decisions and rollback reviews survive as evidence."} -{"id":"afd9d76e668e0687","path":"website/features/deployment-governance/index.html","url":"https://accessflow.io/features/deployment-governance/","anchor":"","title":"The gate is fail-closed by construction","section":"Features","order":0,"tokens":575,"text":"AccessFlow Docs > Features > Deployment approval governance — gate every CI/CD release > The gate is fail-closed by construction\n\nA CI job opens a request by calling AccessFlow with an ordinary API key — no deployment-specific token. The key should belong to a service account, an identity of its own that can never sign in; whoever owns the key is the submitter, which is what makes the self-approval ban meaningful for a machine-triggered release. When the pipeline names the engineer who started it, that engineer cannot approve the release either. The trigger is idempotent on the CI run id at the database level, so a retried step never opens a second review.\n\nFrom there the request behaves like any other: the AI analyzer scores the release metadata, routing policies keyed on environment, provider, version glob, risk or time window can auto-approve, auto-reject or raise the approval bar, and reviewers decide in the UI — from the shared review queue, or from the deployment's own page once they have read its version, commit and justification. Review is single-stage, a rejection is immediately terminal, and self-approval is refused first and always — including for the submitting API key's owner, and including for an admin. Where the resolved review plan names approvers, only those users may decide.\n\nThe job then polls one endpoint and branches on one field. A release is releasable only when all three hold: the request is exactly approved, no freeze window is active, and any scheduled time has already passed. Everything else answers not releasable — a pending request, a rejected one, an unknown pipeline or version, a caller who may not see the request, and any internal error at all. A failure to evaluate degrades to a refusal, never to an accidental yes.\n\n/deployments/ — this one is still pending review, so the gate answers not releasable; approval is what flips it. An eligible reviewer decides right here; everyone else sees no buttons at all."} +{"id":"afd9d76e668e0687","path":"website/features/deployment-governance/index.html","url":"https://accessflow.io/features/deployment-governance/","anchor":"","title":"The gate is fail-closed by construction","section":"Features","order":0,"tokens":605,"text":"AccessFlow Docs > Features > Deployment approval governance — gate every CI/CD release > The gate is fail-closed by construction\n\nA CI job opens a request by calling AccessFlow with an ordinary API key — no deployment-specific token. The key should belong to a service account, an identity of its own that can never sign in; whoever owns the key is the submitter, which is what makes the self-approval ban meaningful for a machine-triggered release. When the pipeline names the engineer who started it, that engineer cannot approve the release either. The trigger is idempotent on the CI run id at the database level, so a retried step never opens a second review.\n\nFrom there the request behaves like any other: the AI analyzer scores the release metadata, routing policies keyed on environment, provider, version glob, risk or time window can auto-approve, auto-reject or raise the approval bar, and reviewers decide in the UI — from the shared review queue, or from the deployment's own page once they have read its version, commit and justification. Review is single-stage, a rejection is immediately terminal, and self-approval is refused first and always — including for the submitting API key's owner, and including for an admin. Where the resolved review plan names approvers, only those users may decide.\n\nThe job then polls one endpoint and branches on one field. A release is releasable only when all of these hold: the request is exactly approved, no freeze window is active, any scheduled time has already passed, and — where the environment requires a change ticket — that ticket has been approved in ServiceNow or Jira. Everything else answers not releasable — a pending request, a rejected one, an unknown pipeline or version, a caller who may not see the request, and any internal error at all. A failure to evaluate degrades to a refusal, never to an accidental yes.\n\n/deployments/ — this one is still pending review, so the gate answers not releasable; approval is what flips it. An eligible reviewer decides right here; everyone else sees no buttons at all."} {"id":"4c55f35e72fd481d","path":"website/features/deployment-governance/index.html","url":"https://accessflow.io/features/deployment-governance/","anchor":"","title":"Freeze windows, and the emergency exit","section":"Features","order":0,"tokens":392,"text":"AccessFlow Docs > Features > Deployment approval governance — gate every CI/CD release > Freeze windows, and the emergency exit\n\nA freeze window suspends releases across an organization, one pipeline, or a single environment. It is either one-off — a start and an end — or recurring on chosen weekdays in a named timezone, evaluated as local wall-clock so a window that crosses midnight behaves the way an operator means it to. Among simultaneously active windows the most specific scope wins.\n\nWindows come in two behaviours. A rejecting window turns a release away at submission. A holding window lets the request be analyzed, reviewed and approved as normal and only withholds releasability at the gate — so the release proceeds the moment the window closes, with no re-approval. A window that cannot be evaluated at all fails closed to a hold, never to a rejection: a broken definition can stall deployments but can never destroy requests.\n\nBreak-glass deploys are gated twice with no admin bypass — the submitter needs a break-glass grant on the pipeline and the environment must allow it. In exchange they fan out instantly to every org admin, record an audit entry naming any freeze window they bypassed, and open a mandatory retro-review that an admin, never the submitter, must acknowledge. Deployment pipeline docs →"} {"id":"e0f5f6f12280651d","path":"website/features/deployment-governance/index.html","url":"https://accessflow.io/features/deployment-governance/","anchor":"","title":"What happened after the deploy","section":"Features","order":0,"tokens":225,"text":"AccessFlow Docs > Features > Deployment approval governance — gate every CI/CD release > What happened after the deploy\n\nApproval without an outcome is half a record, so the pipeline reports back: succeeded, failed, or rolled back. Repeating the same report is idempotent; reporting a different outcome is a conflict rather than a silent overwrite. A failure report flips the request from executed to failed — the one post-terminal transition the state machine allows.\n\nA rollback on an environment that requires review opens a follow-up review in the same transaction, so a governed rollback can never exist without one. As everywhere else, the submitter cannot close their own: acknowledgement is somebody else's, and it is a latch rather than a contested decision."} {"id":"48d57a316eca9b72","path":"website/features/deployment-governance/index.html","url":"https://accessflow.io/features/deployment-governance/","anchor":"","title":"Which version is where","section":"Features","order":0,"tokens":385,"text":"AccessFlow Docs > Features > Deployment approval governance — gate every CI/CD release > Which version is where\n\nA matrix lists every environment you deploy to, the version it is running now, the one it was running before, when it got there, and how that deploy ended. It answers the question the gate cannot: not whether a release may go out, but what is already out there.\n\nEach row also says how far behind it has fallen, counted two ways — how many newer releases have gone out since it last moved, and how many days passed between its deploy and the newest one. \"Behind\" is a plain comparison against the newest release that succeeded on that pipeline, not an attempt to parse your version numbers, so it works whether you ship 2.4.1, a date, or a commit hash. Nothing is scheduled and nothing is alerted on: the comparison is made when you look.\n\nEnvironments carry short tags of your choosing — up to ten each, a customer, a region, a tier — and the matrix filters on them. That is what turns one application deployed for many customers into a single readable view: one environment per customer, all tagged alike, all lined up beside their versions. Each environment also keeps its own history, newest first, and a rolled-back environment says what it reverted to rather than quietly showing a version it is no longer running."} diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json index 8a686650c..82a99d661 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "c739bd6f5465", - "generatedAt": "2026-10-22T08:48:04.985Z", - "sourceCommit": "ab425545df813643293a92f957c9f36c7059e068", + "corpusVersion": "304d5b2d9043", + "generatedAt": "2026-10-22T09:55:15.213Z", + "sourceCommit": "c96a84b8ae8607cce27d901bea6a484af38394c8", "chunkCount": 608, - "sha256": "c739bd6f5465788cfadbd5a2f6576070cd002212d56e0df2f6de05571b13b96a", + "sha256": "304d5b2d90433c3fb4e9880e363a55e734a3fd3f21c2b62954ad1abeaa704662", "quickReferenceSha256": "2eaa3d1f2e024c2049bf0f8a699c2208a88c1e20ad06e9503257fdcf6fea0746", "sources": [ { @@ -261,7 +261,7 @@ "url": "https://accessflow.io/docs/guides/deployment-approval/", "section": "Guides", "chunks": 12, - "sha256": "d4f37b37ec40691d4340af4a91e4bfc3f7843bfc41488764f850bd955382cd87" + "sha256": "9570578a1b23a82bec8ea71d6a33ecc8fcbfeff8fa233438d5c6912085d33ee0" }, { "path": "website/docs/guides/first-query/index.html", @@ -381,7 +381,7 @@ "url": "https://accessflow.io/features/deployment-governance/", "section": "Features", "chunks": 8, - "sha256": "492bae6f0ed3add05754058317d00c04abf8af1d0f9906b45b4dd7816c8c774a" + "sha256": "4f21e72c9859b9677e2b4f3492fad889c0d0976ba2ab0f6fb298415e91a25cf1" }, { "path": "website/features/index.html", diff --git a/website/docs/guides/deployment-approval/index.html b/website/docs/guides/deployment-approval/index.html index 1f5c82435..2489b64bf 100644 --- a/website/docs/guides/deployment-approval/index.html +++ b/website/docs/guides/deployment-approval/index.html @@ -64,7 +64,7 @@ "inLanguage": "en", "articleSection": "Guides", "datePublished": "2026-09-01", - "dateModified": "2026-09-30", + "dateModified": "2026-10-22", "url": "https://accessflow.io/docs/guides/deployment-approval/", "mainEntityOfPage": "https://accessflow.io/docs/guides/deployment-approval/", "image": "https://accessflow.io/og-image.png", @@ -282,7 +282,7 @@ Guides

Gate a deployment pipeline.

-

Last updated

+

Last updated

@@ -319,8 +319,9 @@

How do I gate a deployment behind approval?

The gate is fail-closed. A deployment is releasable only when its - status is APPROVED, no freeze window is active, and any scheduled time has - passed. Anything else — an error, an unreachable instance, a request the key cannot see + status is APPROVED, no freeze window is active, any scheduled time has + passed, and — on an environment that requires a change ticket — a linked change ticket + has been approved. Anything else — an error, an unreachable instance, a request the key cannot see — answers not releasable, and the CI wrappers fail the job. That is the point, but it has a consequence worth accepting before you start: if AccessFlow is down, your deployments stop. @@ -640,7 +641,14 @@

If the job fails instead

window closes, with no re-approval.
  • Approved, but still not releasing. Approval alone is not a green light. The job branches on releasable, which also requires no active freeze - window and any scheduled time to have passed.
  • + window and any scheduled time to have passed. If the environment requires a change + ticket, the waiting line includes change_ticket=…. A ticket key means the + release waits until AccessFlow hears that the ticket was approved — which only happens + through the ticketing system's callback, so set up + bi-directional status sync on the + channel. A rejected ticket blocks the release for good; submit a new deployment. + none linked yet means no ticket was opened — check that a ServiceNow or + Jira channel carries the deployment trigger, or the release will never go out.
  • The log says "rate limited (HTTP 429), retrying". Not a failure. Every API key has a per-identity request cap (120 per minute by default; a service account can be given its own), and the wrappers simply wait the number of seconds diff --git a/website/features/deployment-governance/index.html b/website/features/deployment-governance/index.html index f3b49238c..e161ca663 100644 --- a/website/features/deployment-governance/index.html +++ b/website/features/deployment-governance/index.html @@ -61,7 +61,7 @@ "articleSection": "Features", "inLanguage": "en", "datePublished": "2026-08-30", - "dateModified": "2026-09-30", + "dateModified": "2026-10-22", "url": "https://accessflow.io/features/deployment-governance/", "mainEntityOfPage": "https://accessflow.io/features/deployment-governance/", "image": "https://accessflow.io/og-image.png", @@ -257,7 +257,7 @@

    Pipelines, environments and who may trigger them

    The gate is fail-closed by construction

    A CI job opens a request by calling AccessFlow with an ordinary API key — no deployment-specific token. The key should belong to a service account, an identity of its own that can never sign in; whoever owns the key is the submitter, which is what makes the self-approval ban meaningful for a machine-triggered release. When the pipeline names the engineer who started it, that engineer cannot approve the release either. The trigger is idempotent on the CI run id at the database level, so a retried step never opens a second review.

    From there the request behaves like any other: the AI analyzer scores the release metadata, routing policies keyed on environment, provider, version glob, risk or time window can auto-approve, auto-reject or raise the approval bar, and reviewers decide in the UI — from the shared review queue, or from the deployment's own page once they have read its version, commit and justification. Review is single-stage, a rejection is immediately terminal, and self-approval is refused first and always — including for the submitting API key's owner, and including for an admin. Where the resolved review plan names approvers, only those users may decide.

    -

    The job then polls one endpoint and branches on one field. A release is releasable only when all three hold: the request is exactly approved, no freeze window is active, and any scheduled time has already passed. Everything else answers not releasable — a pending request, a rejected one, an unknown pipeline or version, a caller who may not see the request, and any internal error at all. A failure to evaluate degrades to a refusal, never to an accidental yes.

    +

    The job then polls one endpoint and branches on one field. A release is releasable only when all of these hold: the request is exactly approved, no freeze window is active, any scheduled time has already passed, and — where the environment requires a change ticket — that ticket has been approved in ServiceNow or Jira. Everything else answers not releasable — a pending request, a rejected one, an unknown pipeline or version, a caller who may not see the request, and any internal error at all. A failure to evaluate degrades to a refusal, never to an accidental yes.

    diff --git a/website/sitemap.xml b/website/sitemap.xml index 7ddf911d7..613f744a8 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -26,7 +26,7 @@ https://accessflow.io/features/deployment-governance/ - 2026-09-30 + 2026-10-22 monthly 0.8 @@ -266,7 +266,7 @@ https://accessflow.io/docs/guides/deployment-approval/ - 2026-09-30 + 2026-10-22 weekly 0.7