Repository navigation
feat(authbridge): switch the outbound leg to token-exchange-aiac - #242
anatolykoyfman wants to merge 21 commits into
Conversation
Wire discovery-exchange (no routes) in place of token-exchange on the outbound AuthBridge leg. The audience is derived from the destination host (spiffe://<td>/ns/<ns>/sa/<service>); a target is granted only by a default client scope of the caller, since the plugin requests no scope. - k8s: opa-kind-enable.sh writes the discovery-exchange config (TRUST_DOMAIN, AGENT_NAMESPACE); opa-kind-verify.sh checks one discovery-exchange and no token-exchange; the runbook documents the outbound token exchange and its outcomes. - demo: drop authproxy-routes; WIRE grants the github-tool audience as a default scope and no longer patches a route; restore/teardown follow. - test/system: drop ensure_github_tool_route; grant_exchange_scope assigns a default scope; exchange_token sends no scope by default; is_exchange_refusal keeps a discovery-exchange refusal from counting as an OPA deny; the convergence gate waits for the sidecar rollout, so a probe cannot reach the plain first pod past OPA. - test/unit: classifier and sidecar-rollout predicate tests. - docs: PRD, specs, testing docs; two analysis notes on the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Conflicts: docs/testing/uc1-onboarding-pipeline.md test/system/uc1_onboard.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
- grant_exchange_scope and the demo driver's WIRE phase move an optional github-tool audience link to default first: Keycloak skips a default link of a scope already linked as optional, so a realm wired by the old harness kept the optional link and every outbound call got a 403. - require_pipeline skips when discovery-exchange is not on the outbound leg: the harness adds no route, so an older token-exchange pipeline would fail every tool rung after BUNDLE_TIMEOUT instead of skipping. The testing specs list the new skip condition. - test_launcher_outcome.py moves to the test/unit/ root, next to the other launcher.py unit tests. - Harness comments name discovery-exchange for the outbound refusals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
…t's exchange rule - grant_exchange_scope reads and removes an optional link outside the try that logs "benign if already assigned", so a failed read or delete raises instead of leaving the scope optional with an INFO line. - The demo driver checks the DELETE status (204 or 404) and dies on any other answer, instead of a WIRE pass and an ENFORCE failure. - exchange_plugin_reason, a pure decider, takes the rule of opa-kind-verify.sh: exactly one discovery-exchange and no token-exchange, quoted names included. Unit tests cover it. - is_exchange_refusal returns False for an unhashable plugin value instead of raising TypeError. - The rollback test counts a plain-body exchange refusal (403 / 503) as refused, not only an error frame in an HTTP 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
The cortex plugin discovery-exchange is now token-exchange-aiac (rossoctl/cortex#1496). Every reference follows: the pipeline overlay of opa-kind-enable.sh, opa-kind-verify.sh, the runbook, the specs, the testing docs, the demo and the system harness. The same change closes the third code review of the harness: - grant_exchange_scope raises on any Keycloak error: the assign is an idempotent PUT, so no error is benign, and without the default link every exchange is refused far from the cause. - The demo driver accepts only 204 from the optional-link DELETE: Keycloak answers 204 also when the scope is not linked. - The skip gate parses the runtime config (pipeline_plugins) and applies the 1/0 rule of opa-kind-verify.sh to the outbound leg only. The OPA check uses the same parsed legs. opa-kind-verify.sh matches whole names, because token-exchange is a prefix of token-exchange-aiac. - The rollback test counts only Keycloak's refusal (policy.forbidden) as blocked, decided by launcher.exchange_refusal. A failed exchange (an IdP error or outage) now fails it. token-exchange-aiac answers a disabled target with that refusal (target_disabled); the runbook lists it. - The onboarded_stack docstring describes Part B as it is: a default client scope and no route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
…OPA deny - notification_outcome reads the body: a 403 is "deny" only when it names OPA. token-exchange-aiac's 403 policy.forbidden for a session frame was reported as an OPA deny, the misclassification the request frames already avoid. mcp_session_decisions passes the body. - teardown.sh warns when team1's authproxy-routes still has the github-tool-mcp route of an older demo. It does not delete it: the ConfigMap carries no owner label, so a platform-seeded one looks the same. - INSTALL.md states the outbound prerequisite: the demo ships no routes, so it needs the token-exchange-aiac pipeline of opa-kind-enable.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Trim two trailing spaces and drop the space inside a code span (MD038), which failed the PR's lint and Markdown Lint checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
oblinder
left a comment
There was a problem hiding this comment.
Automated review (Claude Code). 10 findings; 9 are inline.
Not inline (the line is outside the diff): demo/use-cases/onboarding/driver.sh:360. The driver preflight still checks only that name: opa == 2. It does not check that the outbound leg runs token-exchange-aiac, but the demo now ships no routes and depends on that plugin. On the stock chart pipeline (token-exchange with routes), which INSTALL.md now says to avoid, preflight passes and WIRE grants the default scope. Then ENFORCE gets un-exchanged calls that github-tool rejects (401 or deny), and these are reported as policy failures. The PR adds a clean-skip gate only to the Python harness (exchange_plugin_reason), not to the driver.
The two most severe findings are the namespace in opa-kind-enable.sh and the removed github-tool-mcp route.
🤖 Generated with Claude Code
| identity: | ||
| type: "client-secret" | ||
| audience_template: "spiffe://${TRUST_DOMAIN}/ns/{namespace}/sa/{service}" | ||
| namespace: "${AGENT_NAMESPACE}" |
There was a problem hiding this comment.
Wrong namespace for agents outside AGENT_NAMESPACE. The overlay sets namespace: "${AGENT_NAMESPACE}" (team1) for short host names. But the chart renders this one authBridge.pipeline into the authbridge-runtime-config of every namespace in agentNamespaces (team1 and team2 by default).
Scenario: an agent in team2 calls http://foo:8080. The plugin derives spiffe://localtest.me/ns/team1/sa/foo, not .../ns/team2/sa/foo. No such client exists, so the call passes through un-exchanged and the callee's jwt-validation rejects it. Or, if a team1 client foo exists, the plugin asks for a token for another namespace's service.
There was a problem hiding this comment.
Fixed in 452341d. The plugin now has a namespace_file key (rossoctl/cortex#1496, 1049bd07): when namespace is empty it reads the namespace once at start, and a missing, blank or invalid file stops the sidecar. The overlay points it at /var/run/secrets/kubernetes.io/serviceaccount/namespace, so a short name called from team2 derives a team2 audience. ${POD_NAMESPACE} was not an option: the webhook injects no such env, and the config loader leaves an unset ${...} unexpanded. I checked that the injected authbridge-proxy container mounts that path.
| # AuthBridge exchanges the token for the audience derived from this | ||
| # host — see the token-exchange note in the header above. | ||
| - name: MCP_URL | ||
| value: "http://github-tool-mcp:9090/mcp" |
There was a problem hiding this comment.
Tool calls on github-tool-mcp are no longer exchanged. The PR deletes the authproxy-routes route (github-tool-mcp → github-tool, token_scopes: openid github-tool-aud github-full-access) but keeps MCP_URL=http://github-tool-mcp:9090/mcp. No step replaces the route's work: no Service=SA alignment and no default github-full-access grant.
Scenario: every agent tool call now derives an audience that has no Keycloak client. The call passes through with the user's un-narrowed token, and the tool's jwt-validation rejects it (401). Also, no token carries github-full-access now, so PAT selection breaks.
There was a problem hiding this comment.
Agreed. The UC-1 driver isn't affected (it calls the stub github-tool directly), but the production tool's live calls are: they go out un-exchanged, and no token carries github-full-access. In 452341d this is a known limitation in INSTALL.md and the PR's Merge Danger. The fix (Service = ServiceAccount alignment, or an MCP_URL change, plus github-full-access as a default scope of github-agent) is tracked in #247.
| kubectl get configmap authbridge-runtime-config -n team1 \ | ||
| -o jsonpath='{.data.config\.yaml}' | grep -c 'name: token-exchange-aiac' | ||
| kubectl get configmap authbridge-runtime-config -n team1 \ | ||
| -o jsonpath='{.data.config\.yaml}' | grep -c 'name: token-exchange' |
There was a problem hiding this comment.
This grep also matches token-exchange-aiac. grep -c 'name: token-exchange' counts name: token-exchange-aiac too, so a correctly wired cluster prints 1, not the expected 0. An operator can conclude that the legacy plugin is still wired. opa-kind-verify.sh uses an anchored \s*$ pattern, so the runbook and the script disagree.
There was a problem hiding this comment.
Fixed in 452341d. Check 1b now runs k8s/outbound-exchange-plugins.py, the helper verify.sh uses. It expects token-exchange-aiac=1 token-exchange=0.
| PIPELINE_CFG=$(kubectl get configmap authbridge-runtime-config -n "$NS" \ | ||
| -o jsonpath='{.data.config\.yaml}' 2>/dev/null || true) | ||
| # Whole names: the legacy token-exchange is a prefix of token-exchange-aiac. | ||
| DX_COUNT=$(grep -cE 'name: token-exchange-aiac\s*$' <<<"$PIPELINE_CFG" || true) |
There was a problem hiding this comment.
The grep gate and the Python gate can disagree. This greps the whole config.yaml with an unquoted, line-anchored pattern. The Python gate (pipeline_plugins / exchange_plugin_reason) parses YAML and looks at the outbound leg only.
- name: "token-exchange-aiac"(quoted, which the new unit test accepts) givesDX_COUNT=0, so verify.sh fails.- A commented-out
# - name: token-exchangegivesTX_COUNT=1, a false failure. - A legacy
token-exchangeon the inbound leg fails verify.sh but passes the system-lane gate.
Consider parsing the YAML here too (for example with yq), so both gates give one verdict.
There was a problem hiding this comment.
Fixed in 452341d. verify.sh now parses the YAML with k8s/outbound-exchange-plugins.py (python3 + PyYAML) and counts whole plugin names on the outbound leg only. A unit test runs the helper on every config the harness gate is tested with, including the quoted, commented-out, inbound-only and half-migrated cases, and checks that the two give the same verdict. The demo driver's preflight uses the same helper.
| on the agent's outbound leg: ``audience`` only, **no** ``scope`` (so only the client's default | ||
| scopes apply), ``subject_token_type`` and ``requested_token_type`` = access token. The client | ||
| authenticates with ``client_secret`` (form data, like ``mint_token``), | ||
| as AuthBridge does with the ``client-secret`` identity that ``k8s/opa-kind-enable.sh`` sets. Raises ``TokenExchangeError`` on a non-2xx answer; |
There was a problem hiding this comment.
Out-of-date docstring. This (and exchanged_subject in uc1_onboard.py) says AuthBridge uses the client-secret identity that k8s/opa-kind-enable.sh sets. The PR removes identity: type: client-secret from the overlay, and the overlay comment now says that key is rejected. The harness reasoning that "the exchange must succeed" rests on a setting the script no longer applies.
There was a problem hiding this comment.
Fixed in 452341d. Both docstrings now say why the exchange must succeed: token-exchange-aiac authenticates with the client secret the operator writes to /shared/client-secret.txt (its default client_secret_file) and has no other way to authenticate. They no longer mention the client-secret identity key.
| RELEASE_NAME="${RELEASE_NAME:-rossoctl}" | ||
| RELEASE_NAMESPACE="${RELEASE_NAMESPACE:-rossoctl-system}" | ||
| AGENT_NAMESPACE="${AGENT_NAMESPACE:-team1}" | ||
| TRUST_DOMAIN="${TRUST_DOMAIN:-localtest.me}" |
There was a problem hiding this comment.
A third, unlinked source of the trust domain. TRUST_DOMAIN defaults to localtest.me, separate from the chart's spireTrustDomain and the harness's AIAC_TRUST_DOMAIN.
Scenario: a cluster installed with spireTrustDomain: example.org and enable.sh run without TRUST_DOMAIN. The plugin derives spiffe://localtest.me/..., which names no client, so every exchange silently passes through un-exchanged. Reading the value from the chart values would remove the drift.
There was a problem hiding this comment.
Fixed in 452341d. TRUST_DOMAIN is gone. The audience template reads the chart's operator-chart.signatureVerification.spireTrustDomain through the chart's tpl(), the value the operator names SPIFFE clients with. helm template with another value puts it in every rendered copy.
| kubectl("apply", "-f", "-", input_text=json.dumps(cm), timeout=30) | ||
|
|
||
|
|
||
| def grant_exchange_scope(admin) -> None: |
There was a problem hiding this comment.
Duplicate of an existing helper. The optional→default move here (get_client_optional_client_scopes / delete_client_optional_client_scope / add_client_default_client_scope) already exists as _link_default_scope in src/aiac/idp/service/configuration/keycloak/main.py:326, and driver.sh repeats it in curl. The "Keycloak skips a default link of a scope already linked as optional" workaround now lives in three places, so a fix to one will not reach the others.
There was a problem hiding this comment.
Fixed in 452341d. grant_exchange_scope now calls _link_default_scope, so the harness and onboarding share the workaround. driver.sh is bash, so it keeps its curl version, with a comment pointing at the helper.
There was a problem hiding this comment.
Correction: I reverted the import in 94f237c. Importing main.py configures the root logger and builds its FastAPI app inside the pytest process, so the harness changes logging for the whole session. grant_exchange_scope keeps its own 3-line copy again, with a comment saying it mirrors _link_default_scope and why it is not imported. driver.sh still keeps its curl copy, pointing at the same helper.
| return (isinstance(plugin, str) and plugin in EXCHANGE_PLUGINS) or doc.get("error") == EXCHANGE_FAILED_ERROR | ||
|
|
||
|
|
||
| def exchange_refusal(code: int | None, body: str) -> bool: |
There was a problem hiding this comment.
Third copy of the JSON-RPC error-frame parsing. outbound_outcome and deny_origin already unwrap "an error frame at HTTP 200 to error.data, else the plain 403 body" separately. A shared helper would keep the three classifiers in agreement if the frame shape changes.
There was a problem hiding this comment.
Fixed in 452341d. A new rejection_doc unwraps both shapes (error.data of a frame at 200, else a plain 403 body) once. outbound_outcome, exchange_refusal, deny_origin and notification_outcome all use it, and it has its own unit tests.
|
|
||
|
|
||
| def notification_outcome(code: int | None) -> str: | ||
| def notification_outcome(code: int | None, body: str = "") -> str: |
There was a problem hiding this comment.
Risky default for body. The function now needs the body to classify a deny, but body defaults to "". A caller with the old call shape, notification_outcome(code), silently gets "error" for a real OPA 403 deny. Making body required would catch this at the call site.
- opa-kind-enable.sh no longer inlines a namespace or a trust domain: the chart renders one pipeline into every agent namespace. Short names use the pod's own namespace (namespace_file, rossoctl/cortex#1496), and the trust domain is the chart's operator-chart.signatureVerification .spireTrustDomain, the one the operator names clients with. TRUST_DOMAIN is gone. - opa-kind-verify.sh, the runbook's check 1b and the demo driver's preflight count the exchange plugins with k8s/outbound-exchange-plugins.py: parsed, outbound leg only, the rule of the harness's skip gate. A unit test checks that the two agree on every test config. - grant_exchange_scope reuses _link_default_scope; rejection_doc unwraps both rejection shapes for the four classifiers; notification_outcome requires the body; two docstrings no longer cite a client-secret identity the overlay dropped. - INSTALL.md records that the production github-tool-mcp calls are not exchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
|
Re the finding outside the diff ( |
…ness self-contained - The rollback test counts an exchange refusal as blocked only when the tool's client is disabled (target_disabled_refusal, by the plugin's message). An audience_not_granted refusal, such as a lost default-scope link, no longer passes the quarantine assertion. - grant_exchange_scope copies the optional-to-default move again instead of importing the IdP Configuration Service's main.py, which configures the root logger and builds its FastAPI app in the test process. - The exchange audience of exchanged_subject uses the trust domain of the live token-exchange-aiac template (exchange_trust_domain), the one the leg exchanges for; AIAC_TRUST_DOMAIN is the fallback. - outbound_outcome classifies AuthBridge's minimal fallback frame (code -32000, no error.data) as an error, not an allow, and parses the body once. - driver.sh's preflight checks for PyYAML. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
… the classifiers - The trust domain of the live token-exchange-aiac template is read once, when onboarded_stack builds its ctx (ctx["trust_domain"]), and an unreadable one raises instead of falling back to a guess. The unit test of exchanged_subject no longer shells out to kubectl. - outbound_outcome counts AuthBridge's full frame with data: null as a rejection too, not only the frame with no data key. - exchange_trust_domain ignores a config that is not a mapping, and shares the pipeline walk with pipeline_plugins (_leg_plugins). - teardown.sh reads the old route before grepping it, so pipefail cannot hide a match; driver.sh finds the helper through AIAC_DIR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
oblinder
left a comment
There was a problem hiding this comment.
Review of 58cdcd6. These findings come from reading the diff plus the local operator and cortex checkouts; they are not verified on a live cluster, and I could not read the token-exchange-aiac plugin source (feat/token-exchange-aiac is not in my cortex checkout), so the plugin-behaviour points (realm default scope, audience_unknown cache, passthrough on unknown audience) are worth a check against the plugin.
Highest priority: the runbook's grant boundary (realm default-default scope, creation order), no_token_policy: client-credentials, and the tool-less-rung rationale.
Points on lines outside the diff:
demo/use-cases/onboarding/driver.sh:662—probe_tooldoes not tell a token-exchange refusal from an OPA deny. Every 403/503 isDENIED_HTTPand every 200 error frame isDENIED_JSONRPC; the body'spluginis not checked.demo.mdnow says a body naming token-exchange-aiac means the WIRE grant is missing, but the driver does not act on it. If the default-scope link is lost or the exchange fails (403audience_not_granted, disabled target, 503), the test-user -> source-read check (!= ALLOWED_RESULT) still passes as "correctly denied" and the verdict table shows OPA-style denials for all probes, though OPA was never consulted. Suggest classifying onpluginand failing (or a distinct verdict) when it is token-exchange-aiac.test/unit/agent/uc/onboarding/test_uc1_subject_scope.py:607— stale docstring. It saysk8s/opa-kind-enable.shsets "the AuthBridgetoken-exchangeidentity toclient-secret"; this PR removed thatidentity: client-secretblock and the overlay now wires token-exchange-aiac.test/system/test_uc1_onboard_agent_only.py:15and:137— same "short-circuits before OPA" claim as the inline comment onuc1_onboard.py:2005.
| - each target granted through a client scope with an audience mapper for | ||
| it, attached as a **default** scope. The plugin requests no `scope`, so an | ||
| *optional* scope is never applied and the target answers 403. A **realm** | ||
| default scope would grant every target to every caller. |
There was a problem hiding this comment.
The "default scope only, else 403" boundary may depend on client creation order. The operator's EnsureAudienceScope (operator/internal/keycloak/audience.go:82) registers each workload's agent-<ns>-<wl>-aud scope as a realm default-default scope — the case this paragraph says "would grant every target to every caller".
Scenario: deploy github-tool first, then any agent. Keycloak attaches agent-team1-github-tool-aud to the new agent client as a default scope at creation. token-exchange-aiac requests no scope, so it mints a github-tool token for that agent with no WIRE grant and no audience_not_granted 403. So the "not granted -> 403" row holds for agent-then-tool but not for tool-then-agent.
There was a problem hiding this comment.
Confirmed, on the Kind cluster. Keycloak links a realm default-default scope to every client created later: a test scope registered as one, then a new client, had it in its default-client-scopes. So with the tool deployed first, the agent holds the tool's audience with no grant step and is never refused. The tool's own inbound OPA still decides each call.
In 8d83c4c the runbook states this under "Known limitation: creation order": the "not granted -> 403" row holds only for a caller created before the target. The fix belongs in the operator (EnsureAudienceScope makes the scope a realm default) or in AIAC onboarding; #248 lists both options and tracks it.
| ### Outbound token exchange | ||
|
|
||
| `token-exchange-aiac` runs on every outbound request and has no routes and no | ||
| `authproxy-routes` ConfigMap. Adding a target is a Keycloak change only, with no |
There was a problem hiding this comment.
"no restart" conflicts with the plugin's negative cache. token-exchange-aiac caches audience_unknown for 5 minutes, and that is why WIRE (driver.sh) and the harness's restart_agent restart the agent.
Scenario: the agent calls the tool before the tool's client exists, so the audience is cached as unknown. The operator adds the Keycloak grant per this runbook and does not restart. For up to 5 minutes calls pass through un-exchanged and github-tool's jwt-validation rejects them with 401. Suggest documenting the restart (or the cache TTL).
There was a problem hiding this comment.
Fixed in 8d83c4c. The runbook no longer says "no restart" without a condition. It now says that the plugin remembers an audience with no Keycloak client as unknown for 5 minutes (unknown_audience_ttl), so a caller that called the target before the target's client existed keeps passing through un-exchanged (and the target answers 401) for up to 5 minutes. A restart of the caller ends that at once, as WIRE and the harness do. The table row says the same, and so does docs/analysis/token-exchange-vs-token-exchange-aiac.md.
| type: "client-secret" | ||
| audience_template: "spiffe://{{ index .Values "operator-chart" "signatureVerification" "spireTrustDomain" | default "localtest.me" }}/ns/{namespace}/sa/{service}" | ||
| namespace_file: /var/run/secrets/kubernetes.io/serviceaccount/namespace | ||
| no_token_policy: "client-credentials" |
There was a problem hiding this comment.
no_token_policy: client-credentials widens what a bearer-less outbound call can do. Before, a request with no Authorization header went out unauthenticated and the tool's jwt-validation rejected it. Now the sidecar exchanges the workload's own client-credentials token, so any process in the agent pod (app code or a library) that calls http://github-tool:9090/mcp with no header gets an agent-audienced token. Only the tool's rego user gate stands between it and the tool; where a policy keys on client_id only, the call is allowed. Is this intended? If so, it is worth a line in the runbook's security notes.
There was a problem hiding this comment.
Not intended, and nothing used it: every probe of the harness and of the demo sends a bearer, and the agent forwards the inbound one. Fixed in 8d83c4c: the overlay sets no_token_policy: deny (the plugin default, which the route-based pipeline also left in place). A bearer-less request to a target now gets 401 auth.unauthorized; the workload's own token is never sent in its place. A destination Keycloak has no client for still passes through.
The wiring check now also requires deny (or unset), so a cluster still on the old overlay fails opa-kind-verify.sh and the demo preflight, and the system lane skips, with "run k8s/opa-kind-enable.sh". The runbook, the PRD (D31 limits), the IdP Configuration Service spec and both analysis notes say the same. The system lane passed on a cluster re-enabled with deny.
| # when the scope is not linked; SCOPE_ID was just read, so any other answer is a failure. The same | ||
| # move as _link_default_scope in src/aiac/idp/service/configuration/keycloak/main.py; this bash copy | ||
| # (and the harness's grant_exchange_scope) follows it. | ||
| DEL_HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "Authorization: Bearer ${ADMIN}" \ |
There was a problem hiding this comment.
The optional-scope DELETE likely also removes the existing default link on a re-run. Keycloak's removeOptionalClientScope calls the same client.removeClientScope as the default path, so if the scope is already a default scope (second make enforce), this DELETE drops it and the PUT re-adds it. In between, in-flight exchanges get 403 audience_not_granted. The comment ("drop any optional link") also describes the call as optional-only. Option: GET optional-client-scopes first and DELETE only when the scope is listed there. The same applies to _link_default_scope and the harness's grant_exchange_scope.
There was a problem hiding this comment.
Confirmed on the Kind cluster: DELETE .../optional-client-scopes/{id} on a scope linked as default removed the default link. Fixed in 8d83c4c: WIRE reads the optional scopes first (scope_linked ... optional <scope>) and deletes only when the scope is listed there, so a re-run never drops the live grant.
_link_default_scope and the harness's grant_exchange_scope already read the optional list first (get_client_optional_client_scopes), so they were not affected; the comment in driver.sh now says why the check matters.
| # The demo ships no authproxy-routes: only token-exchange-aiac exchanges the agent's call to the tool. | ||
| # On the stock pipeline (token-exchange with routes) ENFORCE would see un-exchanged calls that github-tool | ||
| # rejects, reported as policy failures. Same parsed rule as k8s/opa-kind-verify.sh (Verify 1). | ||
| EXCHANGE=$(kubectl get configmap authbridge-runtime-config -n "$NS" -o jsonpath='{.data.config\.yaml}' 2>/dev/null \ |
There was a problem hiding this comment.
grep -c 'name: opa' on L362 still counts OPA by substring, while the exchange check below now parses the outbound leg. A commented # - name: opa or a duplicate entry on one leg gives 2, so the preflight passes where the harness's per-leg pipeline_unwired_reason skips. The configmap is also fetched twice. Same in k8s/opa-kind-verify.sh Verify 1. Suggest extending outbound-exchange-plugins.py (or a sibling) to report OPA per leg too, and fetching the config once.
There was a problem hiding this comment.
Fixed in 8d83c4c. One parsed check now covers OPA and the exchange: k8s/authbridge-pipeline-check.py (it replaces outbound-exchange-plugins.py) requires opa exactly once on each leg, one token-exchange-aiac and no token-exchange outbound, a spiffe://<td>/...{service}... audience template, and no_token_policy: deny. With --namespace NS it reads the ConfigMap itself, once, with kubectl's stderr kept out of the YAML. The driver's preflight and its ENFORCE re-check call it; so do opa-kind-verify.sh (Verify 1), teardown.sh (--opa-legs) and the restore hints. A commented # - name: opa, or opa twice on one leg, now fails the same way in all of them.
| pass "'name: opa' occurrences in authbridge-runtime-config: got '2' (expected '2')" | ||
| # Outbound token exchange is on by default via token-exchange-aiac (no routes); | ||
| # a leftover token-exchange entry means the pipeline predates the switch. | ||
| PIPELINE_CFG=$(kubectl get configmap authbridge-runtime-config -n "$NS" \ |
There was a problem hiding this comment.
Second fetch of authbridge-runtime-config — the OPA count on L118-119 already fetched it, and still counts with grep -c 'name: opa' (substring, not per leg). See the comment on driver.sh:367.
There was a problem hiding this comment.
Fixed in 8d83c4c, with the driver (see the reply on driver.sh:367). Verify 1 is now one call to k8s/authbridge-pipeline-check.py --namespace "$NS": one read of the ConfigMap, OPA counted per leg (not by substring), the exchange plugin, the trust domain and no_token_policy. Verify 4 uses the trust domain that check returns, unless TRUST_DOMAIN is set.
| pipeline = doc.get("pipeline") | ||
| outbound = pipeline.get("outbound") if isinstance(pipeline, dict) else None | ||
| plugins = outbound.get("plugins") if isinstance(outbound, dict) else None | ||
| names = [p.get("name") for p in plugins or [] if isinstance(p, dict)] |
There was a problem hiding this comment.
This re-implements the YAML walk and count rule of launcher._leg_plugins / exchange_plugin_reason, and a unit test now has to keep the two copies in agreement. A change to one copy only (e.g. also checking inbound, or parsing OPA) makes verify.sh/driver and the system skip gate disagree, caught only for the configs the test lists. Could the harness and this script share one implementation (e.g. the script imports the launcher helper, or the launcher shells out to the script)?
There was a problem hiding this comment.
Fixed in 8d83c4c: there is one implementation. k8s/authbridge-pipeline-check.py (renamed from this file) holds the rule. opa-kind-verify.sh, the demo driver and teardown.sh run it as a script, and the harness's skip gate loads the same file (pipeline_check in test/system/launcher.py) instead of keeping its own YAML walk. So the system gate, verify.sh and the driver cannot disagree. A unit test still runs the script on every config the gate is tested with, now to check the script's exit code and output rather than a second copy.
The same goes for the outbound classifier: k8s/authbridge-rejection.py (standard library only) is loaded by the harness and sent into the agent pod by the demo's probes, so the demo and the harness tell an exchange refusal from an OPA deny by one rule.
| **Outbound signals exist only when a tool is onboarded.** The agent-only rung (rung 1) has no tool | ||
| to gate, so there is no real ``agent -> tool`` call and its outbound leg is neither prepared (Part B | ||
| is skipped) nor probed nor asserted — on a tool-less rung token-exchange short-circuits before OPA | ||
| is skipped) nor probed nor asserted — on a tool-less rung token-exchange-aiac settles the call before OPA |
There was a problem hiding this comment.
Rationale may be wrong for the tool-less rung. With no github-tool client, the derived audience is unknown, so token-exchange-aiac passes the call through (skip / audience_unknown) — it does not settle it before OPA, and OPA is consulted. An outbound "deny" on rung 1 would be an OPA verdict on an un-exchanged request, not a Keycloak refusal. The same claim is in test/system/test_uc1_onboard_agent_only.py (module docstring L15 and L137: "token-exchange short-circuits ... before OPA is ever consulted"). The decision to not probe rung 1 still stands; only the stated reason needs a fix.
There was a problem hiding this comment.
Right, thanks. With no github-tool client the audience is unknown, so token-exchange-aiac passes the call through (audience_unknown) and OPA is consulted. Fixed in 8d83c4c: the reason now says the probe would see the agent's pass-through outbound OPA on a request that reaches no tool, not the tool check. It is corrected in all three places (here, and test_uc1_onboard_agent_only.py at the module docstring and the note at L137) and in docs/testing/uc1-onboarding-pipeline.md. The decision not to probe rung 1 is unchanged.
| "tool_onboarded": tool_onboarded, | ||
| # The trust domain the live outbound leg exchanges for: read once per stack, here, so a | ||
| # probe asks for the audience AuthBridge asks for (``exchanged_subject``). | ||
| "trust_domain": _exchange_trust_domain(), |
There was a problem hiding this comment.
_exchange_trust_domain() runs at ctx build time — after deploy, onboarding, grant_exchange_scope and restart_agent — and on every rung, including agent-only, which never uses it. It is not part of the require_pipeline skip gate. If the live audience_template is not spiffe://<td>/..., require_pipeline passes (it counts plugin names only), the run mutates and restarts the cluster, then fails with RuntimeError instead of a clean skip up front. Suggest reading it in the skip gate.
Also, bundle_service_probe (L953) still builds the SPIFFE ID from the env TRUST_DOMAIN, so the diagnosis can probe a different trust domain from the one the exchange uses.
There was a problem hiding this comment.
Fixed in 8d83c4c. The trust domain is now part of the skip gate: require_pipeline checks the token-exchange-aiac audience template (it must be spiffe://<td>/...{service}...) and returns its trust domain from the same read of the config it approved. A run with a bad template skips before any cluster change, and the stack reads the config once, not twice. _exchange_trust_domain is gone.
bundle_service_probe now uses that trust domain too. AIAC_TRUST_DOMAIN stays only as an explicit override of that probe, the way TRUST_DOMAIN works in opa-kind-verify.sh.
- Overlay: no_token_policy deny (was client-credentials). A bearer-less
call to a target gets 401; the workload's own token is never sent in
its place.
- One wiring rule, k8s/authbridge-pipeline-check.py (replaces
outbound-exchange-plugins.py): opa once per leg, one token-exchange-aiac
and no token-exchange outbound, a spiffe://<td>/...{service}... audience
template, no_token_policy deny. opa-kind-verify.sh, the demo driver and
teardown run it (--namespace reads the ConfigMap once, kubectl stderr
kept apart); the system skip gate loads the same file and returns the
trust domain it approved, so a bad template skips before any change.
- One outbound classifier, k8s/authbridge-rejection.py (stdlib only): the
harness loads it on first use, and the demo's probes send it into the
agent pod. An OPA verdict is policy.forbidden only; OPA with no bundle,
AuthBridge's fallback frame and other plugins are errors; an exchange
refusal or failure is EXCHANGE_REFUSED. ENFORCE passes test-user only
on github-tool's own OPA deny.
- WIRE deletes the optional scope link only when the scope is optional:
Keycloak's DELETE of an optional link also drops a default link.
- Runbook: the 5 min unknown-audience cache and the restart that clears
it; the creation-order limitation of the operator's realm default
audience scopes (#248).
- Docs: the tool-less rung's reason (an un-exchanged pass-through, not a
Keycloak refusal), stale client-secret docstrings, the PRD and spec
no-token sentences, the skip-gate lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
|
Re the three points outside the diff in the review of 58cdcd6, all fixed in 8d83c4c:
Evidence for 8d83c4c:
|
oblinder
left a comment
There was a problem hiding this comment.
Code review (high effort): 8 findings, posted inline. The changed classifier, skip-gate and subject-scope unit tests pass (229 passed). Most important: plugin order not checked (pipeline-check), broken clean-skip path (uc1_onboard imports), and the demo agent tool path regression (deployment.yaml).
🤖 Generated with Claude Code
| an outbound route, so only ``token-exchange-aiac`` exchanges the agent's call to the tool, and a | ||
| ``token-exchange`` entry means the pipeline predates the switch.""" | ||
| names = plugin_names(legs) | ||
| opa = {leg: names[leg].count("opa") for leg in LEGS} |
There was a problem hiding this comment.
Wiring check does not check plugin order. The check counts plugins on each leg but never checks their order. A pipeline with opa before token-exchange-aiac on the outbound leg (or before jwt-validation on the inbound leg) passes.
Failure scenario: a values override lists outbound plugins as [opa, token-exchange-aiac]. check() returns ok trust_domain=..., so verify.sh Verify 1, the demo preflight and the require_pipeline skip gate all pass. OPA then runs with no input.delegation or outbound input.identity — the opposite of what the overlay comment requires ("opa runs AFTER token-exchange-aiac").
There was a problem hiding this comment.
Fixed in b43bd33. The check now also checks the order on each leg: opa must run after the plugins that set what AIAC's Rego reads there. Inbound that is jwt-validation (input.identity) and mcp-parser (input.mcp, read by a tool's inbound package); outbound, token-exchange-aiac (input.identity, input.delegation). A leg without one of them is reported as missing, not as an order problem. The other parsers, and mcp-parser on the outbound leg, may run anywhere: AIAC's outbound package is a pass-through (D24), and no AIAC policy reads input.a2a or input.inference. I checked which inputs the generated Rego reads (src/aiac/pdp/service/policy/opa/rego.py) to keep the rule to those. The check also accepts the bare-string entry form (- opa), which cortex's config.PluginEntry reads as {name: opa}.
| require_event_path, | ||
| require_pipeline, | ||
| resolve_pod, | ||
| target_disabled_refusal, # noqa: F401 — re-exported: the rollback test proves the quarantine with it |
There was a problem hiding this comment.
Promised clean skip for a missing/broken shared file never happens. uc1_onboard imports is_opa_violation, outbound_outcome and target_disabled_refusal from launcher at import time. That goes through launcher's module __getattr__, which loads k8s/authbridge-rejection.py during import.
Failure scenario: k8s/authbridge-rejection.py is missing or has a syntax error. Every system test module that imports uc1_onboard fails at collection with ImportError, so the marker-selected lane errors out. pipeline_check() was written to turn this into a skip reason, but never gets to run.
There was a problem hiding this comment.
Reproduced in the second review: with k8s/authbridge-rejection.py deleted (in a worktree), import test.system.uc1_onboard fails with ImportError: .../k8s/authbridge-rejection.py is missing. It does not skip.
This also breaks the offline unit lane, not only the system lane: unit tests that import uc1_onboard (for example test_uc1_subject_scope and test_uc1_staged_gates) fail at collection.
There was a problem hiding this comment.
Right: names bound through the module __getattr__ loaded the file at import time. Fixed in b43bd33: the classifiers in launcher.py are now plain wrapper functions that load k8s/authbridge-rejection.py on their first call, so from test.system.launcher import outbound_outcome (as uc1_onboard does) loads nothing. pipeline_check loads the file in the skip gate, so a missing or broken file skips the live suite up front with the file named. A file that does not load (a syntax error) is now an ImportError naming it, so the gate catches that too. Unit tests import the launcher from a copy of test/ with no k8s/, and check the missing-file and broken-file skips.
There was a problem hiding this comment.
Thanks for reproducing it. I checked again at 0d0d46e, in a worktree with k8s/authbridge-rejection.py deleted:
import test.system.uc1_onboardnow succeeds. The classifiers are wrapper functions that load the file on their first call, so importing them needs nok8s/.- Two unit tests still fail there, both in
test_uc1_staged_gates.py(test_an_opa_403_points_at_the_crandtest_a_403_that_is_not_from_opa_says_so), because they call the classifier, and the classifier is that file.test_uc1_subject_scope.pypasses.
I'm keeping that as intended. A unit test of the classifier needs the file it tests, the same way the existing test/unit/k8s/ tests on main need the manifests they read. In a real checkout a missing file is a packaging bug, and a failing test names it; a skip would hide it. What must skip cleanly is the live suite, and it does: pipeline_check loads both shared files in the skip gate, so a missing or broken one is a skip reason, with the file named.
| # AuthBridge exchanges the token for the audience derived from this | ||
| # host — see the token-exchange note in the header above. | ||
| - name: MCP_URL | ||
| value: "http://github-tool-mcp:9090/mcp" |
There was a problem hiding this comment.
Demo agent's tool calls are no longer exchanged. This PR deletes the authproxy-routes route for github-tool-mcp but leaves MCP_URL at http://github-tool-mcp:9090/mcp.
Failure scenario: on a cluster wired with opa-kind-enable.sh, the derived audience spiffe://<td>/ns/team1/sa/github-tool-mcp matches no client, so the plugin forwards the user's login token unchanged and the tool's inbound jwt-validation rejects every call with 401. The old route exchanged this call for the github-tool audience with github-full-access. The PR body lists this as known limitation #247, but it is a working path this PR breaks.
There was a problem hiding this comment.
Agreed it is a working path this PR breaks. The fix needs changes on both sides, so I split it:
- cortex (github-issue demo: make the production github-tool-mcp reachable by token-exchange-aiac cortex#1540): the production tool's demo manifest gives the tool a ServiceAccount
github-tool-mcp, equal to its Service, and enrolls it, so the operator registersspiffe://<td>/ns/team1/sa/github-tool-mcp. Today it runs under thedefaultServiceAccount with a plain clientgithub-toolthatsetup_keycloak.pycreates by hand, and it has no sidecar. The namegithub-toolcannot be reused: AIAC's UC-1 stub is Service = ServiceAccount =github-toolinteam1. - AIAC (b43bd33):
MCP_URLstays atgithub-tool-mcp, which already derives that audience. The onboarding demo's WIRE phase linksagent-<ns>-github-tool-mcp-audas a default scope ofgithub-agentwhen the scope exists, best effort, and skips it otherwise.restore.shremoves the link. The deployment comment, the agent README and spec, INSTALL.md and the runbook describe this wiring.
github-full-access is deliberately not granted. token-exchange-aiac requests no scope, so the only way to get it into a token is a default scope of github-agent, and then every token the agent exchanges carries it, whoever the user is: every user would get the privileged PAT. So the tool uses its public PAT for now. A per-user grant is a design question for the cortex demo, recorded in #1540.
The UC-1 demo and the system lane are not affected: they call the stub github-tool. Both pass at b43bd33 (see the top-level comment).
| # the message tells them apart (cortex ``core/plugins/tokenexchangeaiac/plugin.go``, the ``deny`` of | ||
| # ``outcomeTargetDisabled``). The response carries no reason code, so a reworded message there must be | ||
| # copied here; the rollback test then fails as ``error``, never passes wrongly. | ||
| TARGET_DISABLED_MESSAGE = "the destination's client is disabled" |
There was a problem hiding this comment.
Exact match on a human-readable cortex message. target_disabled_refusal tells "target disabled" from "not granted" only by matching the cortex message text. A copy of that string is the only link between the two repos.
Failure scenario: cortex rewords "the destination's client is disabled". _outbound_block then sees error, not refused, on every post-quarantine probe, and test_failed_tool_outbound_call_denied fails although the quarantine works. Suggest cortex emit a machine-readable reason (e.g. details.outcome=target_disabled) and key on that.
There was a problem hiding this comment.
Agreed it is fragile, and a machine-readable reason would be better. token-exchange-aiac's deny() sets no Details today, so there is nothing else to key on; I opened rossoctl/cortex#1570 to emit the reason (target_disabled / audience_not_granted) in the rejection's details. Until then the match fails closed: a reworded message makes target_disabled_refusal false, so test_failed_tool_outbound_call_denied fails as error. It cannot pass wrongly. The comment above TARGET_DISABLED_MESSAGE says so, and points at the cortex line that sets the message.
| require_pipeline(namespace=NAMESPACE, workloads=[]) | ||
| # poll below — never a skip, never a false pass. The gate also gives the trust domain the live | ||
| # outbound leg exchanges for, from the same read of the config it approved. | ||
| trust_domain = require_pipeline(namespace=NAMESPACE, workloads=[]) |
There was a problem hiding this comment.
pristine_stack and onboarded_stack now yield different ctx shapes. Here onboarded_stack keeps the trust domain from require_pipeline and puts it in ctx["trust_domain"]. pristine_stack (line 3236) calls require_pipeline(...) and drops the result, so its ctx has no trust_domain key.
Failure scenario: a rung-5 or failure-path test on pristine_stack calls uc1.exchanged_subject(ctx, user) or builds a diagnosis from its ctx and gets KeyError: 'trust_domain'.
There was a problem hiding this comment.
Fixed in b43bd33. pristine_stack now keeps the trust domain require_pipeline returns and yields it as ctx["trust_domain"], so both stacks yield the same keys for what needs no deploy.
| ``legs`` (``leg_plugins``), or ``None`` when there is none (no such plugin, or a template that is | ||
| not ``spiffe://<td>/...``). **Pure.**""" | ||
| template = _exchange_config(legs).get("audience_template") | ||
| if not isinstance(template, str) or not template.startswith("spiffe://"): |
There was a problem hiding this comment.
{namespace} not required in the template path. trust_domain() requires {service} but not {namespace}, so spiffe://td/sa/{service} or a hard-coded namespace passes.
Failure scenario: a shared values file pins audience_template: spiffe://localtest.me/ns/team1/sa/{service}. The check passes, and every non-team1 agent namespace derives a team1 audience — exchanges are refused or sent unexchanged, while verify.sh and the skip gate report the cluster as correctly wired.
There was a problem hiding this comment.
Fixed in b43bd33. The template must now be spiffe://<td>/...{namespace}...{service}...: {namespace} and {service} both in the path, {namespace} first (the operator names a workload .../ns/<ns>/sa/<sa>). A pinned namespace, a missing {namespace}, or {service} before {namespace} fails the check, and so verify.sh, the demo preflight and the skip gate.
| # The SPIFFE trust domain of the bundle-service probe of the diagnosis, when it differs from the one the | ||
| # exchange uses (``AIAC_TRUST_DOMAIN``, as ``TRUST_DOMAIN`` of ``k8s/opa-kind-verify.sh``). Unset, the | ||
| # probe uses the trust domain of the live outbound leg (``require_pipeline``). | ||
| BUNDLE_TRUST_DOMAIN = os.environ.get("AIAC_TRUST_DOMAIN") or None |
There was a problem hiding this comment.
AIAC_TRUST_DOMAIN silently changed meaning. It used to set the exchange audience; now it only changes the bundle-service probe in the diagnosis dump, with no warning when it disagrees with the live template.
Failure scenario: an environment still exports AIAC_TRUST_DOMAIN=example.org for a cluster whose template says localtest.me. The exchange uses localtest.me (correct), but the diagnosis probes /bundles?spiffe=example.org/..., giving a misleading 404/403 in every gate-failure message.
There was a problem hiding this comment.
Fixed in b43bd33. The gate-failure dump names the trust domain the bundle probe asked for, and when AIAC_TRUST_DOMAIN differs from the live token-exchange-aiac trust domain it says so: "(AIAC_TRUST_DOMAIN=example.org differs from the live token-exchange-aiac trust domain localtest.me)". So a 404 from the probe is not taken for the cluster's. The env table in docs/testing/ says the variable changes only that probe.
| # default link every exchange is refused much later, far from this cause. | ||
| try: | ||
| admin.add_client_optional_client_scope(client_uuid, scope["id"], {}) | ||
| if any(s.get("id") == scope["id"] for s in admin.get_client_optional_client_scopes(client_uuid)): |
There was a problem hiding this comment.
Third copy of the "optional → default" scope rule. grant_exchange_scope copies _link_default_scope (src/aiac/idp/service/configuration/keycloak/main.py:333-335) line for line, and driver.sh phase WIRE (line 590) has a bash copy.
If the IdP service rule changes, the harness and the demo silently diverge from production. A small shared helper with no FastAPI import (or a lazy import) would let the harness call the real function.
There was a problem hiding this comment.
I kept the copies, on purpose, and made each say what it mirrors:
- The harness can't call the real function.
_link_default_scopelives in the IdP Configuration Service'smain.py, which configures the root logger and builds its FastAPI app when it is imported. Importing it changed logging for the whole pytest session, which is why 94f237c went back to the copy. Moving the helper into its own module would change the service image: its Dockerfile copies onlymain.pyandrequirements.txt. driver.shis bash. It is now one function,grant_default_scope, used for every default link the demo makes.- The Keycloak quirk is documented next to each copy. A DELETE of an optional link also drops a default link, which I checked on the Kind cluster. All three copies read the optional list first and delete only when the scope is there.
If you would rather have one source, the next step is a small keycloak_scopes.py next to main.py that the image also copies, imported by the service and the harness. I'd do that as a follow-up, since it touches the service build.
oblinder
left a comment
There was a problem hiding this comment.
Second code review (high effort): 5 new findings, posted inline. Four other findings repeat the first review and are not posted again; for one of them (import-time load of k8s/authbridge-rejection.py), the reviewer reproduced the failure, see the reply in that thread. The 229 unit tests that touch the changed code pass.
🤖 Generated with Claude Code
| # cannot be told from a platform-seeded one: report it, never delete it. | ||
| # Read into a variable first: under pipefail, `kubectl | grep -q` can fail on a match (SIGPIPE). | ||
| OLD_ROUTES=$(kubectl get configmap authproxy-routes -n "$NS" -o jsonpath='{.data.routes\.yaml}' 2>/dev/null || true) | ||
| if grep -q 'host: "github-tool-mcp"' <<<"$OLD_ROUTES"; then |
There was a problem hiding this comment.
Leftover-route check misses the old route's host. The check for a stale authproxy-routes matches only host: "github-tool-mcp". But the old WIRE phase replaced routes.yaml with a host: "github-tool" route, and the old harness ensure_github_tool_route appended the same route.
Failure scenario: a cluster where the old make enforce (WIRE) or the old system lane ran has authproxy-routes with only - host: "github-tool". teardown.sh finds no match and gives no warning, so the stale route stays in team1 and the operator still mounts it in every pod.
There was a problem hiding this comment.
Right, the old WIRE phase and ensure_github_tool_route wrote a github-tool route, which the check missed. Fixed locally, in the next commit (not pushed yet): teardown.sh now reports a leftover route for either host, github-tool-mcp or github-tool, quoted or not. As before, it reports and never deletes: the route carries no owner label, so it cannot be told from a platform-seeded one. I'll reply here with the commit once it is pushed.
There was a problem hiding this comment.
Pushed in f2b6d0f. teardown.sh now reports a leftover authproxy-routes route for either host, github-tool-mcp or github-tool: quoted or not, with or without a trailing comment, matching only the host key (not, say, upstream_host). It still reports and never deletes.
Evidence at f2b6d0f: unit lane 2141 passed; system lane 87 passed, 14 skipped (the test/system/disabled/ modules); the onboarding demo (make e2e, ./driver.sh --only-enforce, make restore) passes, and restore leaves team1 empty.
|
|
||
| step "Preflight — kubectl and cluster" | ||
| command -v kubectl >/dev/null 2>&1 || die "kubectl not found on PATH" | ||
| python3 -c 'import yaml' >/dev/null 2>&1 || die "python3 with PyYAML not found on PATH (Verify 1 parses the pipeline)" |
There was a problem hiding this comment.
enable.sh now fails late on a host with no PyYAML. This PR adds the python3 + PyYAML requirement here. opa-kind-enable.sh runs this script as Step 6, but its header (line 34) still says "python3 not needed here", and it has no matching preflight.
Failure scenario: on a host with no PyYAML, enable.sh builds and loads both images, runs the helm upgrade, applies the combiner and restarts every agent and tool pod. Only then does Step 6 die with "python3 with PyYAML not found". The script exits non-zero after it has already changed the cluster. Suggest the same preflight at the top of enable.sh and an updated header.
There was a problem hiding this comment.
Fixed in 0d0d46e: opa-kind-enable.sh checks for python3 with PyYAML at the top, before the image builds and the helm upgrade, and its header now lists the requirement instead of "python3 not needed here".
| if not isinstance(doc, dict) or doc.get("error") != "policy.forbidden": | ||
| return False | ||
| plugin = doc.get("plugin") | ||
| return plugin is None or plugin == "opa" |
There was a problem hiding this comment.
A policy.forbidden body with no plugin name is counted as an OPA verdict. The cortex pipeline always fills in Violation.PluginName (pipeline.go:340), so a body that names no plugin cannot be an AuthBridge OPA rejection.
Failure scenario: a 403 {"error":"policy.forbidden","message":...} from a component that is not AuthBridge (the tool app, another proxy or middleware) is classified as deny / DENIED_HTTP 403. A deny expectation then passes in the harness and in the demo ENFORCE phase although OPA did not decide.
There was a problem hiding this comment.
Agreed. Fixed in 0d0d46e: is_opa_violation now requires policy.forbidden and plugin == "opa", so a body with no plugin name is not an OPA verdict (the same point as the later comment on this file; details there). I also checked the live cluster: every OPA deny it returns names "plugin":"opa".
| admin.add_client_optional_client_scope(client_uuid, scope["id"], {}) | ||
| if any(s.get("id") == scope["id"] for s in admin.get_client_optional_client_scopes(client_uuid)): | ||
| admin.delete_client_optional_client_scope(client_uuid, scope["id"]) | ||
| admin.add_client_default_client_scope(client_uuid, scope["id"], {}) |
There was a problem hiding this comment.
The harness and the demo driver handle HTTP 409 differently. grant_exchange_scope raises RuntimeError on any KeycloakError. The copy of the same grant in driver.sh treats 409 "already assigned" as idempotent and continues.
Failure scenario: Keycloak answers 409 to the default-scope PUT (for example, the realm-default scope already linked it in the tool-then-agent order). The harness stops the rung with RuntimeError, but driver.sh only warns and continues. Either the harness gives a false failure, or the 409 branch in the driver is dead code. (Related to the three-copies comment at line 1234.)
There was a problem hiding this comment.
The harness was wrong. Fixed in 0d0d46e: grant_exchange_scope takes a 409 as an existing default link, as driver.sh does, and any other Keycloak error still raises. A unit test covers both (same point as the later comment on line 1262).
| client_secret: str, | ||
| audience: str, | ||
| scope: str, | ||
| scope: str | None = None, |
There was a problem hiding this comment.
Unused scope parameter. exchange_token keeps a new optional scope parameter that no production caller uses. Its only caller (exchanged_subject) now sends no scope, and the only user of the parameter is the new unit test test_exchange_token_sends_an_explicit_scope.
This adds code and a test to maintain, and it makes a scoped exchange look like a supported path, but the plugin under test never sends one. Suggest removing the parameter and its test.
There was a problem hiding this comment.
Removed in 0d0d46e: the scope parameter, its branch, and test_exchange_token_sends_an_explicit_scope.
- Wiring check: opa must run after the plugins that set what AIAC's Rego
reads (inbound jwt-validation and mcp-parser, outbound
token-exchange-aiac); the audience template must be
spiffe://<td>/...{namespace}...{service}...; bare-string plugin entries
are read as cortex reads them.
- Harness: the outbound classifiers are plain wrappers that load
k8s/authbridge-rejection.py on first call, so importing them needs no
k8s/; the skip gate loads both shared files, and a missing or broken one
skips the live suite. pristine_stack yields the trust domain too.
Teardown deletes only the demo workloads' own audience scopes and
credentials Secrets, so another workload's (github-tool-mcp's) survive.
The gate-failure dump says when AIAC_TRUST_DOMAIN differs from the live
trust domain.
- Demo: WIRE links github-tool-mcp's audience scope as a default scope of
github-agent when it exists (best effort), and never github-full-access,
which as a default scope would give every user the privileged PAT;
restore.sh removes the link and survives a failed Keycloak call. The
production tool still needs a ServiceAccount github-tool-mcp and an
enrollment on the cortex side (rossoctl/cortex#1540).
- Demo: ENFORCE waits one bundle cycle (BUNDLE_WAIT_SECS, default 180) for
dev-user's allow before the per-tool matrix, so a bundle that lands after
the restart is waited out instead of failing the run. The one-shot check
raced the OPA bundle poll on main too.
- Docs: the github-tool-mcp wiring (agent manifest, README, spec,
INSTALL.md, runbook, analysis note, PRD), and cortex main at ef5ac5f8
replacing the feat/token-exchange-aiac branch note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
|
Replies to the review of 8d83c4c are inline; all 8 are addressed in b43bd33. Two cortex issues came out of it:
One more fix that the demo run turned up: ENFORCE checked dev-user's allow once, right after WIRE restarts the agent. github-tool's and the agent's OPAs poll bundle-service every 10-120 s, so a bundle could land after that check and fail the run. The same check is on Evidence:
|
oblinder
left a comment
There was a problem hiding this comment.
Automated code review (/code-review): 10 findings, none a crash on the main path. Nine are inline. One is here, because its line is outside the diff:
test/system/uc1_onboard.py:1294 — the optional-to-default link move is now in three places: _link_default_scope in src/aiac/idp/service/configuration/keycloak/main.py, a copy in grant_exchange_scope, and a bash copy (grant_default_scope in driver.sh). A future fix to the move must go into all three, and they already differ on 409 handling (see the inline comment on line 1262). Moving _link_default_scope into a side-effect-free module would remove the "importing configures the root logger" reason for the copy.
Note: no verifier pass ran, and the cortex token-exchange-aiac plugin source was not checked.
🤖 Generated with Claude Code
| # importing that module configures the root logger and builds its FastAPI app in the test process. | ||
| # Every Keycloak error raises: the assign is an idempotent PUT, so none is benign, and without the | ||
| # default link every exchange is refused much later, far from this cause. | ||
| try: |
There was a problem hiding this comment.
Harness and demo driver disagree on Keycloak 409. grant_exchange_scope now raises RuntimeError on every KeycloakError, including a 409 on the default-scope PUT. The driver's copy of the same move (grant_default_scope in driver.sh) treats HTTP 409 as an idempotent success.
Failure scenario: on a Keycloak version that answers 409 for a scope that is already linked as default, a re-run of the system lane (or phase 3 of the rollback test after the tool is already linked) raises and aborts the whole fixture. Before this PR the harness logged it as benign. Either the harness is wrong, or the driver's 409 branch is dead code.
There was a problem hiding this comment.
The harness was wrong. Fixed in 0d0d46e: grant_exchange_scope takes a 409 on the default-scope PUT as an existing link and returns, as driver.sh's grant_default_scope does. A 409 is a likely answer here: the operator's realm default scope can link the tool's audience when the agent's client is created (#248). Any other Keycloak error still raises. A unit test covers both.
| ``core/plugins/opa``), which decides nothing.""" | ||
| if not isinstance(doc, dict) or doc.get("error") != "policy.forbidden": | ||
| return False | ||
| plugin = doc.get("plugin") |
There was a problem hiding this comment.
A rejection with no plugin name is counted as an OPA verdict. cortex's pipeline always stamps PluginName on a reject (pipeline.stampPluginName), so a policy.forbidden with no plugin never comes from AuthBridge's OPA.
Failure scenario: a plain HTTP 403 JSON body {"error":"policy.forbidden",...} from a non-AuthBridge hop (a gateway, or the tool's own handler), or a tool JSON-RPC error frame with data: {"error":"policy.forbidden"}, is classified deny / DENIED_HTTP 403. ENFORCE then passes on a deny that did not come from github-tool's OPA, and deny_origin reports TOOL_INBOUND.
There was a problem hiding this comment.
Agreed, checked in cortex: pipeline.stampPluginName names the plugin on every rejection. Fixed in 0d0d46e: is_opa_violation now requires policy.forbidden and plugin == "opa". A plain 403 {"error":"policy.forbidden"} with no plugin, from a gateway or the tool's own handler, is no longer deny and no longer DENIED_HTTP 403; ENFORCE fails on it.
The JSON-RPC case had a second hole: _outcome checked for an OPA verdict before it checked that the frame was AuthBridge's at all. A tool's own error frame (its own code, data: {"error":"policy.forbidden"}) read as an OPA deny. It now first asks whether the frame is AuthBridge's (code -32000, or data naming a plugin); anything else is the tool's own error, and the call was let through. deny_origin now follows outbound_outcome, so it returns nothing for that frame either. Unit tests cover both.
|
|
||
|
|
||
| def target_disabled_refusal(code: int | None, body: str) -> bool: | ||
| """True when the outbound answer ``(code, body)`` is the exchange plugin's refusal **because the |
There was a problem hiding this comment.
Quarantine refusal is found by an exact match on human-readable prose. target_disabled_refusal matches "the destination's client is disabled".
Failure scenario: a small wording change in cortex (punctuation, capitals, added context) makes test_failed_tool_outbound_call_denied classify the real quarantine refusal as error, and the rollback test fails. The real fix is a machine-readable reason code (cortex#1570); until then, consider at least a TODO that links it.
There was a problem hiding this comment.
Agreed. The machine-readable reason is rossoctl/cortex#1570, opened from the previous round. The comment above TARGET_DISABLED_MESSAGE now links it. Until then the match fails closed: a reworded message makes the rollback test fail as error, it never passes wrongly.
| DEV_SOURCE_READ="$(probe_tool dev-user source-read)" | ||
| [ "$DEV_SOURCE_READ" = "ALLOWED_RESULT" ] && break | ||
| case "$DEV_SOURCE_READ" in | ||
| "DENIED_HTTP 403" | DENIED_JSONRPC | "UNEXPECTED 503"* | "UNEXPECTED 200"*upstream.unreachable* \ |
There was a problem hiding this comment.
Bundle-wait retry matches on a truncated body. The loop greps upstream.unreachable in the verdict text, which carries only body[:200], not the parsed error code.
Failure scenario: Go marshals map keys sorted, so description/details come before error. With a long description, upstream.unreachable falls after character 200, the match fails, and the loop breaks at once, so the demo dies on a transient state it should wait out. verdict() could emit the parsed error code instead.
There was a problem hiding this comment.
Right, Go marshals the keys sorted. Fixed in 0d0d46e: verdict() now writes the parsed error code right after the status, UNEXPECTED <code> <error> <body[:200]> (- when there is none). The bundle-wait loop matches that field ("UNEXPECTED 200 upstream.unreachable "*), so a long description cannot push it out. A unit test builds a frame with a 300-character description.
| if ref.startswith("secret/rossoctl-keycloak-client-credentials-"): | ||
| _kubectl_try("delete", ref, "-n", NAMESPACE, "--ignore-not-found") | ||
| # The operator mints one ``rossoctl-keycloak-client-credentials-<hash>`` Secret per client, named | ||
| # from (namespace, workload). Tolerant — an already-absent Secret is not a teardown error. |
There was a problem hiding this comment.
Teardown no longer sweeps all client-credentials Secrets. It now deletes only the two names recomputed from scn.AGENT_WORKLOAD / scn.TOOL_WORKLOAD with a copied operator hash.
Failure scenario: the agent runs under an AIAC_AGENT_DEPLOYMENT that is not scn.AGENT_WORKLOAD, or the operator changes the hash/key format of KeycloakClientCredentialsSecretName. The real Secret is not deleted (--ignore-not-found hides it), and the full-to-pristine teardown leaves stale credentials for the next run.
There was a problem hiding this comment.
Partly changed in 0d0d46e. The teardown still deletes by exact name, on purpose: the namespace also holds other workloads' credentials, for example the production github-tool-mcp's, which a prefix sweep would delete. Two changes cover the cases you name:
- The name now comes from one place,
k8s/operator-names.py, which both the harness andrestore.shuse. A unit test pins it against the operator's Go formula. - After the delete, the teardown lists the namespace's credentials Secrets and logs a warning for any whose owner reference (the operator sets the workload's Deployment as owner) is one of the two workloads. A changed hash, or a Secret left under another name, is reported instead of passing in silence.
AIAC_AGENT_DEPLOYMENT names only the Deployment restart_agent rolls. The client and its Secret are named from scn.AGENT_WORKLOAD, which that Deployment's manifest also uses.
| client_secret: str, | ||
| audience: str, | ||
| scope: str, | ||
| scope: str | None = None, |
There was a problem hiding this comment.
scope parameter is now unused in production. After this change no production caller passes scope to exchange_token; only a unit test (_exchange(scope="openid extra")) uses it. Consider removing the parameter, its branch, and that test case.
There was a problem hiding this comment.
Removed in 0d0d46e: the scope parameter, its branch and test_exchange_token_sends_an_explicit_scope. exchange_token now always sends what token-exchange-aiac sends: audience only, no scope.
| return f"spiffe://{trust_domain}/ns/{NAMESPACE}/sa/{scn.TOOL_WORKLOAD}" | ||
|
|
||
|
|
||
| def _tool_aud_scope() -> str: |
There was a problem hiding this comment.
Redundant alias. _tool_aud_scope() is now only workload_aud_scope(scn.TOOL_WORKLOAD). Call that directly at the single call site in grant_exchange_scope and delete _tool_aud_scope.
There was a problem hiding this comment.
Done in 0d0d46e: _tool_aud_scope is gone, and grant_exchange_scope calls workload_aud_scope(scn.TOOL_WORKLOAD).
| return f"agent-{NAMESPACE}-{workload}-aud" | ||
|
|
||
|
|
||
| def client_credentials_secret_name(workload: str) -> str: |
There was a problem hiding this comment.
Second copy of the operator Secret-name hash. client_credentials_secret_name copies the formula (sha256 of ns\0workload\0..., first 16 hex chars) that demo/use-cases/onboarding/restore.sh already has inline. Both must change together when rossoctl/operator changes. A shared helper under k8s/ (as this PR does for the classifier) would keep one copy.
There was a problem hiding this comment.
Done in 0d0d46e: k8s/operator-names.py (standard library only) holds the operator's names, the credentials Secret and the audience scope. restore.sh runs it and the harness loads it, the same way as the other two shared k8s/ files. A unit test checks it against the names the operator's Go code gives.
| if str(REPO_ROOT) not in sys.path: # so ``import test.*`` resolves | ||
| sys.path.insert(0, str(REPO_ROOT)) | ||
|
|
||
| from test.unit.test_launcher_outcome import _CONFIG, _PASSING, _SKIPPING, _reason # noqa: E402 |
There was a problem hiding this comment.
Test module imports private names from another test module. It imports _CONFIG, _PASSING, _SKIPPING, _reason from test.unit.test_launcher_outcome and edits sys.path to do so. A rename in that file breaks collection here, and its module-level setup runs twice. The shared configs belong in a conftest or a small fixtures module.
There was a problem hiding this comment.
Fixed in 0d0d46e. The shared configs moved into test/unit/pipeline_configs.py, a plain module with no tests. Both test files import it by its public names, so neither reaches into the other's private names or runs its setup.
- Classifier: an OPA verdict needs policy.forbidden and plugin opa (cortex names the plugin on every rejection), so a nameless 403 from another hop is no deny. A JSON-RPC frame that is not AuthBridge's (not its -32000 code, no plugin named) is the tool's own error: an allow, never a deny, and deny_origin follows outbound_outcome. verdict() puts the parsed error code after the status, so a long body cannot hide it from the bundle wait. - Harness: grant_exchange_scope takes a 409 as an existing default link, as the demo driver does (the operator's realm default can link it, #248). exchange_token drops its unused scope parameter. The operator's names (credentials Secret, audience scope) come from k8s/operator-names.py, which restore.sh also runs; teardown warns when a workload's Secret survives the delete. - Demo: the bundle wait also retries a 401 (an audience cached as unknown, for ENFORCE run alone). opa-kind-enable.sh checks for PyYAML before it changes the cluster. - Tests: the shared pipeline configs live in test/unit/pipeline_configs.py, so no test imports another's private names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
|
Replies to the review of b43bd33 are inline; all are addressed in 0d0d46e. On the point outside the diff (the optional-to-default move in three places): the harness copy now agrees with the driver on 409, which was the drift you found. The reason it cannot import Evidence at 0d0d46e:
|
- Wiring check: bare-string plugin entries (`- opa`) are read as cortex
reads them. The overlay's trust-domain lookup uses get, not index, so a
values set without operator-chart.signatureVerification falls back to
localtest.me instead of failing the render.
- Classifier: verdict() parses once, and its <error> field is always one
word ("-" when the error is not a string). The analysis note no longer
says a nameless policy.forbidden is an OPA verdict.
- Demo: --only-enforce restarts github-agent itself (as WIRE does), after
its read-only checks, instead of waiting out the 5-min unknown-audience
cache; a 401 is no longer retried. The audience scope names come from
k8s/operator-names.py, named once with a loud failure, in driver.sh,
restore.sh and the demo's Python. teardown.sh reports a leftover route
for github-tool too, matching only the host key.
- Docs: cortex#1540 and #1570 are noted as closed by cortex as deferred.
- Tests: the shared configs module gives the one skip-reason helper both
test files use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
- Demo: ENFORCE skips its own agent restart when the agent restarted in the last 2 minutes, so `make enforce` (WIRE, then ENFORCE, as two runs) restarts it once, not twice. restore.sh no longer aborts when k8s/operator-names.py fails: it warns, skips the audience scopes, and still removes the clients, CRs, workloads and Secrets; step 3 names each component's scope from the component. The demo's loader for operator-names.py is cached and names a missing file. - Decisions, each in the spec for its feature (docs/agents/domain.md): PRD D36 (no routes, default client scopes, no_token_policy deny, Service = ServiceAccount, no default github-full-access, the realm default widening, and the rejected options); five Testing Decisions in docs/testing/uc1-onboarding-pipeline.md (what an OPA verdict is, one copy of each shared k8s/ rule, the wiring order, exact-name teardown, the harness's own link-move copy); and "How ENFORCE waits, and why" in demo.md. - Tests: one import block; the shared-configs docstring says it holds a helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Decisions in this PR, and where they are recordedEvery design decision behind this PR now has a written reason in the repo, in the spec for its feature (the repo's convention, Routing and grants: PRD D36 (new, under Key architectural decisions)
Test harness: uc1-onboarding-pipeline.md, Testing Decisions
Demo: demo.md, Step 5 How ENFORCE waits, and why
This commit (db37855)A delta review of
Not changed: removing the Evidence at db37855:
|
oblinder
left a comment
There was a problem hiding this comment.
Automated code review (Claude Code). 9 findings, checked against head db37855, most severe first. Eight are inline comments. The most severe finding is on a line outside the diff, so it is here:
1. Correctness: ensure_default_audience_scope does not promote an existing OPTIONAL link (demo/use-cases/onboarding/lib/setup_keycloak.py:75)
ensure_default_audience_scope is now called with aud_scope_name from 05-onboard-tool.py and run(). It never moves an existing OPTIONAL link to default before add_client_default_client_scope. The _link_default_scope move that this PR copies into driver.sh and grant_exchange_scope does this, but this function does not. Keycloak skips the default link without an error, so the function reports "added" but no default grant exists.
Failure scenario: a cluster where an older driver WIRE linked agent-team1-github-tool-aud as an optional scope. 05-onboard-tool.py logs "added ... as a default scope", but the scope is still optional only. token-exchange-aiac requests no scope, so every agent->github-tool call is refused with 403 policy.forbidden (plugin token-exchange-aiac), while the demo onboarding reports success.
No doc in the diff gives pytest a path, and I found no clear repo CLAUDE.md violation.
🤖 Generated with Claude Code
| return None | ||
|
|
||
|
|
||
| def is_opa_violation(doc: object) -> bool: |
There was a problem hiding this comment.
Correctness: an OPA evaluation error is classified as a deny. is_opa_violation treats every OPA policy.forbidden as a policy verdict. But cortex's OPA plugin also returns Deny("policy.forbidden", "OPA decision error: ...") on an evaluation error (core/plugins/opa/plugin.go:449/501). So a Rego runtime or decision error is classified as deny / DENIED_HTTP 403.
Failure scenario: a generated bundle whose Rego fails at evaluation. github-tool's inbound answers 403 {error: policy.forbidden, plugin: opa, message: 'OPA decision error: ...'}. outbound_outcome returns deny, and the driver's test-user check says "correctly denied by github-tool's inbound". Deny expectations false-pass. The PR says the classifier now prevents this ("an OPA deny is a policy.forbidden verdict only").
There was a problem hiding this comment.
Fixed in e6188c9, on both legs.
Root cause. cortex's OPA plugin answers an evaluation error with policy.forbidden too: "OPA decision error: ..." and "OPA response decision error: ..." (cortex core/plugins/opa/plugin.go:449, :501). Only the message tells it from a verdict. A plain 403 has the message at the top of the body; a frame at HTTP 200 has it in error.message, outside error.data.
Change
k8s/authbridge-rejection.py:70:is_opa_violation(doc, message)now requires the message. A default could not read it from a frame'sdata, so leaving it out would bring the bug back.k8s/authbridge-rejection.py:86:opa_verdict(code, body)andopa_decision_error(code, body)read the message from the right place in both shapes.outbound_outcome,verdict,deny_origin,notification_outcomeandprobe_hintall go through them.- Inbound. My self-review found the same bug on the inbound leg; it was not in your comment.
inbound_outcome(test/system/launcher.py:699) counted any 403 as a deny. It now needs an OPA verdict in the body. The demo driver'sprobe_agentmakes the same check on a wanted 403 (demo/use-cases/onboarding/driver.sh:697). A live inbound deny body was{"error":"policy.forbidden","message":"policy denied","plugin":"opa"}, so a real deny still passes.
Result for your scenario. An evaluation error is now error, never deny. The driver gives UNEXPECTED 403 policy.forbidden …, so its test-user check fails instead of passing, and deny_origin returns None.
Tests (test/unit/test_launcher_outcome.py): both shapes; the response variant; a deny whose reason only mentions the phrase (still a deny); opa_verdict / opa_decision_error on raw answers; inbound_outcome.
Recorded in: uc1-onboarding-pipeline.md, Testing Decisions ("only a named opa deny is one"), and the runbook's Reading a decision table (k8s/opa-kind-runbook.md:590).
Residual risk. The two prefixes are copied from cortex, and this match does not fail closed. If cortex rewords them, an evaluation error reads as a deny again, as before this fix. A machine-readable reason in the rejection would remove the copy. rossoctl/cortex#1570 asked for one for the exchange plugin's refusals (target_disabled), and cortex closed it as deferred. The prefixes sit in one constant, OPA_DECISION_ERROR_PREFIXES (k8s/authbridge-rejection.py), next to the cortex lines they copy.
There was a problem hiding this comment.
Follow-up in e677824: the residual risk in my reply above is now recorded as a deliberate, accepted risk, with its rationale, not left as a loose limitation.
Where: docs/testing/uc1-onboarding-pipeline.md:802, Testing Decisions ("Accepted risk: the evaluation error is told apart by its message text"). The constant OPA_DECISION_ERROR_PREFIXES in k8s/authbridge-rejection.py points there. The PR body's Merge Danger section lists it as an accepted risk.
Why it is accepted
- Without the check, every evaluation error reads as a deny. So the check can only remove false passes, never add one.
- If cortex rewords the prefix, detection falls back to the behaviour before this PR, not to something worse.
- The allow side does not depend on it: an evaluation error never reads as an allow. So every allow expectation still fails on a Rego that does not evaluate.
Rejected alternatives
- Matching cortex's exact deny message,
"policy denied"(its text forallow: falsewith no reason; AIAC's Rego sets none). It would fail closed, but it pins a different cortex string and breaks on any Rego that sets a reason. Each such deny would read aserror, which the convergence polls retry until they time out. - Waiting for a machine-readable reason code. token-exchange-aiac: put the refusal reason in the rejection, not only the message cortex#1570 asked for that for the exchange plugin's refusals, and cortex closed it as deferred.
Revisit when cortex puts a reason code in its rejections: key on it, and drop the prefixes.
| # (`make enforce` runs --only-wire-outbound, then --only-enforce, as two processes). After the checks | ||
| # above, so a cluster that fails them has no agent restart (their one write, the idempotent ROPC | ||
| # default-scope link above, is the same one a full run makes). | ||
| if [ "$DO_WIRE" -eq 1 ] || agent_restarted_within 120; then |
There was a problem hiding this comment.
Correctness: the 120 s rule can skip the restart that clears the cache. ENFORCE skips its cache-clearing agent restart when the newest github-agent pod started less than 120 s ago. It does this whatever caused the start, not only when WIRE restarted it.
Failure scenario: the agent pod restarts (a crash, the operator's final rollout, a manual delete) before github-tool's Keycloak client exists. Then --only-enforce runs within 120 s. No restart happens, and token-exchange-aiac still holds the github-tool audience as unknown (5 min TTL). The dev-user call passes through without an exchange, and github-tool's jwt-validation answers 401, giving "UNEXPECTED 401". The bundle-wait loop treats that as non-retryable, so the driver stops at once.
There was a problem hiding this comment.
Fixed in e6188c9, by removing the rule together with the restart it guarded (see the opa-kind-enable.sh thread).
Change. The overlay sets unknown_audience_ttl: "0" (k8s/opa-kind-enable.sh:466), so no audience is ever cached as unknown and ENFORCE has nothing to clear. agent_age, agent_restarted_within and restart_agent_for_exchange are deleted from driver.sh.
Result for your scenario. A pod that restarted for any reason within 120 s of --only-enforce no longer matters. Nothing is cached, so the dev-user call is exchanged, not passed through as a 401.
The case this leaves open, and its guard. The running agent could still have an old sidecar config. The webhook rebuilds authbridge-config-github-agent from the namespace ConfigMap only when it admits the pod. So ENFORCE checks that per-agent config (check_agent_wiring, driver.sh:249) and stops with "run k8s/opa-kind-enable.sh again". I checked this on the cluster by setting that ConfigMap back to the 5 m default.
Evidence. make e2e and ./driver.sh --only-enforce pass with no agent restart.
| ) # fmt: skip | ||
| for line in out.splitlines() if ok else []: | ||
| name, _, owners = line.partition("\t") | ||
| if name.startswith("rossoctl-keycloak-client-credentials-") and set(owners.split()) & set(workloads): |
There was a problem hiding this comment.
Stale comment / missed case. The leftover-Secret warning matches owner references only against the scenario workload names (scn.AGENT_WORKLOAD / TOOL_WORKLOAD). So the case that the comment names ("the agent ran under another Deployment", that is AIAC_AGENT_DEPLOYMENT) is never reported. The old prefix sweep that deleted those Secrets is gone.
Failure scenario: AIAC_AGENT_DEPLOYMENT=my-agent. The operator's Secret is owned by my-agent, which is not in {github-agent, github-tool}. The exact-name delete misses it and the warning does not fire. A stale client-credentials Secret stays in team1 across runs with no log line, although the comment says it is reported.
There was a problem hiding this comment.
Fixed in e6188c9, by making the comment true rather than widening the match.
Why not widen the match. AIAC_AGENT_DEPLOYMENT only named the Deployment that restart_agent restarted. It never changed what the harness deploys: deploy_workload always applies the demo manifest, so the Deployment is always github-agent. The operator's controller reference on the Secret (clientregistration_controller.go:600, SetControllerReference) always names it too. So the case in your scenario cannot occur through the harness.
Change
restart_agentis gone (see theopa-kind-enable.shthread), soAIAC_AGENT_DEPLOYMENTand its two entries in the testing docs are gone too.- The comment (
test/system/uc1_onboard.py:1152) names the only case left: an operator naming change that the exact-name delete misses. The owner check catches that.
Recorded in: uc1-onboarding-pipeline.md, Testing Decisions ("Teardown removes the demo workloads' own objects by exact name").
| type: "client-secret" | ||
| audience_template: "spiffe://{{ get (get (index .Values "operator-chart" | default dict) "signatureVerification" | default dict) "spireTrustDomain" | default "localtest.me" }}/ns/{namespace}/sa/{service}" | ||
| namespace_file: /var/run/secrets/kubernetes.io/serviceaccount/namespace | ||
| no_token_policy: "deny" |
There was a problem hiding this comment.
Altitude: fix the cache TTL once, not with restarts. Three places work around the 5-minute unknown_audience_ttl cache with agent restarts: the driver WIRE, the ENFORCE restart with its agent_restarted_within age heuristic, and the harness restart_agent. If this dev overlay sets the plugin's unknown_audience_ttl to a few seconds, the root cause goes away.
Each new caller of a new target needs its own restart logic, and the age heuristic is already fragile (see the comment on driver.sh). A runbook user who adds a tool still sees un-exchanged calls and 401s for up to 5 minutes.
There was a problem hiding this comment.
Agreed, done in e6188c9. Your suggestion is now the design.
Change
k8s/opa-kind-enable.sh:466setsunknown_audience_ttl: "0". At a TTL of 0 or less the plugin's cache does nothing (cortextokenexchangeaiac/cache.go,has/add).- All three workarounds are removed: WIRE's restart, ENFORCE's restart with the age rule, and the harness's
restart_agent(with its call in the rollback test, andAIAC_AGENT_DEPLOYMENT). No rung now asserts a bundle that a restart re-fetched. The convergence polls already wait for each OPA's next bundle poll.
So it cannot regress silently. The wiring check requires a zero Go duration, as a string (k8s/authbridge-pipeline-check.py:161). I checked the matcher (:66) against Go's time.ParseDuration: "0", "0s", "-0s" and ".0s" pass. Unset (the plugin's 5 m), "5m", "0.5s", "00" and a bare YAML 0 fail; the plugin's string field refuses a bare 0 at sidecar start. The driver preflight, opa-kind-verify.sh and the harness skip gate run that check. A cluster on an older overlay is told to re-run k8s/opa-kind-enable.sh instead of failing later with a 401.
The running agent's own config. A sidecar runs authbridge-config-<agent>, which the webhook rebuilds only when it admits the pod. So WIRE and ENFORCE also check authbridge-config-github-agent (demo/use-cases/onboarding/driver.sh:249). The harness needs no such check: onboarded_stack deploys the agent after its skip gate passed.
Cost. One Keycloak token request per call to an in-cluster host that has no client; today, the agent's calls to the unregistered github-tool-mcp (#247). Hosts outside the cluster are never asked about.
Rejected alternatives: keeping the restarts; waiting out 5 minutes; a short non-zero TTL, which still lets a new target's first calls pass through un-exchanged.
Recorded in: PRD D36, the new bullet "No unknown-audience cache" (docs/specs/PRD.md:466) and its rejected list; uc1-onboarding-pipeline.md, Testing Decisions (:817: required value, cost, the per-agent check, rejected options); the runbook's Outbound token exchange section; demo.md Step 5.
Evidence
- System lane: 87 passed, 0 failed, 14 skipped, with no agent restart.
make e2epasses.- A live check with a throwaway target. A call to an in-cluster host with no client passed through. I created its Keycloak client, and the next call, one second later with no restart, went through the exchange: 403
audience_not_granted. With the 5 m TTL it would have passed through again.
| NAMES_OK=1 | ||
| TOOL_AUD_SCOPE="$(aud_scope github-tool)" || NAMES_OK=0 | ||
| MCP_AUD_SCOPE="$(aud_scope github-tool-mcp)" || NAMES_OK=0 | ||
| AGENT_AUD_SCOPE="$(aud_scope github-agent)" || NAMES_OK=0 |
There was a problem hiding this comment.
Cleanup: dead variable. AGENT_AUD_SCOPE is computed (and can set NAMES_OK=0) but nothing reads it. Step 3 calls aud_scope again for each component at line 150 instead of using the names computed at the top.
Result: a failure of the github-agent name sets the "audience scopes are not removed" warning, although step 1 never uses that name. Step 3 also runs operator-names.py again with different error handling (|| true, empty AUD_NAME). The same names have two sources.
There was a problem hiding this comment.
Fixed in e6188c9. Each name now has one source: the three assignments at the top of restore.sh, and AGENT_AUD_SCOPE is read.
Change
- Step 3 takes each workload's own name from those variables (
demo/use-cases/onboarding/restore.sh:134:github-agent→AGENT_AUD_SCOPE,github-tool→TOOL_AUD_SCOPE). It no longer callsaud_scopeagain with|| true. - An empty name (
operator-names.pyfailed; the warning is printed) matches no scope, so the prefixedgithub-*.scopes are still deleted. Before, an emptyAUD_NAMEwas compared to the scope names as it was. - The comment above the assignments says which step reads which name, so the warning's claim is accurate.
Evidence. make restore leaves team1 with no demo objects.
| if [ -n "$UUID" ]; then | ||
| curl -s -o /dev/null -w " delete ${name} HTTP %{http_code}\n" -X DELETE \ | ||
| -H "Authorization: Bearer ${ADMIN}" "${KC}/admin/realms/${REALM}/clients/${UUID}" | ||
| -H "Authorization: Bearer ${ADMIN}" "${KC}/admin/realms/${REALM}/clients/${UUID}" || true |
There was a problem hiding this comment.
Reuse: duplicated helpers. Step 1 inlines the same json_eval expression as the new scope_id_in in driver.sh (next(... "absent") if isinstance(d, list) else ""). Also, aud_scope_name in lib/_lib.py re-implements the load-by-path cache of launcher._load_k8s_module for k8s/operator-names.py.
The "absent" vs "" contract and the k8s/ module loader each exist in two copies. A change to one (for example a new sentinel, or a clearer ImportError message for a broken file, as the launcher has) can silently diverge from the other. This goes against the PR's own "one copy of each shared rule" aim.
There was a problem hiding this comment.
Fixed in e6188c9, both duplications.
Shell. json_eval, aud_scope, realm_client_scopes and scope_id_in now live once, in the new demo/use-cases/onboarding/lib/keycloak.sh, and driver.sh and restore.sh both source it. The "absent" / "" contract is written once (lib/keycloak.sh:35). Restore step 1 calls scope_id_in, and steps 1 and 3 call realm_client_scopes.
Python. aud_scope_name in lib/_lib.py has no loader or cache of its own now. It reads k8s/operator-names.py with runpy.run_path (lib/_lib.py:41), which touches no sys.modules. A missing file is a FileNotFoundError that names it.
Why one Python loader cannot be shared. docs/testing/testing-strategy.md (Dependency direction) forbids the harness depending on demo/, and lib/_lib.py is meant to run without the test/ tree. So _load_k8s_module stays in the harness, and the demo reads the same k8s/ file directly. The rule has one copy, in k8s/operator-names.py.
Evidence. make e2e and make restore pass with the shared file.
| return _rejection().outbound_outcome(code, body) | ||
|
|
||
|
|
||
| def exchange_refusal(code: int | None, body: str) -> bool: |
There was a problem hiding this comment.
Cleanup: unused public API. The new harness wrappers exchange_refusal, is_exchange_refusal and target_disabled_message (and the public classify in authbridge-rejection.py) have no system-lane caller. Only unit tests use them.
This adds harness API surface and docstrings to maintain that no live test uses. The unit tests can call the k8s/authbridge-rejection.py module directly, as test_launcher_outcome already does for classify through launcher._rejection().
There was a problem hiding this comment.
Fixed in e6188c9.
Change
exchange_refusal,is_exchange_refusalandtarget_disabled_messageare removed fromtest/system/launcher.py. The wrappers left are the ones the harness calls (outbound_outcome,rejection_doc,opa_verdict,opa_decision_error,target_disabled_refusalat:770), and the comment above them says so.- Their unit tests call
k8s/authbridge-rejection.pythroughlauncher._rejection(), astest_launcher_outcomealready did. - The public
classifyis gone too. Its docstring moved to_classify, whichverdictalready used.
Evidence. Unit lane: 2197 passed.
|
|
||
| # The audience scopes this driver reads, named once. A plain assignment, so a failure of | ||
| # k8s/operator-names.py stops the driver here, not as an empty name inside a poll loop. | ||
| AGENT_AUD_SCOPE="$(aud_scope github-agent)" || { echo "k8s/operator-names.py failed" >&2; exit 1; } |
There was a problem hiding this comment.
Cleanup: misleading error without python3. The top-level AGENT/TOOL/MCP_AUD_SCOPE assignments run python3 k8s/operator-names.py before the Preflight step that checks that python3 is on PATH (line 389).
Failure scenario: on a host without python3, the driver exits 1 with "k8s/operator-names.py failed" before Preflight runs. It does not give the clear "missing required command on PATH: python3" message that Preflight exists to give.
There was a problem hiding this comment.
Fixed in e6188c9.
Change. The three *_AUD_SCOPE assignments now come right after Preflight's command -v loop (demo/use-cases/onboarding/driver.sh:379). A failure goes through die "k8s/operator-names.py failed", so --collect-logs captures it too. The helpers they call come from lib/keycloak.sh, which defines functions only and runs nothing when sourced.
Result for your scenario. With no python3 on PATH, the driver stops at Preflight with "missing required command on PATH: python3".
- No agent restart. The overlay sets token-exchange-aiac's
unknown_audience_ttl to "0", and the wiring check requires a zero Go
duration, so no audience is cached as unknown. WIRE's restart,
ENFORCE's restart with its 120 s age rule, and the harness's
restart_agent (with AIAC_AGENT_DEPLOYMENT) are removed. A sidecar
reads its config at admission, so WIRE and ENFORCE also check
github-agent's own authbridge-config-github-agent.
- An OPA evaluation error ("OPA decision error: ...", policy.forbidden
from opa) is no deny, on either leg. is_opa_violation needs the
rejection's message; opa_verdict and opa_decision_error read it in
both response shapes. inbound_outcome and the driver's inbound probe
count a 403 only when it is an OPA verdict.
- ensure_default_audience_scope moves an optional link to default
before it links, as _link_default_scope does.
- Demo scripts: json_eval, aud_scope, realm_client_scopes and
scope_id_in live once, in lib/keycloak.sh. restore.sh names each
audience scope once. The driver computes the names after Preflight's
python3 check. _lib.py reads operator-names.py with runpy.
- Harness: the launcher drops the wrappers that nothing calls, and
authbridge-rejection.py drops the public classify.
- Docs: PRD D36 gets a bullet for the TTL (why, the cost, the rejected
options). The Testing Decisions record the required value, the cost,
the per-agent config check and the rejected options. The runbook's
"Reading a decision" table lists the OPA decision error. demo.md and
the testing docs say there is no restart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
|
Replies to the review of db37855 are inline. All 9 findings are addressed in e6188c9, and each reply gives the change with The finding outside the diff ( The largest change follows your Two more fixes from my own review, both in the same commit:
Evidence at e6188c9
|
The Testing Decisions now state the risk of telling an OPA evaluation error from a deny by its message prefix, why it is accepted (the check only removes false passes; a cortex rewording falls back to the earlier behaviour; allow expectations still fail on a broken Rego), the rejected alternatives (matching cortex's "policy denied"; waiting for a reason code, rossoctl/cortex#1570) and when to revisit. The constant in k8s/authbridge-rejection.py points there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
…check lib/keycloak.sh is sourced, so it has no shebang, and shellcheck failed it with SC2148 (unknown target shell) in the Shell Script Lint job. A `# shellcheck shell=bash` directive declares it. The source directives in driver.sh and restore.sh now resolve the file from the script's own directory (SCRIPTDIR), so `shellcheck -x` follows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Anatoly Koyfman <anatoly@il.ibm.com>
Summary
AuthBridge's outbound leg switches from
token-exchange(one route per target, inauthproxy-routes) totoken-exchange-aiac(no routes), from rossoctl/cortex#1496. The plugin derives the audience from the destination host and asks Keycloak, so a new tool needs no route and no agent restart.With no
scopeparameter, only a default client scope of the agent's client grants a target. That changes the grant and the failure shapes:authproxy-routes+ agent restartagent-<ns>-github-tool-audpolicy.forbidden,plugin: token-exchange-aiacpolicy.forbidden(target_disabled)upstream.token-exchange-failedauth.unauthorized(no_token_policy: deny; the workload's own token is never sent)The system harness:
grant_exchange_scopelinks the tool's audience scope as default, moving an optional link first (the same move as_link_default_scopeof the IdP Configuration Service, copied rather than imported). Keycloak silently skips a default link for a scope that is already optional. Any Keycloak error raises.unknown_audience_ttl: "0", so an audience the plugin saw as unknown before the tool's client existed is asked again on the next call. Each rung asserts the bundle the live onboarding produced.require_pipelineskips cleanly unless the runtime config hasopaonce on each leg, exactly onetoken-exchange-aiacand notoken-exchangeon the outbound leg, aspiffe://<td>/...{service}...audience template,no_token_policy: deny, andunknown_audience_ttl: "0". It parses the config. The rule isk8s/authbridge-pipeline-check.py:opa-kind-verify.sh, the demo driver andteardown.shrun that file, and the gate loads it, so they cannot disagree. The gate returns the trust domain from the same read, so a bad template skips before any cluster change.k8s/authbridge-rejection.py(standard library only), loaded by the harness and sent into the agent pod by the demo's probes. An OPAdenyis apolicy.forbiddenverdict only. An exchange refusal or failure, OPA with no bundle yet, an OPA evaluation error (OPA decision error: …), another plugin's rejection, and AuthBridge's fallback frame are never adeny, and never anallow. The demo's ENFORCE passes test-user only on github-tool's own OPA deny. The rollback test counts a refusal as blocked only when the tool's client is disabled; a refusal for a target not granted, and a failed exchange, still fail it.token-exchange-aiactemplate, which the skip gate returns.ensure_github_tool_route,restart_agentand its knobAIAC_AGENT_DEPLOYMENTare gone.Decisions
Each design decision is recorded in the spec for its feature (
docs/agents/domain.md):no_token_policy: deny; no unknown-audience cache (unknown_audience_ttl: "0"), so adding a target needs no restart; Service = ServiceAccount; no defaultgithub-full-access; the realm-default widening (Task: the operator's realm default audience scopes grant every later client every earlier target #248); the rejected options.uc1-onboarding-pipeline.md→ Testing Decisions — what an OPA verdict is (an evaluation error is not one); one copy of each sharedk8s/rule; the wiring order; no agent restart; exact-name teardown; the harness's own link-move copy.demo.md→ Step 5, How ENFORCE waits, and why — the bundle wait; no cache to wait out, so no restart; no401retry.Evidence
At
e6188c9(after the sixth review; the next commit changes only docs and a comment):pytest(the default lane) passes: 2197 passed. Pure tests cover the classifier and its edge frames (OPA with no bundle, an OPA evaluation error in both shapes, AuthBridge's fallback frame, another plugin's rejection, a tool's own JSON-RPC error), the inbound classifier, the refusal rules, and the skip gate on parsed configs: half-migrated, inbound-only, duplicated, commented-out, quoted and bare-string entries, a non-listplugins,opabefore the plugins it reads, a template without{namespace}or{service}or with them in the wrong order,no_token_policyother thandeny, andunknown_audience_ttlthat is unset, non-zero, an int, or not a Go duration. I checked the zero-duration matcher against Go'stime.ParseDuration. Also tested: the gate skips when a sharedk8s/file is missing or broken, or PyYAML is missing, and importing the classifiers needs nok8s/.test/unit/k8s/test_shared_scripts.pyruns bothk8s/scripts as the shell scripts run them, on the same configs.pytest -m systempasses on a Kind cluster re-enabled with this branch's overlay (no_token_policy: deny,unknown_audience_ttl: "0"), with the AuthBridge sidecar built from cortexmainatef5ac5f8(#1496): 87 passed, 0 failed, 14 skipped (thetest/system/disabled/modules, skipped on every run). No agent is restarted. The run was on this commit's code.make e2e,./driver.sh --only-enforceandmake restoreindemo/use-cases/onboarding/pass, with no agent restart, and restore leavesteam1empty. With the agent's own sidecar config set back to the 5 m default, ENFORCE stops and says to re-runk8s/opa-kind-enable.sh.audience_not_granted).ensure_default_audience_scopemoved an optional-only link to default. The previous code left it optional and still reported success.k8s/opa-kind-verify.shandk8s/keycloak-spi-verify.shpass all checks on that cluster.Merge Danger
Door: two-way
Revert, then run
k8s/opa-kind-enable.shfrom a cortex checkout without the plugin to get the route-based pipeline back. Nothing in AIAC's own services changes.Blast Radius: platform
token-exchange-aiac:mainat or afteref5ac5f8(Feat: Add token-exchange-aiac, an outbound token exchange with no routes cortex#1496, merged). An older cortex fails at sidecar start: with an unknown-plugin error, or, before1049bd07, by rejecting thenamespace_filekey.k8s/opa-kind-verify.sh, the demo driver andteardown.shnow needpython3with PyYAML.k8s/opa-kind-enable.sh. Earlier overlays setno_token_policy: client-credentials, or leftunknown_audience_ttlat the plugin's 5 minutes; the wiring check now requiresdenyand"0", so verify.sh and the demo preflight fail, and the system lane skips, until it is re-run.OPA decision error:), copied from cortexcore/plugins/opa/plugin.gointo one constant. If cortex rewords it, an evaluation error reads as a deny again, as it did before this PR. We accept this because the check can only remove false passes, never add one; a rewording falls back to the earlier behaviour; and every allow expectation still fails on a Rego that does not evaluate. Rejected: matching cortex's"policy denied"instead (it pins another string, breaks on any Rego that sets a reason, and stalls the convergence polls); waiting for a machine-readable reason (token-exchange-aiac: put the refusal reason in the rejection, not only the message cortex#1570, closed as deferred). Revisit when cortex adds a reason code. Recorded inuc1-onboarding-pipeline.md, Testing Decisions.unknown_audience_ttl: "0"costs one Keycloak token request per outbound call to an in-cluster host that has no client (today, the agent's calls to the unregisteredgithub-tool-mcp, Task: exchange the demo github-agent's calls to the production github-tool-mcp under token-exchange-aiac #247). Hosts outside the cluster are never asked about.authproxy-routes. On the stock chart pipeline its tool calls go out unexchanged (INSTALL.md says so).teardown.shwarns about a route left by an older demo and does not delete it.agent-<ns>-<workload>-audscope as a realm default scope, and Keycloak links realm defaults to every client created later. So an agent created after a tool already holds that tool's audience with no grant step, and its exchange is never refused (403). The tool's own inbound OPA still decides each call. The runbook says so; follow-up: Task: the operator's realm default audience scopes grant every later client every earlier target #248.github-tool-mcp) are not exchanged yet. The AIAC side is wired:MCP_URLderives…/sa/github-tool-mcp, and WIRE links that tool's audience scope as a default scope ofgithub-agentwhen it exists. The cortex demo must still run the tool under a ServiceAccountgithub-tool-mcpand enroll it (github-issue demo: make the production github-tool-mcp reachable by token-exchange-aiac cortex#1540, which cortex closed as deferred on 2026-10-29: the Kubernetes path is not their current priority).github-full-accessis never granted: as a default scope it would give every user the privileged PAT, so the tool uses its public PAT. The UC-1 demo and the system lane call the stubgithub-tooland are not affected. Follow-up: Task: exchange the demo github-agent's calls to the production github-tool-mcp under token-exchange-aiac #247.k8s/authbridge-pipeline-check.py) is stricter:opamust run afterjwt-validationandmcp-parserinbound and aftertoken-exchange-aiacoutbound, and the audience template must carry{namespace}before{service}. A cluster enabled byk8s/opa-kind-enable.shmeets it; a custom overlay that does not now fails Verify 1 and skips the system lane.🤖 Generated with Claude Code