Skip to content

Feat: AIAC OPA plugin integration + live enforcement (phase 2, PR 1) - #752

Merged
abigailgold merged 44 commits into
rossoctl:mainfrom
s-and-p-team:aiac-phase2-opa
Aug 13, 2026
Merged

Feat: AIAC OPA plugin integration + live enforcement (phase 2, PR 1)#752
abigailgold merged 44 commits into
rossoctl:mainfrom
s-and-p-team:aiac-phase2-opa

Conversation

@oblinder

Copy link
Copy Markdown
Contributor

Summary

First PR in a series toward #646 (AIAC phase 2 — platform integration: live onboarding trigger + live enforcement). This PR delivers OPA plugin integration within the AIAC system plus live enforcement: AIAC now publishes the rules it generates to the enforcement layer's policy resource, and real requests are allowed/denied by the deployed AuthBridge OPA plugin.

Later PRs in the series will cover the remaining #646 scope (e.g. the live IdP-event onboarding trigger path).

What's in this PR

PDP OPA policy writer → live enforcement

  • Rewrote the OPA policy writer from a filesystem stub into an always-on writer of per-agent AuthorizationPolicy Custom Resources (agent.rossoctl.dev/v1alpha1) on the live Kubernetes API via server-side apply; the bundle-service composes these CRs into per-pod OPA bundles the plugin loads.
  • Reworked the Rego generator: fixed authbridge.client.{inbound,outbound}.request packages matching the live AuthBridge OPA plugin input shape (input.identity.*, input.mcp.params.name), with de-prefixed outbound scope values.
  • Removed the superseded Keycloak composite-role writer and fixed every dangling reference across the PRD, specs, and the k8s deployment guide.
  • Rego dump to REGO_OUTPUT_DIR is now purely additive local-debug output, gated by POLICY_WRITER_DUMP_REGO (default off); it never gates the CR write. Error mapping: malformed agent_id → 400, k8s API failure → 502, delete of an absent CR → 204.

AuthBridge OPA plugin + token exchange

  • Plugin changes to support outbound Rego rules (in addition to inbound).
  • Token-exchange plugin/delegation changes supporting scoped token exchange for the agent's onward call to the tool.

Kubernetes wiring

  • Added the aiac-pdp-policy-writer ServiceAccount + cluster-scoped RBAC (get,list,create,update,patch,delete on authorizationpolicies, no watch) and wired the SA into the aiac-interface pod.
  • Turned the prod Rego dump off (dropped REGO_OUTPUT_DIR + /rego mount), kept read-only rootfs + /tmp; added PLATFORM_SOURCE_CLIENTS.

Integration tests — real OPA loop

  • Rebuilt the OPA integration tests to close the real enforcement loop through the AuthBridge OPA plugin on a live Kind cluster: onboard → poll bundle-service → real HTTP request through AuthBridge → assert allow/deny from the deployed plugin over bare tool names. Retired the opa eval / probe model (opa no longer needed on PATH). Suite skips cleanly when the cluster is not wired.
  • Added scripts/opa-kind-{enable,disable,driver,restore}.sh to stand up / tear down the OPA pipeline on Kind.

Docs

  • Rewrote the pdp-policy-writer-opa spec to match the CR-writer implementation.

Supporting refactors carried in this branch

  • Renamed Policy StorePolicy Model Store across specs, code, and tests.
  • Renamed kagentirossoctl (realm, operator contract, brand, repo paths).

Testing

  • .venv/bin/pytest test/ -m "not integration" — green.
  • Integration tests (-m integration) close the real OPA loop against a live rossoctl/Kind cluster with the AuthBridge OPA pipeline wired in (see docs/opa-kind-runbook.md); they skip cleanly when the cluster/env is absent.

Related

Part of #646. Sub-issue of the AIAC epic #643; depends on phase 1 (#645).

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

anatolykoyfman and others added 30 commits August 3, 2026 08:44
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Define a new ninth component in the Policy/Domain Knowledge RAG Pod:
a pre-flight, fail-closed verification gate between the RAG Ingest
Service and ChromaDB. Its concrete check set is left TBD; this
change fixes the component's architectural placement and its
interoperability contract with the RAG Ingest Service and ChromaDB
(pod-local only, one call per document, all-or-nothing rejection,
no Event Broker interaction).

Signed-off-by: Oleg Blinder <oblinder@gmail.com>
…config

- CLAUDE.md: issue-tracking section describes the GitHub issues/AIAC Project layout (no migration history); adds an '## Agent skills' block wiring the Matt Pocock engineering skills.
- .gitignore: drop obsolete docs/issues/ and docs/gh-issues/ entries.
- docs/agents/: issue-tracker, triage-labels, and domain config the skills read from.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
…config

