IT and security operations / Approval-gated workflow
Control Plane
An operations environment where a request becomes identity, MFA, endpoint state, a ticket, an alert, an approval, an action, and evidence. Every privileged step passes one authorization decision, and that decision is recorded whether it allows or denies.
Every user, device, alert, vulnerability, approval, and containment action is synthetic. This is a local learning environment, not ServiceNow, Jira, Okta, Active Directory, Intune, a commercial scanner, or a compliance certification.
At a glance
Claim
An operations chain from request to closure can be made executable, and every privileged action inside it can be forced through a single recorded policy decision.
Evidence
A seeded 600-event corpus run through the real ingest and authorization paths, a 2,092-case policy matrix evaluated through two independent engines, 28 invariants across five scenarios, and a verified audit hash chain.
Boundary
The data is synthetic and written by the same person as the code. The results show the controls behave as specified, not that the system survives input nobody anticipated.
On this page
The problem
The tools are the easy part. The handoffs are not.
Most IT and security work is not one product. It is a chain of handoffs between the person who asks, the identity that carries the request, the endpoint it lands on, the ticket that tracks it, the alert that interrupts it, the approver who authorizes it, and the evidence that closes it. Each link is usually a different system, and the failures live between them.
The failure mode I wanted to study is a case that looks closed. A command returned successfully, so the ticket was resolved, even though the state never changed, nobody independent approved it, or the proof attached to it went stale weeks earlier. So I built the chain instead of a dashboard over it, and made closure depend on the state transition, the authorization boundary, and fresh evidence rather than on an exit code.
Architecture
Six stages, one recorded decision each.
- Intake Correlated events Lifecycle requests and endpoint events arrive with a source event ID and an idempotency key.
- Identify Keycloak OIDC Groups, roles, and TOTP enrollment settle who the actor is before any state changes.
- Record PostgreSQL state Canonical state, an append-only event ledger, and an outbox worker with retry and replay.
- Route Native ITSM Tickets, SLAs, changes, approvals, and a knowledge base, with each alert linked to the ticket it opened.
- Decide Authorization gate One policy call per privileged action, written to the decision table on allow and on deny, checked by a 2,092-case parity matrix across both engines.
- Close Fresh evidence A case closes on verified, unexpired evidence, never on a command that returned zero.
Authorization
One decision point, two implementations that must agree.
Every privileged action routes through a single call. The default engine is a versioned Python evaluator, so offline verification needs no external binary. policies/authorization.rego implements the same contract over the same input shape, and setting LAB_POLICY_ENGINE to opa sends the entire environment through the Rego bundle instead. CI runs the whole suite both ways, which is what makes the Rego an enforcement path rather than documentation that drifted.
A parity test builds a 2,092-case cross product of actors, actions, and targets, evaluates each case through both engines, and fails the build on any disagreement in the verdict or in the set of denial reasons. Two guard tests keep that matrix honest. One fails if the matrix stops producing both allows and denies, since a matrix that only ever denies would pass parity for the wrong reason. The other fails if any denial reason stops being exercised.
{
"action": "approve_change",
"decision": "deny",
"reasons": [
"required_role_present",
"mfa_present",
"separation_of_duties_violation"
],
"policy_version": "2026-08-10-control-plane-v2"
}
{
"action": "execute_containment",
"decision": "deny",
"reasons": [
"required_role_present",
"mfa_present",
"independent_approver",
"valid_approval_present",
"device_posture_not_eligible"
]
}
A denial keeps the reasons that passed. The first record shows an approver who held the right role and had MFA, refused only because they were the person who requested the change. The second shows an analyst with an independent, valid approval in hand, refused because the device posture had gone stale. A reviewer reading the decision table can see which control fired without rerunning anything.
Scenarios
Three chains, each ending in evidence.
Joiner, mover, leaver
A lifecycle request becomes an identity, an MFA enrollment, a role mapping, entitlement changes, a device assignment, a ticket, and offboarding completeness checks. Granting a privileged role requires an administrator, so a manager cannot quietly promote someone through the normal path.
Suspicious endpoint to approval-gated containment
A Wazuh event becomes an enriched alert, an owned case, a containment change, an independent approval, a simulated isolation action, and post-action evidence. The analyst who requests containment cannot approve it, an expired approval stops counting, and an ineligible device posture blocks the action even when the approval is valid.
Vulnerability remediation
A Grype finding becomes an owned asset, a remediation change, a patch, and a rescan. The case cannot close without fresh rescan evidence, which is the whole point of the scenario. Scanner version, vulnerability database checksum, image reference, and capture time are recorded with the finding, because a scan result is only meaningful next to the scanner that produced it.
Measurement
A seeded corpus, pushed through the real paths.
The published numbers come from one command. It generates a deterministic synthetic corpus, pushes it through the same ingest and authorization code the scenarios use, and reports what happened. The corpus is roughly 55 percent benign endpoint noise, 20 percent suspicious alerts, 15 percent posture snapshots, and 10 percent input the pipeline is expected to refuse. It also replays every eighth envelope verbatim, which is what an at-least-once delivery pipeline actually produces.
Each of the five denial reasons isolates one control: a missing role, a self-approval, a containment with no valid approval, a containment against an ineligible device posture, and a closure without fresh verified evidence. Replayed envelopes never produced a second alert or a second ticket, so alert and ticket counts stayed equal to the unique actionable-alert count. Refused input landed in the dead-letter table with its error preserved, and the audit chain still verified afterwards.
None of the 332 benign alerts opened a ticket. That shows the routing honors the label the generator assigned, not that the system can tell benign traffic from malicious traffic. It cannot, and it does not try. Every attempt pattern has a fixed expected verdict by construction, so this is a conformance result, not an accuracy result.
Evidence integrity
Nothing is trusted for being written by the system.
Audit records chain by SHA-256 over the previous record hash, starting from a genesis value, and a verify command recomputes the chain across the whole run rather than across the three headline scenarios. Evidence is hashed over canonical JSON so key ordering cannot change the digest, and every new artifact enters marked for review rather than verified.
Testing that pair of rules is the part I care about. A tampered evidence body is rejected on content-hash mismatch, and a closure that requires fresh verified evidence is refused when the evidence has expired. An offline verification suite asserts 28 invariants across five scenarios, covering role denial, the MFA gate, the approval gate, idempotency, stale and tampered evidence, traceability, and the audit chain. The suite runs on SQLite, and a separate CI job runs the same tests against PostgreSQL 16 on a throwaway database per test, so portability is tested rather than asserted.
Known limits
What this cannot show.
- Synthetic throughout. The corpus was written by the same person as the code, so it cannot demonstrate behavior on input nobody anticipated.
- Routing is not detection. Benign handling is a property of the corpus and the routing rule. It is not a false-positive rate.
- Containment is simulated. The adapter changes synthetic database state. It cannot reach a host firewall, an endpoint, or a real network.
- Append-only is an application rule. A database administrator can bypass it. The guarantee sits above the storage layer, not inside it.
- A hash proves the artifact, not the event. Evidence hashes show the captured artifact is unchanged, not that the underlying event happened as described.
- Not a platform. The ITSM service is deliberately smaller than a commercial one, and the endpoint agent is a lab simulator rather than an EDR or MDM agent.
- Scanner output is time-bound. A Grype match is a finding, not proof of exploitability, and a missing match is not proof of remediation.
Source
Run it in three minutes, offline.
The offline path needs only Python. Docker Compose profiles add PostgreSQL, Keycloak, the event worker, the endpoint simulator, and optionally Wazuh and the scanners. The threat model, the limitations, the measured results, and the runbooks are in the repository next to the code they describe.
git clone https://github.com/RasheedFarhat/control-plane.git
cd control-plane
make verify
make corpus
make evidence