Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ offline unit lane.
- To write a new test (placement, markers, clean-skip), see
`docs/testing/testing-strategy.md`.

**Dependency direction.** Tests of any type (`test/`, `eval/`) must not depend on
`demo/` or point to a demo doc (for example `demo.md`). They set up what they need
themselves. The demo may use the test harness, but the harness must not use the demo.
One exception: `demo/assets/` (the `github-agent` and `github-tool` workloads, their
manifests, Dockerfiles and `kind-load.sh`). The system tests deploy these two
workloads, so they may use `demo/assets/` and nothing else under `demo/`.

## Python environment

Always use the `.venv` virtual environment (`.venv/bin/python`, `.venv/bin/pytest`)
Expand Down
4 changes: 2 additions & 2 deletions demo/use-cases/onboarding/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -817,8 +817,8 @@ make teardown ARGS=--include-opa # also revert step 2's overlay and combiner (
- **the `team1` namespace itself** — the installer owns it
([`INSTALL.md`](../../assets/INSTALL.md): "a precondition, not an output"). A fresh install gives
you an *empty* `team1`, so emptying it *is* the post-install state.
- **the realm fix-up** (Direct Access Grants) — one-time cluster state that this demo and the
`-m system` suite both depend on. The client scope `aiac-username-sub` and its links (D31) are
- **the realm fix-up** (Direct Access Grants) — one-time cluster state that this demo depends on.
(The `-m system` suite enables the grant itself.) The client scope `aiac-username-sub` and its links (D31) are
AIAC's; AIAC makes them again at its next start and onboarding.
- **the operator's `*-aud` audience client scopes**, which it owns and recreates.
- **step 2's OPA overlay**, unless you pass `--include-opa`.
Expand Down
9 changes: 6 additions & 3 deletions docs/agents/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,12 @@ test both sides, run the lane, do a clean redeploy with the other side (see
`docs/agents/deploy.md`), and run the lane again.

The platform prerequisites, wiring, verification and the setting of the side:
`k8s/opa-kind-runbook.md`. The realm fix-up (Direct Access Grants) and the manual
probes of the scenario: `demo/use-cases/onboarding/demo.md`. The per-loop shape:
`test/system/uc1_onboard.py`.
`k8s/opa-kind-runbook.md`. The fixture enables Direct Access Grants on the login
client `rossoctl` itself and turns it off again at teardown if it was off, so the
lane needs no manual realm step. A
manual probe with the password grant outside the lane needs the grant set by hand:
see the runbook section "Direct Access Grants on the login client". The per-loop
shape: `test/system/uc1_onboard.py`.