- CLAUDE.md: issue-tracking section describes the GitHub issues/AIAC Project layout (no migration history); adds an '## Agent skills' block wiring the Matt Pocock engineering skills.
- .gitignore: drop obsolete docs/issues/ and docs/gh-issues/ entries.
- docs/agents/: issue-tracker, triage-labels, and domain config the skills read from.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
…c-phase2

Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Phase 03a of the Policy Store rename: retitle the SQLite-backed structured
policy service to Policy Model Store across docs/specs/** only, freeing the
aiac-policy-store name for reassignment to ChromaDB (Handoff 04).

- Display name Policy Store -> Policy Model Store
- aiac-policy-store{,-service,-config} -> aiac-policy-model-store*
- AIAC_POLICY_STORE_URL -> AIAC_POLICY_MODEL_STORE_URL
- aiac.policy.store[.library] -> aiac.policy.model_store[.library]
- Dockerfile path policy/store/service -> policy/model_store/service
- k8s manifest policy-store-statefulset.yaml -> policy-model-store-statefulset.yaml
- Rename component spec files policy-store.md, library-policy-store.md and
  repoint inbound links
- Drift fixes: AGENTPOLICY_DB_PATH -> SERVICEPOLICY_DB_PATH,
  /data/state.db -> /data/policy_model.db

Code, manifests, tests, and both CLAUDE.md files are intentionally left on
the old names until phase 03c.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Replace the enumerated source-tree, Docker-image table, and volume-service
list with ls/find/grep discovery guidance, keeping only conceptual prose,
patterns, and commands. This also removes the last references to the old
policy-store name from aiac/CLAUDE.md.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Renames the SQLite-backed policy-model service from aiac-policy-store to
aiac-policy-model-store across code, tests, manifests, Docker image, and
demo/integration targets, freeing the old name/key/filename for ChromaDB
in Handoff 04.

- Python package aiac.policy.store -> aiac.policy.model_store (+ tests)
- Env key AIAC_POLICY_STORE_URL -> AIAC_POLICY_MODEL_STORE_URL
- k8s manifest policy-store-statefulset.yaml -> policy-model-store-statefulset.yaml
  (all identifiers; SERVICEPOLICY_DB_PATH and securityContext preserved)
- Image aiac-policy-store -> aiac-policy-model-store; Dockerfile moves with package
- Demo/integration svc target aiac-policy-model-store-service
- PRB import-isolation FORBIDDEN guard repointed to aiac.policy.model_store.library

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
… diagram

Signed-off-by: Oleg Blinder <oblinder@gmail.com>
…tract, brand, repo paths)

Apply the kagenti->rossoctl rebrand driven by the real infra/branding change:
- Keycloak realm default kagenti -> rossoctl
- Operator contract strings: agent.kagenti.dev -> agent.rossoctl.dev; labels
  kagenti.io/* -> rossoctl.io/* and protocol.kagenti.io/* -> protocol.rossoctl.io/*;
  credentials secret prefix, operator name, kind cluster name
- Platform-brand prose "Kagenti ..." -> "Rossoctl ..."
- Monorepo rename: kagenti-extensions/ paths -> cortex/; MCP link URL -> rossoctl/cortex

Preserves genuine upstream references: the Kagenti Developer Guide, github-org
sample data in demo prompts, Keycloak test fixtures, and historical PR markers.

Unit tests green (466 passed, 155 deselected).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Complete the kagenti→rossoctl rebrand by normalizing the arbitrary
Keycloak-payload fixture data in TestKeycloakRealWorldPayloads that the
mechanical rename pass deliberately skipped (alice@kagenti.org, lastName
"Kagenti", role kagenti-admin, and a stale docstring). These are
round-trip parsing fixtures, so the literal value is arbitrary and the
change is behaviour-preserving; the same test class already used
containerId "rossoctl".

grep -rni kagenti src/ test/ is now clean; genuine upstream carve-outs
(github owner=kagenti in test_prereq.py, the Kagenti Developer Guide
link) are untouched. Unit suite: 466 passed, 155 deselected.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>

# Conflicts:
#	aiac/docs/specs/PRD.md
#	aiac/k8s/aiac-deployment-guide.md
The deployment guide had five stray "Policy Store" references (build/deploy/
verify comments and the env-var table) that predated the Policy Store ->
Policy Model Store rename. Align them with the rest of the docs, which already
use "Policy Model Store" and the aiac-policy-model-store image/service names.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>

Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Move useradd ahead of the COPY steps and add --chown=10001:10001 to
each COPY so application files are owned by the non-root aiac user
instead of root. pip install still runs as root to write into system
site-packages. Applies to the controller, idp/keycloak, pdp/keycloak,
pdp/opa, model_store, and demo github_tool images.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Add a stateless GET /health liveness/readiness endpoint to the
Controller API, returning 200 {"status": "ok"}. The Controller holds
no local state and opens no connection at rest, so /health is a bare
process-liveness signal; upstream reachability stays validated
per-request by the handlers.

- routes.py: new GET /health handler.
- test_routes.py: unit test asserting 200/body and that no handler or
  PCE is dispatched.
- agent-deployment.yaml: switch readiness+liveness probes from tcpSocket
  to httpGet /health.
- integration (uc1_onboard.py): poll /health as the Controller
  port-forward ready_url; fix stale 'no /health' comment in launcher.py.
- demo (03-onboard-agent.py, 04-onboard-tool.py): pass ready_url=/health
  to the Controller port-forward.
- aiac-agent.md: document GET /health in the Endpoints table.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
install.sh's load_image_to_kind() set a `trap ... RETURN` to clean up a
temp tar file, but RETURN traps in bash aren't scoped to the function
that set them -- they fire on every subsequent function return until
cleared. After the tool image loaded, the stale trap fired again on
build_and_load's return and referenced tar_file outside its scope,
failing with "unbound variable" under set -u. Replaced the trap with a
direct rm -f after the kind load call.

Also updated INSTALL.md's verification snippet to port-forward on
18080 instead of 8080, since a rossoctl-installed Kind cluster already
binds host port 8080 to the Gateway.

Signed-off-by: Oleg Blinder <oblinder@gmail.com>
The Policy Rules Builder built ChatOpenAI with no request timeout, so a
stalled LLM socket never raised and POST /apply/service wedged forever.
Even with a timeout, openai raises APITimeoutError/APIConnectionError,
whose class names were not in is_transient()'s recognized set, so a
timed-out call would surface as a hard error instead of being retried.

- graph.py: _build_llm() now passes timeout (from LLM_REQUEST_TIMEOUT,
  default 120s, tolerant of unset/bad values) and max_retries=0 so the
  tenacity Retrying in _structured_call is the sole retry owner.
- shared/upstream.py: is_transient() recognizes APITimeoutError and
  APIConnectionError by name (no openai import; stays transport-agnostic).
- k8s/agent-deployment.yaml: expose LLM_REQUEST_TIMEOUT and
  UPSTREAM_MAX_RETRIES in the aiac-agent-config ConfigMap.
- tests: timeout/connection errors classified transient, retried then
  reraised, and _build_llm sources timeout from env.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Add init/00-discover-keycloak.sh: a sourceable script that port-forwards
the in-cluster Keycloak and exports KEYCLOAK_URL + admin creds from the
keycloak-admin-secret, so the demo targets no longer require the caller to
export those by hand.

Renumber the init/onboard scripts into one 00-05 sequence and rework the
Makefile: SHELL=bash, a KC_ENV self-source prefix on every Keycloak-touching
recipe (make can't propagate env across recipes), a new 'keycloak' target,
renamed onboard-agent/onboard-tool to agent/tool, and grouped phase targets
init (00-03) / onboard (04-05) / run, with demo now chaining init -> onboard
-> run. Update demo.md to match.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Wave-1 PDP Policy Writer changes (handoffs 01 and 04):

- rego.py: replace slugify with identity_ref; emit fixed
  authbridge.client.{inbound,outbound}.request packages matching the
  live AuthBridge OPA plugin input shape (input.identity.*,
  input.mcp.params.name); de-prefix outbound scope values while
  keeping full SPIFFE target keys.
- Remove the superseded Keycloak composite-role writer
  (src/aiac/pdp/service/policy/keycloak/ + its tests + component
  spec) and fix every dangling reference across the PRD, specs, and
  the k8s deployment guide.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Rewrite the OPA policy writer from a filesystem stub into an always-on
writer of per-agent AuthorizationPolicy Custom Resources
(agent.rossoctl.dev/v1alpha1) on the live Kubernetes API via server-side
apply. Metadata name/namespace derive from identity_ref; bundle-service
composes these CRs into per-pod OPA bundles.

The rego dump to REGO_OUTPUT_DIR is now purely additive local-debug output,
gated by POLICY_WRITER_DUMP_REGO (default off); it never disables, replaces,
or gates the CR write. Error mapping: malformed agent_id -> 400, Kubernetes
API failure -> 502, delete of an absent CR -> 204, health -> 200/503.

Add the kubernetes client dependency to requirements.txt.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Finish the wave-3 handoffs of the OPA policy-writer rework:

- 03 (k8s): add aiac-pdp-policy-writer ServiceAccount + cluster-scoped
  RBAC (get,list,create,update,patch,delete on authorizationpolicies,
  no watch); wire the SA into the aiac-interface pod; turn the prod
  rego dump off (drop REGO_OUTPUT_DIR + /rego mount + rego-output
  volume, keep read-only rootfs + /tmp); add PLATFORM_SOURCE_CLIENTS.
- 07 (tests): rewrite the OPA writer unit tests for identity_ref, the
  fixed authbridge.client.{inbound,outbound}.request packages, nested
  input.identity/input.mcp shape, rossoctl platform bypass, de-prefixed
  outbound scopes, and the always-on CR writer (SSA args, delete-by-
  label, delete-404 idempotency, batch-400, /health, dump-toggle).
- 09 (demo): update uc1-onboarding to the new packages/input shape and
  de-prefixed outbound scopes; source rego from the AuthorizationPolicy
  CR (spec.policies[].content) via the nested ns/name layout.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Match the spec to the current CR-writer implementation: fixed package
names (authbridge.client.{inbound,outbound}.request) + import rego.v1,
input.identity.* + input.mcp.params.name input shape, per-agent
AuthorizationPolicy CR (server-side apply, delete-by-label), RBAC/auth
model, PLATFORM_SOURCE_CLIENTS / POLICY_WRITER_DUMP_REGO / REGO_OUTPUT_DIR
config, always-on CR write + additive dump, and the Keycloak-writer
removal. Both embedded Rego blocks now match docs/examples/opa-team1-policy.yaml.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
Address PR rossoctl#752 review feedback:
- opa-kind-enable.sh hardcoded the bundle_url namespace to
  rossoctl-system on both OPA legs, silently breaking the
  RELEASE_NAMESPACE override. Restore ${RELEASE_NAMESPACE} (the
  heredoc is unquoted, so it expands).
- Add an inline comment at the jwt.ParseInsecure call site noting
  the decoded subject is used for delegation provenance only, never
  for an auth decision.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR wires AIAC into the live OPA enforcement loop: OPA policy-writer→CR, outbound delegation identity surface in input, and the Kind integration test suite. All CI checks pass (CodeQL, Bandit, Hadolint, Go/Python/Shell lint, DCO — 38/38 commits signed). No .claude/.vscode changes.

  • Suggestion: mint_token() in scripts/opa-kind-driver.sh uses password=${user} (password == username) — appropriate for the local Kind dev cluster and clearly documented, but a comment guarding it from production use would add clarity.
  • Nit: In the generated outbound Rego, agent_role_scopes is emitted but explicitly not referenced by allow; a brief in-Rego comment (# informational / debugging only — not part of allow) would prevent future policy authors from wondering why it's there.

Reviewed by clawgenti using the github-pr-review skill

mint_token() {
local user="$1" resp tok err
resp=$(curl -s -X POST "${KC}/realms/${REALM}/protocol/openid-connect/token" \
-d client_id=rossoctl -d "username=${user}" -d "password=${user}" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: password=${user} is the right pattern for this Kind dev cluster (credentials == username per the runbook), but it's easy to cargo-copy this helper into a staging or production script. A one-liner guard — e.g. # DEV ONLY: password == username; see opa-kind-runbook.md prerequisites — right before the curl call would make the intent explicit and reduce copy-paste risk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2233b70 — added a DEV ONLY guard comment right before the curl grant in mint_token, spelling out that password == username is valid only for this Kind dev cluster's seeded users (per the runbook prerequisites) and must not be cargo-copied into a staging/production script. Thanks.

_group_rules_deprefixed(model.outbound_subject_rules),
),
_render_map(
"agent_role_scopes", _group_rules_deprefixed(model.outbound_rules)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: agent_role_scopes is rendered here but explicitly not used in allow (the docstring notes "informational / debugging"). A short inline comment in the generated Rego template — e.g. # informational only; not referenced by allow right after the variable is rendered — would prevent future policy authors from hunting for where agent_role_scopes feeds into the decision and assuming it is accidentally omitted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2233b70 — the generated outbound Rego now emits # informational/debugging only — not referenced by allow immediately before agent_role_scopes, so a future policy author reading the bundle sees plainly that it feeds logging/debugging only and that target_scopes[input.identity.service_id] is the capability gate.

@abigailgold

abigailgold commented Aug 12, 2026

Copy link
Copy Markdown

Few more small comments:

Must-fix:

  • As discussed with the authors, we want this feature to be off by default since it is potentially blocking. This includes changes to existing plugins (opa and token exchange). Also please find an additional reviewer for these specific changes (in existing plugins) to give the final ok.

Nits:

  • upsert_policy (POST /policy, the batch endpoint) applies agents one at a time with no rollback: if agent N in a batch has a malformed agent_id, agents 1..N-1 are already server-side-applied and stay applied when the batch returns 400. This is called out explicitly in a comment (# A malformed agent_id aborts the batch with a 400 naming it; agents already applied before that point stay written (no rollback)), and is arguably fine given SSA is idempotent and the caller can retry — but it's a real partial-failure semantic worth the caller (the PDP orchestration on the other side of this HTTP call) being aware of. Suggestion: confirm the batch caller either treats 400 as "some agents may have applied" or pre-validates agent_ids before calling /policy.
  • New dependency: kubernetes (Python client) added unpinned to aiac/src/aiac/pdp/service/policy/opa/requirements.txt. This matches the existing convention in that file (fastapi/uvicorn/pydantic are also unpinned), so it's consistent with current repo practice rather than a new problem — but as the PDP writer now talks directly to the live K8s control plane with create/update/patch/delete RBAC, an unpinned client library version is a slightly higher-stakes gap than it was for the old filesystem stub. Suggestion: consider pinning kubernetes to a known-good major version given the elevated blast radius of this service now that it mutates cluster state.
  • Security note worth flagging in tokenexchange/plugin.go: this is a case of intentionally trusting unverified claims from client-supplied input for policy input enrichment. The risk is contained because (a) it only affects the subject field surfaced for logging/policy input, not the actual authorization gate on this leg — the outbound allow decision in the Rego (from the earlier PDP review) is keyed on target_scopes[input.identity.service_id] and subject_role_scopes[role], both of which come from the delegation hop's audience/scopes (which token-exchange did establish legitimately via its own token minting), not from the unverified subject. But if any future policy branches on input.identity.subject for an outbound allow decision, that would be an actual auth bypass, since a caller could forge a bearer's sub claim freely. Worth a code comment warning future policy authors of that trap, if one doesn't already exist elsewhere in the spec docs.

oblinder added a commit to s-and-p-team/cortex that referenced this pull request Aug 12, 2026
Address PR rossoctl#752 review feedback:
- opa-kind-enable.sh hardcoded the bundle_url namespace to
  rossoctl-system on both OPA legs, silently breaking the
  RELEASE_NAMESPACE override. Restore ${RELEASE_NAMESPACE} (the
  heredoc is unquoted, so it expands).
- Add an inline comment at the jwt.ParseInsecure call site noting
  the decoded subject is used for delegation provenance only, never
  for an auth decision.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
…client)

- opa-kind-driver.sh: guard mint_token's password==username grant with a
  DEV ONLY comment so it is not cargo-copied into staging/production.
- rego.py: emit a Rego comment before agent_role_scopes noting it is
  informational/debugging only and not referenced by allow.
- opa/requirements.txt: pin the kubernetes client to the tested major
  (>=36.0.3,<37) now that the writer mutates cluster state.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
@oblinder

Copy link
Copy Markdown
Contributor Author

@clawgenti thanks for the review — both points are now addressed in 2233b70:

  • Suggestion (mint_token password==username): added a DEV ONLY guard comment right before the curl grant, spelling out that it is valid only for this Kind dev cluster's seeded users (per the runbook prerequisites) and must not be cargo-copied into a staging/production script. (reply on the inline thread)
  • Nit (outbound Rego agent_role_scopes): the generator now emits # informational/debugging only — not referenced by allow immediately before agent_role_scopes, so a policy author reading the bundle sees plainly it feeds logging/debugging only and that target_scopes[input.identity.service_id] is the capability gate. (reply on the inline thread)

Non-integration suite stays green (472 passed).

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large, well-structured PR delivering OPA plugin integration and live enforcement across the AIAC stack. All CI checks pass, all 39 commits carry DCO sign-off, and no agent/IDE config files are touched.

One minor documentation nit on the new driver script (see inline); everything else looks solid.


Reviewed by clawgenti using the github-pr-review skill

}

# admin_token — realm master admin token for Keycloak admin API (B.2)
admin_token() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: admin_token() hardcodes admin/admin for the Keycloak master realm, but unlike mint_token() (which has a # DEV ONLY: password == username … guard comment at line 122–124) this function has no equivalent caveat. Recommend adding a short # DEV ONLY: admin/admin is the seeded Kind cluster default — never copy into staging/prod note here for consistency and to protect against cargo-copying.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ba30155 — added a DEV ONLY guard comment right before the curl grant in admin_token(), mirroring mint_token()'s existing caveat: warns that admin/admin is the seeded Kind cluster default and should never be cargo-copied into a staging/production script.

@oblinder

oblinder commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@abigailgold thanks — responding to all three.

Must-fix: off by default / least-intrusive

We were asked to be as least-intrusive as possible — i.e. any new feature should be turned off by default. We're contributing three things in this PR:

  1. Being triggered, the AIAC system now creates a set of AuthorizationPolicy Custom Resources.
    Why there is no impact on the system here: this PR implements only a partial integration. The activating trigger is not implemented yet — the system is activated (and eventually produces the AuthorizationPolicy CRs) on an external API call only. No one is going to do that for now, so the system, even if deployed, remains inactive.

  2. The OPA plugins pipeline (A2A, MCP, Token Exchange and OPA plugins) is configured to run in the AuthBridge pipeline.
    Why there is no impact on the system here: the mentioned plugins are deployed on demand only, and by default they don't exist. No OPA exists either, so no additional disable switch is needed.

  3. The existing Token Exchange plugin has been enhanced to parse and populate an additional field (subject ID) to be available in the OPA plugin.
    Why there is no impact on the system here: the Token Exchange plugin is not deployed by default, as mentioned above. However, it's worth mentioning that existing demo setups may require this plugin to be installed in the AuthBridge pipeline — in which case the plugin will come up including our enhancement. We'd like to leave this feature in with no switch-off, but ask an additional reviewer (from the AuthBridge team) to review this change.

Nit: upsert_policy batch partial-failure

Confirmed the semantic and checked the caller. The only caller of the batch POST /policy is the Policy Computation Engine (aiac/src/aiac/policy/computation/engine.py), which builds PolicyModel(agents=derived) where every agent_id is a store-derived, already-validated service id — not caller-supplied free text. So a malformed agent_id mid-batch cannot arise from this path, and server-side apply is idempotent, so a retry safely re-applies the whole set. The # ...no rollback comment stays as the honest contract for any future caller that does pass unvalidated ids.

Nit: unpinned kubernetes client

Done in 2233b70 — pinned to the tested major (kubernetes>=36.0.3,<37) in aiac/src/aiac/pdp/service/policy/opa/requirements.txt, with a comment noting the elevated blast radius now that the writer mutates cluster state. Left as a major-cap (not an exact pin) to stay consistent with the file's convention while closing the surprise-37.x gap.

oblinder added a commit to s-and-p-team/cortex that referenced this pull request Aug 12, 2026
Address PR rossoctl#752 review feedback:
- opa-kind-enable.sh hardcoded the bundle_url namespace to
  rossoctl-system on both OPA legs, silently breaking the
  RELEASE_NAMESPACE override. Restore ${RELEASE_NAMESPACE} (the
  heredoc is unquoted, so it expands).
- Add an inline comment at the jwt.ParseInsecure call site noting
  the decoded subject is used for delegation provenance only, never
  for an auth decision.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
@oblinder
oblinder requested a review from esnible August 12, 2026 16:40
Move aiac/docs/opa-kind-runbook.md and scripts/opa-kind-{enable,restore,driver}.sh
into aiac/k8s/, and update every reference to their old locations across
CLAUDE.md, the runbook itself, and the integration test suite.

Signed-off-by: Oleg Blinder <oblinder@gmail.com>
admin_token() hardcodes the Keycloak master realm admin/admin credentials
with no caveat, unlike mint_token()'s existing DEV ONLY guard. Add the same
warning so the seeded Kind-cluster default is never cargo-copied into a
staging/production script.

Signed-off-by: Oleg Blinder <oblinder@gmail.com>

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substantial, well-structured PR: the OPA outbound identity synthesis, delegation chain exposure, CR-writer lift, and Rego generator rework all hang together coherently and the test coverage is solid. A couple of small gaps worth noting.

Findings:

  • plugin_test.go several buildInput call sites (e.g. lines 344, 382, 405, 426, 453) pass "" as agentID — when pctx.Extensions.Delegation is non-nil those tests will get a synthesized input.identity["client_id"] = "", but none of them assert on that field. Add at least a nil-check or document the empty-string contract.
  • rego.py source_ok if { not input.identity.client_id } — on the outbound leg buildOutboundIdentity always emits client_id (even when agentID is ""), so this bypass rule will never fire outbound. On inbound it fires only when the OPA plugin has no validated JWT. Worth a short comment clarifying the intended trigger surface so future authors don't assume it covers the outbound pass-through case.

Reviewed by clawgenti using the github-pr-review skill

Host: "github-tool-mcp",
Headers: http.Header{},
}
input := buildInput(pctx, inc, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call passes "" as agentID. If pctx.Extensions.Delegation were non-nil here, buildOutboundIdentity would emit "client_id": "" — an observable but untested behavior. The delegation tests below (e.g. TestBuildInput_WithDelegation) also pass "" and don't assert on client_id. Consider adding an assertion on client_id in the delegation-present tests, or explicitly documenting that "" is the expected value when no agentID is configured.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cf5bf7d — added TestBuildInput_OutboundIdentityEmptyClientIDWhenAgentIDUnset, which drives buildInput(pctx, inc, "") with a delegation hop present and asserts explicitly that the synthesized input.identity.client_id is present as the empty string (not omitted). That pins the previously-untested "" contract. Outbound allow keys on service_id/scopes, never client_id, so "" is benign — the test just makes the observable value explicit. Thanks for the catch.

subject holds a role granting >=1 of ``agent_scopes``) AND ``source_ok``.

``source_ok`` passes when there is no calling ``client_id`` (end-user
traffic), when the ``client_id`` is one of ``platform_clients`` (the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

source_ok if { not input.identity.client_id } — on the outbound leg buildOutboundIdentity always populates client_id (even as "" when agentID is unset), so not input.identity.client_id will never be true outbound. On inbound it fires only when no JWT was validated. A short comment here explaining the intended trigger (inbound-only, unauthenticated callers) would prevent future readers from assuming this covers outbound pass-through traffic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cf5bf7d — added a comment above the rule in generate_inbound_rego clarifying its intended trigger surface: it is inbound-only in practice, firing for unauthenticated callers (no validated JWT, so input.identity.client_id is unset). It never fires outbound, since buildOutboundIdentity always populates client_id (as "" when agent_id is unset), so not input.identity.client_id is never true there. Thanks.

- rego.py: comment the inbound-only source_ok bypass rule, noting it never
  fires outbound since buildOutboundIdentity always populates client_id.
- plugin_test.go: add TestBuildInput_OutboundIdentityEmptyClientIDWhenAgentIDUnset
  pinning the client_id == "" contract when delegation is present but no
  agent_id is configured.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large but well-structured phase-2 landing: OPA CR writer, AuthBridge outbound delegation identity, token-exchange bearer-subject extraction, and the Policy Store → Policy Model Store / kagenti → rossoctl renames all land cleanly together.

  • upsert_agent ignores the URL agent_id (/policy/agents/{agent_id}): the route param is accepted but unused — _upsert_agent(model) derives namespace/name from model.agent_id instead. The test confirms this intentionally (json=_agent(SPIFFE) against /policy/agents/ignored), but the URL path segment is dead code and could mislead callers who assume it overrides the body. Consider either removing the param or asserting they match.
  • Batch upsert is not atomic: upsert_policy aborts on the first 400/502 but leaves previously applied CRs written. The docstring acknowledges this ("no rollback"), but there's no compensating rollback or partial-failure response — callers get a 400 without knowing which agents succeeded. Fine for now given the use case, but worth a TODO comment so it's not surprised later.
  • subjectFromToken uses jwt.ParseInsecure: correctly documented as best-effort / observability-only, never for auth. The comment chain from recordDelegationHopbuildOutboundIdentity → README is thorough and consistent. No issue, just confirming the trust boundary is clearly stated.

Reviewed by clawgenti using the github-pr-review skill

out_dir: Path = Depends(get_output_dir),
):
return _run_write(lambda: _upsert_agent(out_dir, model))
def upsert_agent(agent_id: str, model: AgentPolicyModel):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route param agent_id is declared here but never used — _upsert_agent(model) derives namespace/name from model.agent_id. The test exercises this with /policy/agents/ignored to prove the body wins. Either document the param as intentionally ignored (legacy compatibility?) or drop it; as-is it will confuse callers who expect the URL to be authoritative.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aa19fc1 — documented the param as intentionally ignored rather than dropping it. The request body is authoritative: _upsert_agent(model) derives namespace/name from model.agent_id (via identity_ref), the single source of truth, so a mismatched or placeholder URL segment (e.g. /policy/agents/ignored) never affects which CR is written. The added comment says exactly that, and notes the segment is kept only to give the route a RESTful shape. Thanks.

The POST /policy/agents/{agent_id} route param is intentionally unused;
the request body is authoritative (_upsert_agent derives namespace/name
from model.agent_id via identity_ref). Add an inline comment so callers
don't assume the URL segment is authoritative.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior review nits addressed cleanly — the upsert_agent path-param comment (last commit), source_ok bypass comment in rego.py, and TestBuildInput_OutboundIdentityEmptyClientIDWhenAgentIDUnset test all land correctly. All CI checks pass, all commits signed.

All checks pass. Ready for human review.


Reviewed by clawgenti using the github-pr-review skill

@esnible esnible left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large, well-structured phase-2 PR wiring AIAC into live OPA enforcement: a CR-based PDP policy writer with server-side apply, a reworked Rego generator matching the live AuthBridge plugin input shape, outbound delegation/identity synthesis in the Go plugins, and a rebuilt integration-test suite closing the real enforcement loop on Kind. This has already been through several rounds of automated review with fixes landed for each round (RELEASE_NAMESPACE restore, dev-only guards, Rego comments, empty-client_id test coverage). I independently re-verified the current diff rather than trusting prior comments.

Author: oblinder (MEMBER — maintainer)
Areas reviewed: Go (AuthBridge plugins), Python (PDP/OPA writer, Rego generator), Helm/K8s (RBAC), Shell, Docs
Agent/IDE config (.claude/.vscode): none
Commits: 39 unique commits, all signed-off
CI status: all green (CodeQL, Bandit, Hadolint, DCO, Go/Python/Shell/YAML lint, PR title check)

No must-fix issues. RBAC for aiac-pdp-policy-writer is minimal (get/list/create/update/patch/delete on authorizationpolicies only, no watch) and the cluster-wide scope is justified since agents can live in arbitrary namespaces. identity_ref() validates namespace/name as DNS-1123 labels before use in k8s object names, blocking injection. The source_ok bypass concern raised in earlier review rounds is confirmed resolved — the outbound Rego package never emits source_ok at all. Two minor items inline, neither blocking.

// there is no validated Identity and no JWKS trust anchor for the caller's
// token, but that token (used as the RFC 8693 subject_token) still names the
// delegated subject. This is used only to enrich observability / policy input
// (delegation provenance), never for an auth decision — so an unverified

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This comment says the unverified sub claim is "never for an auth decision," but it does flow through Delegation.OriginbuildOutboundIdentity's subject field → the outbound Rego's subject_ok gate, which does gate allow on the outbound leg. Before this PR, the outbound leg had no fallback here, so subject was always empty and subject_ok was effectively vacuous for that field; now an attacker-supplied/garbage bearer's unverified sub claim can influence the outbound decision if a policy author keys on it (as the shipped example policy does).

This is an existing, already-partially-documented trust boundary (plugin.go's buildDelegationInput doc explicitly notes there's "no inbound JWT to validate" on the outbound leg), so I'm not flagging it as a blocker — just suggesting tightening this comment (e.g. "not independently verified; consumed by outbound subject_ok only when a policy author chooses to gate on it") so it isn't miscited later as a hard security guarantee.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — tightened in 6f78aed so the comment can't be miscited as a hard guarantee. The subjectFromToken godoc now spells out the trust boundary explicitly: the sub claim is not independently verified, it is surfaced only as input.identity.subject (delegation provenance), and AuthBridge itself makes no auth decision from it — but any policy that gates allow on input.identity.subject (e.g. the outbound subject_ok rule) is trusting a forgeable value, which is a deliberate choice the policy author owns. The inline comment at the ParseInsecure call site now reads unverified: … forgeable, so gating allow on it is a policy-author trust decision (see godoc) instead of the old "not for auth". Thanks.

def upsert_policy(policy: PolicyModel):
def _op():
# A malformed agent_id aborts the batch with a 400 naming it; agents
# already applied before that point stay written (no rollback).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Batch upsert is non-atomic (documented here) with no bound on batch size. Acceptable as-is per this comment's own caveat, but consider filing a follow-up issue/TODO reference so the partial-write behavior is tracked rather than living only in a docstring.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6f78aed — filed a follow-up so the partial-write behavior is tracked rather than living only in the docstring: s-and-p-team#143 (batch POST /policy is non-atomic and unbounded). The inline comment now references that issue and notes possible hardening directions (pre-validate all ids atomically, partial-success response body, bound the batch size). It stays acceptable for the current caller — the Policy Computation Engine builds the batch from store-derived, already-validated service ids, and SSA is idempotent so a retry re-applies the whole set. Thanks.

…upsert follow-up)

- tokenexchange/plugin.go: reword subjectFromToken godoc + inline comment so
  the unverified sub claim is not miscited as a hard auth guarantee. It is
  forgeable and surfaced as input.identity.subject; gating allow on it (e.g.
  outbound subject_ok) is a policy-author trust decision.
- opa/main.py: note the non-atomic, unbounded batch POST /policy is tracked
  for hardening in #143.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Oleg Blinder <oblinder@gmail.com>
@abigailgold
abigailgold merged commit 4636419 into rossoctl:main Aug 13, 2026
19 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Aug 13, 2026
@abigailgold
abigailgold deleted the aiac-phase2-opa branch August 13, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ai-review Request automated AI code review from clawgenti

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants