# How unauthorized changes are detected

> Per change source (cloud audit log, Kubernetes audit, GitOps, ITSM), what counts as an authorized change, what raises an unauthorized change, and a sandbox demo.

_Concept · Updated 2026-10-26 · Describes release 0.1.164_

An **unauthorized change** is a write the platform saw *applied* in your estate that nothing accounts for: no approved change request, no approved ITSM change ticket, no automation identity you trust. It is different from **unauthorized access**, a write the cloud *refused*: nothing changed, so there is nothing to roll back, and it goes to SecOps as a report.

The rule is the same for every source that can see *who* made a write. What differs is what each source can see. The field-by-field facts (notes, audit actions, caps) are in the [Changes reference](/help/changes-reference.md#unauthorized).

## The switches {#switches}

| Setting | Default | What it does |
|---|---|---|
| `aiops_unauthorized_change_incidents` (**Guardrails → Unmanaged changes**, *Open an incident for every change with no reference*) | off | An applied write from the cloud audit log or the Kubernetes audit log needs a reference; one without opens an incident that waits for a person. Off by default because a busy account records thousands of outside writes a day: fill the allowlist first. |
| `aiops_denied_access_to_secops` | on | A refused write opens a SecOps report (one per principal, account and hour), with the SecOps agent enabled. |
| `aiops_change_principal_check` | on | A write made with the platform's own cloud credential that no platform change, approved action or source-side suppression explains is flagged. |
| `change_freeze` | none | While the declared freeze window matches, any observed change with no platform change in flight for its service is flagged. |
| `change_authorized_principals` | `[]` | The automation identities (globs) whose writes are authorized: CI/CD roles, pipeline service accounts. |
| `itsm_change_match` | per ticket system | The ticket fields read for state, CI and planned window. |

With the first switch off, the platform still records every captured write with its principal, and the freeze window, the principal check and denied attempts still flag rows on the **Changes** page; only the reference rule and its incidents are off.

## What counts as authorized {#authorized}

A captured, applied write is authorized when one of these accounts for it, checked in this order. The first match is stored on the change as its reference.

1. **The platform made it.** The platform's own credential, with a receipt: a typed platform step on the same resource within 10 minutes, a route change naming the resource or its service, an approved action inside an analysis, or a source-side suppression.
2. **An allowlisted principal.** The AWS role or user, the Azure caller or the Kubernetes user matches a glob in `change_authorized_principals` (case-insensitive). Use it for deployment pipelines and for in-cluster CI/CD service accounts, for example `system:serviceaccount:flux-system:*`.
3. **An approved ITSM change ticket.** A ServiceNow change request or a Jira change issue the platform has received, in an approved state, whose CI names the resource or a service the write is linked to, and whose planned window covers the time of the write. A ticket without a planned start and end never matches.
4. **A change request a person approved on the platform.** Its typed steps, reviewed action or operator commands name the resource itself (a whole service is not enough), it was opened before the write, and it moved within 24 hours of it. One the platform executed covers writes only until its execution ended.

A write with none of these is flagged "no reference found (no ticket, approval or signed change request)". A freeze window or the principal check can still flag a write that has a reference.

## Method per source {#per-source}

### Cloud audit log: AWS CloudTrail and the Azure activity log {#cloud-audit-log}

- **What is read.** Completed write events of the read connection's account, in its regions plus `us-east-1` for the global services (CloudTrail), and write operations per subscription (activity log). The harvest runs every 15 minutes by default.
- **What is never judged.** Reads, failed events, session plumbing and every STS call, and writes an AWS service makes for itself (`AWSService`). A write made on the platform's behalf and seen back in the log becomes the receipt of its change, not a second row.
- **Authorized** by the four references above, in that order.
- **Raises an unauthorized change** when none matches and the switch is on: one incident per action (the same principal and account within 10 minutes), one alert row per resource, priority from the resources' criticality (P3 when none is declared), no automatic analysis, a ticket through your on-open ticket route, and a revert drafted per change from its recorded before-state.
- **Raises unauthorized access** when the cloud refused the write (`AccessDenied`, `UnauthorizedOperation`, `AuthorizationFailed`): a denied attempt, reported to SecOps, never rolled back.

### Kubernetes audit log {#kubernetes-audit}

- **What is read.** On EKS, the control-plane audit log in CloudWatch Logs when the AWS connection carries `k8s_audit: true`; on other clusters, the API server's audit webhook. Completed write verbs (`create`, `update`, `patch`, `delete`) by people, and by the service accounts of the CI/CD namespaces in `k8s_ci_namespaces` (Flux, Jenkins, GitLab Runner and Tekton by default).
- **What is never judged.** Other `system:` identities (nodes, controllers, the GitOps controller itself, whose changes arrive as its sync events), EKS's own service identities, server-side dry runs, and the pods and runs a pipeline spawns.
- **Authorized** by the same references. A pipeline service account is authorized only when its glob is on the allowlist; a person's `kubectl` edit needs an ITSM ticket or an approved change request naming the object.
- **Raises** an unauthorized change per action, as for the cloud audit log (the account is the cluster); a 401 or 403 is a denied attempt.

### GitOps: repositories, Argo CD and the change webhook {#gitops}

- **What is read.** Pushes and merged pull requests of the watched repositories (GitHub, GitLab, Gitea, Azure DevOps), Argo CD sync and drift (out of sync) events, and anything you post to the change webhook.
- **Authorized** by the repository's own controls: a merged pull request went through the reviews and branch protection the repository enforces, and Argo CD applies only what is in Git. The platform does not judge these events by the reference rule, and they never open an unauthorized-change incident.
- **Raises** only inside a declared freeze window: an event with no platform change in flight for its service is flagged "external change during a declared freeze window with no in-flight platform change for this service", audited and listed under **Unauthorized** on the Changes page. A direct push that skipped review is recorded, not raised: require reviews with branch protection in the repository.
- **What the cluster sees.** A `kubectl` edit that bypasses Git shows as Argo CD drift and, where the Kubernetes audit log is read, as a person's write judged by the rule above.

### ITSM: ServiceNow and Jira change tickets {#itsm}

- **What is read.** ServiceNow change requests (`change_request`, numbers starting `CHG`) and Jira issues whose type is a change, posted to their source webhooks. Each becomes a ticket row in the change ledger with its state, CI and planned window, refreshed by every re-send: a ticket approved after it was raised counts from then on.
- **Its role.** The ticket is the authorization, never a change to judge: it is reference 3 above for every write the audit logs capture.
- **What to set.** The approved values and field names in `itsm_change_match` when yours differ from the defaults (ServiceNow `approval`, `cmdb_ci`, `start_date`, `end_date`). Jira has no standard planned-window fields: until you name your start and end custom fields, no Jira ticket can match.
- **Raises** nothing by itself. A write whose ticket is not approved, names another CI, or falls outside the planned window has no ITSM reference.

## What a person does {#what-a-person-does}

An unauthorized-change incident never fixes or rolls back on its own, whatever runbook is certified. On its **Unauthorized change** card, a person with the approver permission either:

- chooses **Approve rollback** to open the drafted revert as a change request, approve and release it (a revert with no typed step shows the command to run by hand instead), or
- chooses **Keep this change (legitimate)** with a note: the reverts are rejected, the changes are marked authorized by that person, and the incident closes as expected.

## Run the sandbox demo {#sandbox-demo}

`scripts/demo-unauthorized-change.py` shows the cloud audit log path end to end in a sandbox account. The script is in the platform's source repository, not on this installation: ask your platform administrator or the vendor for it. It turns `aiops_unauthorized_change_incidents` on for the demo window only and puts the workspace's own value back on the way out, also when the run fails or is stopped with Ctrl+C, `SIGTERM` or a closed terminal. If putting it back fails, the script says so in a line that starts `!!!` and names the value to set by hand; a `SIGKILL` skips the restore.

**Before you start:** a workspace whose AWS read connection covers the sandbox account and region; a security group there, tagged `aiops-managed=true` so the platform's write connection can run the drafted rollback; a personal access token with `members.manage`, `pillars.manage` and `incidents.view`; the AWS CLI signed in to the sandbox account with an identity that is **not** on `change_authorized_principals` (the script refuses to start otherwise).

```bash
AIOPS_BASE=https://aiops.example.com AIOPS_TOKEN=<token> TENANT_ID=<workspace id> \
SG_ID=sg-0123456789abcdef0 AWS_PROFILE=<sandbox profile> AWS_REGION=ap-southeast-1 \
  python3 scripts/demo-unauthorized-change.py
```

1. The script adds one ingress rule to the security group: TCP 8443 from `203.0.113.0/24`, a documentation range that is never routed on the internet.
2. It pulls the audit log through the platform every minute until the write is flagged; CloudTrail takes about 5 to 15 minutes to deliver an event. **You should see** the note "no reference found (no ticket, approval or signed change request)" and the incident's address.
3. Open the incident and answer on its **Unauthorized change** card. The script waits up to an hour (`DECIDE_WAIT_S`, 3600 seconds) for the incident to close: a rollback is released by a person and then validated in rounds.
4. The script restores the switch, then removes the rule if no rollback removed it (`CLEANUP=0` keeps it). While the incident's rollback is requested, approved or running, it leaves the rule to the rollback and prints the command that removes it later.

To show an authorized write instead, raise an approved change ticket naming the security group with a planned window around the demo, or add your identity to `change_authorized_principals` (the script then refuses, because nothing would be flagged).


## Next steps

- Next: [The change request document and the change management agent](/help/change-request-document.md)
- [All Work with the agents pages](/help/work-with-the-agents/llms.txt)
- [Documentation home](/help/)