```bash
k8s/opa-kind-enable.sh # one time: wire the OPA plugin and the AIAC combiner into Kind
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ The Controller reads `AIAC_ENFORCEMENT_SIDE` at start, and an unknown value stop
- **The namespace pipelines.** The `authbridge-runtime-config` ConfigMap of each service namespace must have `opa` in the inbound pipeline, and also `mcp-parser` for a tool. Under agent side, the agents' namespaces also need `mcp-parser` and `opa` in the outbound pipeline. Onboarding check #2 finds a missing plugin.
- **Pod restarts.** The webhook injects the sidecar only on pod CREATE. After a change to the pipeline or to `injectTools`, restart the agent pods and the tool pods (`rossoctl.io/type=agent` and `rossoctl.io/type=tool`).
- **The username precondition (D31).** The CRs key users by username, and `sub` is the username. So usernames must be unique, must not change and must never be used again. Keep `editUsernameAllowed: false` and `registrationAllowed: false` in the realm (the operator's realm template sets `registrationAllowed: false`; it does not set `editUsernameAllowed`, so that value is `false` only by the Keycloak default). An admin must never create a deleted username again: the new user would get the grants of the old user until AIAC writes the CRs again.
- **The login clients (D31).** Each login client that calls AIAC agents directly must be in `PLATFORM_SOURCE_CLIENTS` (the `aiac-pdp-config` ConfigMap, default `rossoctl`). The IdP Configuration Service then links `aiac-username-sub` to it, so that the login token has `sub` = the username. The same setting also makes the client a platform client of the source gate (PDP Policy Writer). There is no manual `sub` mapper step: do not add a `sub` client mapper to a login client. The only manual step on `rossoctl` is Direct Access Grants, and only the password grant of the runbook probes and of the test harness needs it.
- **The login clients (D31).** Each login client that calls AIAC agents directly must be in `PLATFORM_SOURCE_CLIENTS` (the `aiac-pdp-config` ConfigMap, default `rossoctl`). The IdP Configuration Service then links `aiac-username-sub` to it, so that the login token has `sub` = the username. The same setting also makes the client a platform client of the source gate (PDP Policy Writer). There is no manual `sub` mapper step: do not add a `sub` client mapper to a login client. The password grant of the test harness and of manual user-token probes needs Direct Access Grants on `rossoctl`. The system-test fixture enables it (`provision_realm_and_users` → `ensure_direct_access_grants`) and turns it off again at teardown if it was off; a manual probe needs it set by hand ([`k8s/opa-kind-runbook.md`](../../k8s/opa-kind-runbook.md#direct-access-grants-on-the-login-client)).
- **The legacy token exchange (optional hardening).** AIAC does not need the Keycloak feature `token-exchange` (V1) to be off: the AuthBridge requests go to the standard exchange (V2), and a request that V1 handles still gets `sub` = the username for an onboarded target (§6). Keycloak recommends to turn V1 off, because then a request that V2 declines fails with `400` ("Standard token exchange is not enabled for the requested client") and does not silently go to a different engine. `KC_FEATURES` is in the Keycloak deployment of the rossoctl platform, not in this repo. If V1 stays on, AIAC needs no change.

Detail: [`k8s/opa-kind-runbook.md`](../../k8s/opa-kind-runbook.md).
Expand Down Expand Up @@ -874,7 +874,7 @@ Require a live rossoctl/Kind cluster with the AuthBridge OPA pipeline wired in (
| `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` | LLM endpoint for onboarding |
| `AIAC_TEST_REALM` | Optional: realm of the live stack (default from the scenario) |

System tests onboard through the in-cluster Controller and assert the deployed OPA plugin's allow/deny on real HTTP requests through AuthBridge. They skip cleanly when the cluster or the env is missing. Under target side, github-tool's own inbound OPA decides the tool calls, and the agent's outbound is a pass-through. A service that has no CR is denied, and a quarantine deletes the CR. The demo passes under each enforcement side; each side is a separate deploy (a clean redeploy changes the side). After each workload converges, `require_subject_scope` checks that its client links `aiac-username-sub` as a default scope (D31); a missing link is a failure, not a skip. `require_subject_scope` also checks that the login client `rossoctl` links the scope as a default scope (a missing login client, a missing link or an optional link only is a failure). Rung 2 also checks the scope and the links of `rossoctl`, `github-agent` and `github-tool`, that `rossoctl` has no client mapper that writes `sub`, and that the `rossoctl` login token of `dev-user` has `sub` = `dev-user` (`test_subject_scope_linked`), and the `sub` of an exchanged token (`test_exchanged_token_subject_is_username`: the harness exchanges as the agent client with its client secret, the identity that `k8s/opa-kind-enable.sh` gives AuthBridge; it skips only if the agent client uses another client authenticator). `verify_login_subject` checks the login token through `rossoctl`: it skips when it cannot mint the token (Direct Access Grants), and fails when `sub` is not the username, because the link is an AIAC step. Rung 5 checks that the rollback keeps the scope (`test_rollback_keeps_the_subject_scope`).
System tests onboard through the in-cluster Controller and assert the deployed OPA plugin's allow/deny on real HTTP requests through AuthBridge. They skip cleanly when the cluster or the env is missing. Under target side, github-tool's own inbound OPA decides the tool calls, and the agent's outbound is a pass-through. A service that has no CR is denied, and a quarantine deletes the CR. The demo passes under each enforcement side; each side is a separate deploy (a clean redeploy changes the side). After each workload converges, `require_subject_scope` checks that its client links `aiac-username-sub` as a default scope (D31); a missing link is a failure, not a skip. `require_subject_scope` also checks that the login client `rossoctl` links the scope as a default scope (a missing login client, a missing link or an optional link only is a failure). Rung 2 also checks the scope and the links of `rossoctl`, `github-agent` and `github-tool`, that `rossoctl` has no client mapper that writes `sub`, and that the `rossoctl` login token of `dev-user` has `sub` = `dev-user` (`test_subject_scope_linked`), and the `sub` of an exchanged token (`test_exchanged_token_subject_is_username`: the harness exchanges as the agent client with its client secret, the identity that `k8s/opa-kind-enable.sh` gives AuthBridge; it skips only if the agent client uses another client authenticator). `verify_login_subject` checks the login token through `rossoctl`: the fixture first enables Direct Access Grants on `rossoctl` and skips with the cause when it cannot (a missing `rossoctl`, or an admin that cannot manage clients); it skips when it still cannot mint the token, and fails when `sub` is not the username, because the link is an AIAC step. Rung 5 checks that the rollback keeps the scope (`test_rollback_keeps_the_subject_scope`).

Selection is marker-only — no path argument. `integration` (several units in-process, no cluster), `system`, `llm` (real LLM, no cluster) and `eval` are opt-in markers:

Expand Down
4 changes: 3 additions & 1 deletion docs/testing/policy-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ over the fully onboarded stack.
provisions these; UC-1 does not), `verify_login_subject` mints a login token through the login client `rossoctl`
and checks that its `sub` is the username, and `ensure_agent_policy` mounts the single
abstract `policy.md` on the Controller pod. `verify_login_subject` skips when it cannot mint the
token (Direct Access Grants is not on), and fails when `sub` is not the username. The `sub` comes
token (`provision_realm_and_users` enables Direct Access Grants first, and skips with the cause if
the login client is absent or the admin cannot manage clients; the teardown turns the grant off
again if it was off), and fails when `sub` is not the username. The `sub` comes
from the client scope `aiac-username-sub`, which the IdP Configuration Service links to
`rossoctl` at its startup and at each onboarding. That link is an AIAC step, not a prerequisite,
so a wrong `sub` is a failure. The exchanged token gets the same mapping from the same scope,
Expand Down
6 changes: 4 additions & 2 deletions docs/testing/uc1-onboarding-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,10 @@ cleanly** when any of it is absent (they never stand it up, and never false-pass
*[Scenario](#scenario)* — via `KeycloakAdmin` into `AIAC_TEST_REALM`, **before** deploying (the PRB
reads the realm role universe when the event fires `onboard_and_apply`); idempotent; left in place.
`verify_login_subject` mints a login token through the login client `rossoctl` and checks that its
`sub` is the username. It skips when it cannot mint the token (Direct Access Grants is a
prerequisite), and fails when `sub` is not the username. The `sub` comes from the client scope
`sub` is the username. `provision_realm_and_users` first enables Direct Access Grants on
`rossoctl` (`ensure_direct_access_grants`; the teardown turns it off again if it was off) and
skips with the cause if `rossoctl` is absent or the admin cannot manage clients. It skips when it
still cannot mint the token, and fails when `sub` is not the username. The `sub` comes from the client scope
`aiac-username-sub`, the one source of the rule (D31). The IdP Configuration Service links it to
`rossoctl` at its startup and at each onboarding, so the link is an AIAC step, not a
prerequisite. There is no `sub` client mapper on `rossoctl`. The exchanged token gets the same
Expand Down
41 changes: 41 additions & 0 deletions k8s/aiac-deployment-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,47 @@ kind load docker-image localhost/aiac-pdp-config:local --name <cluster-name>
kubectl rollout restart deployment/aiac-interface -n aiac-system
```

### Rebuilding the AIAC Agent: always reload its ConfigMap

Every time you rebuild `localhost/aiac-agent:local`, also reload the `aiac-agent-config`
ConfigMap. Do not skip it, also when only code changed. It makes sure the Agent runs with the
LLM endpoint and model of the repo-root `.env`, and not with an older value in the cluster.

```bash
docker build -f src/aiac/agent/controller/Dockerfile -t localhost/aiac-agent:local src/
kind load docker-image localhost/aiac-agent:local --name <cluster-name>

# Reload the ConfigMap: the apply writes the manifest values (placeholders), the patch
# puts the real LLM values from .env back (step 3b). A subshell: a failed check or step
# stops the rest of the block, not your shell.
( set -e
set -a; . .env; set +a
: "${LLM_BASE_URL:?missing in .env}" "${LLM_MODEL:?missing in .env}"
# Read the live side and log level BEFORE the apply: the apply resets them to the manifest values.
SIDE=$(kubectl get configmap aiac-agent-config -n aiac-system -o jsonpath='{.data.AIAC_ENFORCEMENT_SIDE}')
LEVEL=$(kubectl get configmap aiac-agent-config -n aiac-system -o jsonpath='{.data.LOG_LEVEL}')
kubectl apply -f k8s/agent-deployment.yaml

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.

9. The ConfigMap reload quietly resets tuned keys. The new "always reload the ConfigMap" flow runs kubectl apply -f k8s/agent-deployment.yaml on every agent rebuild. It keeps only AIAC_ENFORCEMENT_SIDE and LOG_LEVEL, so the apply quietly sets all other live keys back to the manifest values.

Failure scenario: An operator has tuned LLM_REQUEST_TIMEOUT, LLM_MAX_RETRIES or LLM_RETRY_BACKOFF_* on the cluster. The guide says not to skip the reload even after a code-only rebuild, and the reload puts those keys back to the defaults (120/3/1/30) with no warning. This change is also outside the scope of this PR (no demo dependency in the tests).

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.

Fixed in 4f8043b. The reload block in k8s/aiac-deployment-guide.md now reads all live keys before the apply. The patch then writes them back, with the .env LLM values on top ($live + {LLM_BASE_URL, LLM_MODEL}). Tuned keys such as LLM_REQUEST_TIMEOUT, LLM_MAX_RETRIES and LLM_RETRY_BACKOFF_*, the side and the log level keep their live values. Only a key that is new in the manifest gets the manifest value. I also added pipefail, so a failed read stops the block before the apply. The scope question is still open: this section came in with commit a589c4f, and that commit can move to its own PR.

# jq builds the JSON, so a quote or a backslash in a value cannot break the patch.
kubectl patch configmap aiac-agent-config -n aiac-system --type merge -p "$(jq -n \
--arg url "$LLM_BASE_URL" --arg model "$LLM_MODEL" \
--arg side "${SIDE:-target-side}" --arg level "${LEVEL:-INFO}" \
'{data: {LLM_BASE_URL: $url, LLM_MODEL: $model, AIAC_ENFORCEMENT_SIDE: $side, LOG_LEVEL: $level}}')"

# Restart AFTER the patch. The Agent reads the ConfigMap at start only.
kubectl rollout restart deployment/aiac-agent -n aiac-system
kubectl rollout status deployment/aiac-agent -n aiac-system
)
```

- The apply also writes the manifest values of `AIAC_ENFORCEMENT_SIDE` (`target-side`) and
`LOG_LEVEL` (`INFO`). The block reads the live values before the apply and patches them back
before the restart, so an `agent-side` deployment stays `agent-side`. To change the side, see
`docs/agents/deploy.md`, `ac-side`.
- `.env` must have `LLM_BASE_URL` and `LLM_MODEL`. If one is missing, the `:?` check stops the
block before the apply. Then stop and ask. Do not leave the placeholder in the ConfigMap.
- The Secret `aiac-agent-secret` (`LLM_API_KEY`) is not changed by this flow. To change it, see
step 3b.

---

## Phase 2: Upgrading the OPA PDP Policy Writer to the CR-backed implementation
Expand Down
35 changes: 35 additions & 0 deletions k8s/opa-kind-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,41 @@ such pods, it skips this check.

---

## Direct Access Grants on the login client

A manual probe that mints a user token with the password grant through the
login client `rossoctl` needs Direct Access Grants on that client. In a fresh
realm it is off, and the mint fails with `unauthorized_client`. The system
tests do not need this step: their fixture enables the grant itself
(`ensure_direct_access_grants` in `test/system/uc1_onboard.py`) and turns it off
again at teardown if it was off. To enable it by hand (a partial update: only
this field changes), source the repo-root `.env` first. The snippet uses the
same variables as the harness:

```bash
( set -e # a subshell: a failed check stops the snippet, not your shell
set -a; . .env; set +a
KC=${KEYCLOAK_URL:?}; REALM=${AIAC_TEST_REALM:-rossoctl}
ADMIN=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \

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.

10. The snippet hard-codes the admin realm. The manual DAG snippet says it "uses the same variables as the harness", but it hard-codes the admin realm master. The harness reads KEYCLOAK_ADMIN_REALM (default master).

Failure scenario: A .env sets KEYCLOAK_ADMIN_REALM to a value other than master, and the harness's admin login works with it. The runbook snippet logs in at realms/master, gets no access_token, and prints "admin login failed: check KEYCLOAK_ADMIN_*". That message points the operator at the wrong cause.

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.

Fixed in 4f8043b. The runbook snippet now sets ADMIN_REALM=${KEYCLOAK_ADMIN_REALM:-master} and logs in at realms/$ADMIN_REALM. The harness uses the same rule. The error message now shows the admin realm that it tried.

-d client_id=admin-cli -d grant_type=password \
--data-urlencode "username=${KEYCLOAK_ADMIN_USERNAME:?}" \
--data-urlencode "password=${KEYCLOAK_ADMIN_PASSWORD:?}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
[ -n "$ADMIN" ] || { echo "admin login failed: check KEYCLOAK_ADMIN_*"; exit 1; }
CID=$(curl -s -H "Authorization: Bearer $ADMIN" "$KC/admin/realms/$REALM/clients?clientId=rossoctl" \
| python3 -c 'import sys,json;c=json.load(sys.stdin);print(c[0]["id"] if c else "")')
[ -n "$CID" ] || { echo "login client rossoctl not found in realm $REALM"; exit 1; }
curl -s -o /dev/null -w "enable DAG HTTP %{http_code}\n" -X PUT \
-H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" \
"$KC/admin/realms/$REALM/clients/$CID" -d '{"directAccessGrantsEnabled": true}'
)
```

Expect `enable DAG HTTP 204`. The grant stays on; set the field
to `false` the same way to turn it off again.

---

## Restore

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,12 @@ def test_cross_service_conflict_is_surfaced_as_422_conflict_report() -> None:
# Open the cleanup guard BEFORE the first shared-state mutation: the clean-slate steps below
# (Agent CR delete, Keycloak cleanup, Policy Store clear, provisioning) already mutate shared
# cluster state, so a failure mid-setup must still hit the ``finally`` teardown.
grant_enabled = False
try:
uc1.delete_workload_crs() # clean policy slate (drop any prior run's CRs)
uc1.cleanup_provisioned(admin, TEST_REALM) # clean slate (Keycloak)
uc1.clear_policy_store() # clean slate ONCE — NOT between phases (the agent must see the tool's rule)
uc1.provision_realm_and_users(admin, TEST_REALM) # PRB reads the role universe
grant_enabled = uc1.provision_realm_and_users(admin, TEST_REALM) # PRB reads the role universe
uc1.verify_login_subject(
keycloak_url=keycloak_url, realm=TEST_REALM, user="test-user", password=scn.USER_PASSWORD
)
Expand All @@ -187,6 +188,8 @@ def test_cross_service_conflict_is_surfaced_as_422_conflict_report() -> None:
finally:
uc1.delete_workload_crs() # after — drop any CR (there should be none on the raising path)
uc1.cleanup_provisioned(admin, TEST_REALM) # restore the pre-run Keycloak state
if grant_enabled:

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.

2. Grant restore can be skipped. In the finally block, the grant restore comes after delete_workload_crs() and cleanup_provisioned(). Either can raise, and then the restore does not run.

Failure scenario: cleanup_provisioned raises KeycloakError from admin.get_realm_roles() (it is not guarded), or delete_workload_crs raises. The if grant_enabled: restore_direct_access_grants(...) line never runs, so DAG stays on. The policy store is also not cleared, and the next tests then depend on the order they run in.

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.

Fixed in 4f8043b. The disabled cross-service test now wraps its try/finally in with uc1.direct_access_grants(admin, TEST_REALM):. If delete_workload_crs or cleanup_provisioned raises, the grant is still restored. The order of the cleanup steps (a raise there skips clear_policy_store) was the same on main before this PR, so I did not change it here.

uc1.restore_direct_access_grants(admin, TEST_REALM)
# Phase 1 persisted a tool-side deny; clear it so later integration tests don't read stale
# inbound rules and become order-dependent.
uc1.clear_policy_store()
Expand Down
Loading
Loading