diff --git a/CONTEXT.md b/CONTEXT.md index 18408db7..c3aedfc1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -138,3 +138,25 @@ description-only prohibition yields no durable DENY (see the PRB spec's _digeste input retires exclusivity handling and Door B_ decision). Enforcement is tracked in the neutrality-guard follow-up (`rossoctl/aiac`). _Avoid_: description-driven deny, effect-in-description. + +**Quarantine**: +The policy teardown of a failed onboarding, done by the PCE after the UC1 +rollback disables the client. Keyed by the clientId, as every PCE operation +is. It deletes the service's SPM, removes its roles +from the other SPMs, replaces a failed agent's CR with a no-rules CR (which +denies every request), and re-derives the affected agents. A disabled client +_is_ a quarantined service: the routing guard drops every rule that touches it, +and the focal resolver gives it no candidate role or scope. Only a successful +re-onboarding lifts it. Distinct from **decommission** (the offboard teardown of +a deleted client, keyed by clientId). +_Avoid_: rollback (the IdP-side teardown that comes before it), block, ban. + +**Focus service**: +The service that the current onboarding builds rules for — the `focus_service` +the onboarding route and the NATS consumer pass to the PCE, as its clientId +(never its Keycloak UUID). It is the one +disabled service that the routing guard and the focal resolver do not skip, +because a re-onboarding of a quarantined service builds and applies while its +client is still disabled (`reenable_service` runs after the apply). +_Avoid_: target service (a target is the service an agent calls), focal entity +(the per-pass subject inside one build). diff --git a/docs/examples/opa-team1-policy.yaml b/docs/examples/opa-team1-policy.yaml index 2b5de799..fd85e07f 100644 --- a/docs/examples/opa-team1-policy.yaml +++ b/docs/examples/opa-team1-policy.yaml @@ -1,68 +1,45 @@ # Example Rossoctl OPA AuthorizationPolicy (client-scoped, github-agent) # -# A single, client-scoped policy CR for exactly one workload in the `team1` -# namespace — the github-agent. It carries one rego file per tier -# (inbound/request + outbound/request), server-side-applied by the PDP Policy -# Writer (OPA). This file mirrors the current `generate_inbound_rego` / -# `generate_outbound_rego` output (the ALLOW/DENY split gates) — annotated with -# explanatory comments and blank-line spacing for readability, so it doubles as -# documentation of the generated shape. The declaration maps, gates, and -# decision blocks are identical to the generator's output; only the added -# comments and spacing differ. +# One client-scoped policy CR for exactly one workload in the `team1` namespace: +# the github-agent. It carries one rego file per tier (inbound/request + +# outbound/request), as the PDP Policy Writer (OPA) server-side-applies it. The +# two `content` blocks are the exact output of `generate_inbound_rego` / +# `generate_outbound_rego` (src/aiac/pdp/service/policy/opa/rego.py) for the +# demo policy: users dev-user (role developer) and test-user (role tester), the +# agent's two scopes, and the github-tool's four tools. Regenerate them from the +# generator when it changes; do not edit them by hand. # -# TWO-SIDED MODEL (ALLOW / DENY). Each tier evaluates *_allow_ok gates and -# mirrored *_deny_ok gates and applies DENY-OVERRIDES: a request is permitted -# only when an allow gate passes AND no deny gate matches. The split *_allow_scopes -# / *_deny_scopes maps below feed those gates; the identity maps (subject_roles, -# source_roles) are effect-agnostic and must list a role even if it appears only -# in a deny edge, or the deny lookup can't resolve it. In this demo the deny maps -# are empty (allow-only policy), so no request is denied by an explicit prohibition. +# ALWAYS DENY BY DEFAULT. Both tiers emit `default allow := false`: a request that +# no rule allows is denied. Each tier has *_allow_ok gates and mirrored *_deny_ok +# gates; a matching deny gate blocks the request. The identity maps (subject_roles, +# source_roles) are effect-agnostic. In this demo the deny maps are empty +# (an allow-only policy). # -# PER-POLICY DEFAULT EFFECT (default_effect). AgentPolicyModel.default_effect -# (Allow / Deny, default Deny) decides how a tier treats a (role, scope) pair -# that NO rule mentions. Three states per pair: explicitly allowed (an allow rule -# names it), explicitly denied (a deny rule names it), and unspecified (no rule -# names it -> resolves to default_effect). Both tiers below are generated under -# the DEFAULT `default_effect: Deny`, which emits `default allow := false` plus a -# single `allow if { ... }` rule (least-privilege — an unmentioned pair is denied). -# Only the trailing decision block depends on default_effect; every declaration -# map and *_allow_ok/*_deny_ok gate is identical in both modes. The `default_effect: Allow` -# alternative (permissive default, deny-overrides preserved) is shown as a commented -# block at the end of each tier's content. The generator assumes disjoint allow/deny -# per (role, scope); a genuine overlap is an upstream conflict (HTTP 422, PRB -# PolicyContradictionError) and is never reconciled here. -# -# OUTBOUND gating fields: the outbound rego keys on input.identity.subject, -# input.identity.service_id, and input.mcp.params.name — all populated by the -# live AuthBridge OPA plugin on the outbound leg (see opa-kind-runbook.md Part -# B.5). input.identity.service_id is the downstream service the exchanged token -# was minted for (the last delegation hop's target audience — here the -# github-tool SPIFFE ID). input.mcp.params.name is the specific tool invoked, so -# the rule gates PER TOOL. The maps key on the actual MCP tool names exposed by -# the deployed github-tool (demo/assets/tools/github_tool): source-read, -# source-write, issues-read, issues-write. MCP methods that invoke no specific -# tool (e.g. a `tools/list` discovery request) carry no params.name, so they -# never match and are denied. +# OUTBOUND. The outbound tier reads input.identity.subject (the delegated user), +# input.identity.service_id (the target the exchanged token was minted for — the +# github-tool SPIFFE ID), input.mcp.method and input.mcp.params.name (the invoked +# tool; the maps key on the bare MCP tool names source-read, source-write, +# issues-read, issues-write). A `tools/call` is allowed when the user's role AND +# the target both admit the invoked tool and no deny matches. The MCP session +# messages (initialize, notifications/initialized, ping, tools/list) carry no tool +# name: they are allowed to a target when at least one tool of that target passes +# that same per-tool check (`tool_ok`). Every other request is denied — also A2A +# and LLM calls through the agent's outbound proxy (see the known limit in +# docs/specs/components/pdp-policy-writer-opa.md). # # CLIENT-SCOPE TARGETING: bundle-service looks a client-scope CR up by # metadata.name + metadata.namespace, matched against the ServiceAccount segment -# of the caller's SPIFFE ID (spiffe:///ns//sa/ -> -# namespace=, name=) — see operator/internal/bundleservice/ -# identity/identity.go and watcher.go's GetPolicy(name, namespace). spec.clientID -# is NOT consulted by that lookup (print-column metadata only); this CR is named -# `github-agent` — matching `sa/github-agent` of -# spiffe://localtest.me/ns/team1/sa/github-agent — because that's what scopes it -# to this one workload. clientID must satisfy the CRD's DNS-label regex (no -# `spiffe://`, no `/`). +# of the caller's SPIFFE ID (spiffe:///ns//sa/). +# spec.clientID is not used by that lookup; it must satisfy the CRD's DNS-label +# regex (no `spiffe://`, no `/`). The bundle-service combines each tier with the +# shipped global policy; a tier with no client package falls back to allow +# (`client_ok if not data.authbridge.client.`). # # Identity note: INBOUND the OPA plugin exposes input.identity.{subject, -# client_id, scopes} (+ audience when the validated JWT carries it). OUTBOUND -# there is no validated JWT, so the plugin synthesizes input.identity from the -# token-exchange delegation hop: subject (delegated caller), client_id (this -# agent), scopes, and service_id (the downstream target audience). `subject` is +# client_id, scopes}. OUTBOUND there is no validated JWT, so the plugin +# synthesizes input.identity from the token-exchange delegation hop. `subject` is # the JWT `sub` claim; rossoctl-realm tokens carry the username in `sub` (via a -# username->sub protocol mapper on the `rossoctl` client — see A.1 in -# opa-kind-runbook.md, applied cluster-wide). +# username->sub protocol mapper — see A.1 in k8s/opa-kind-runbook.md). apiVersion: agent.rossoctl.dev/v1alpha1 kind: AuthorizationPolicy metadata: @@ -83,9 +60,7 @@ spec: "dev-user": ["developer"], "test-user": ["tester"], } - source_roles := {} - subject_role_allow_scopes := { "developer": ["github-agent.issue_operations", "github-agent.source_operations"], "tester": ["github-agent.issue_operations"], @@ -104,7 +79,6 @@ spec: some scope in subject_role_deny_scopes[role] scope in agent_scopes } - source_allow_ok if { not input.identity.client_id } source_allow_ok if { input.identity.client_id == "rossoctl" } source_allow_ok if { @@ -117,21 +91,8 @@ spec: some scope in source_role_deny_scopes[role] scope in agent_scopes } - - # default_effect: Deny (the default) — unmentioned (subject, scope) pairs - # are denied. Least-privilege; byte-for-byte today's output. default allow := false allow if { subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok } - - # default_effect: Allow — the SAME declarations/gates above, only this - # trailing block differs. Unmentioned pairs fall through to `true`; an - # explicit deny still overrides. (A bare `default allow := true` with the - # Deny-mode `allow if { ...; not ... }` body would make every prohibition - # evaporate — deny precedence needs its own `allow := false if` rules.) - # default allow := true - # allow := false if { subject_deny_ok } - # allow := false if { source_deny_ok } - - path: "outbound/request.rego" content: | package authbridge.client.outbound.request @@ -140,13 +101,8 @@ spec: agent_roles := ["github-agent.issue_operations", "github-agent.source_operations"] subject_roles := { "dev-user": ["developer"], - "test-user": ["tester"] + "test-user": ["tester"], } - # The deployed github-tool (demo/assets/tools/github_tool) exposes - # exactly four MCP tools — source-read, source-write, issues-read, - # issues-write — one per skill. These names ARE the values that arrive in - # input.mcp.params.name when a specific tool is invoked, so the maps - # below key on them. subject_role_allow_scopes := { "developer": ["issues-read", "source-write", "source-read"], "tester": ["issues-read", "issues-write"], @@ -161,173 +117,32 @@ spec: "spiffe://localtest.me/ns/team1/sa/github-tool": ["source-read", "source-write", "issues-read", "issues-write"], } target_deny_scopes := {} - # user may reach the tool: holds a role granted the invoked tool (input.mcp.params.name) - subject_allow_ok if { - some role in subject_roles[input.identity.subject] - input.mcp.params.name in subject_role_allow_scopes[role] - } - subject_deny_ok if { - some role in subject_roles[input.identity.subject] - input.mcp.params.name in subject_role_deny_scopes[role] - } - # agent may reach the tool: the invoked tool is one the target accepts (direct, per-scope) - target_allow_ok if { - input.mcp.params.name in target_allow_scopes[input.identity.service_id] - } - target_deny_ok if { - input.mcp.params.name in target_deny_scopes[input.identity.service_id] - } - - # default_effect: Deny (the default) — a per-tool AND: allowed only when - # the delegated user's role AND the target service both admit the tool, - # and neither deny gate matches. Unmentioned pairs are denied. - default allow := false - allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok } - - # default_effect: Allow — the two-gate AND is DROPPED and replaced by - # deny-if-either-side. Do NOT flip to `allow := false if { not subject_allow_ok }` - # / `{ not target_allow_ok }`: every unmentioned (role, tool) pair matches - # neither allow gate and would be wrongly denied. Instead an unmentioned - # pair falls through to `true`; a deny on EITHER side overrides. - # default allow := true - # allow := false if { subject_deny_ok } - # allow := false if { target_deny_ok } - -# Example Rossoctl OPA AuthorizationPolicy (client-scoped, github-agent) -# -# A single, client-scoped policy CR that enforces one rule for exactly one -# workload in the `team1` namespace — the github-agent. -# -# INBOUND — deny user "alice" from reaching the github-agent. Every other -# authenticated user (e.g. "dev-user") stays allowed. -# -# OUTBOUND — gate the agent's token-exchanged call to a tool on both the -# delegated user's role and the exchange target audience. -# -# It carries a rego file per tier (inbound/request + outbound/request). The -# bundle-service composes each with the shipped global policy: for a tier the -# global combiner allows only when `ns_ok AND client_ok`, and `client_ok` -# comes from THIS package's `allow`. (A tier left undefined instead falls back -# to allow via the combiner's `client_ok if not data.authbridge.client.` -# rule — that is what happens when only one tier is present.) -# -# NOTE — outbound gating fields: the outbound rego below keys on -# `input.identity.subject`, `input.identity.service_id`, and -# `input.mcp.params.name` — all of which the live AuthBridge OPA plugin -# populates on the outbound leg (see opa-kind-runbook.md Part B.5). -# `input.identity.service_id` is the downstream service the exchanged token was -# minted for (the last delegation hop's target audience — here the github-tool -# SPIFFE ID). `input.mcp.params.name` is the specific tool being invoked, so the -# rule gates PER TOOL: it allows only when the delegated user's role AND the -# target service both admit the invoked tool. The `subject_role_scopes` / -# `target_scopes` maps below are keyed by the actual MCP tool names exposed by -# the deployed github-tool (demo/assets/tools/github_tool): `source-read`, -# `source-write`, `issues-read`, `issues-write`. Note that MCP methods which do -# not invoke a specific tool (e.g. a `tools/list` discovery request) carry no -# `params.name`, so they never match and are denied. -# -# IMPORTANT — how client-scope targeting actually works: bundle-service looks -# up a client-scope CR by **metadata.name + metadata.namespace**, matched -# against the ServiceAccount segment of the caller's SPIFFE ID (`spiffe:// -# /ns//sa/` -> namespace=, -# name=) — see operator/internal/bundleservice/identity/identity.go and -# watcher.go's `GetPolicy(name, namespace)`. `spec.clientID` is NOT consulted -# by that lookup (it's print-column metadata only); this CR is named -# `github-agent` — matching the `sa/github-agent` segment of -# `spiffe://localtest.me/ns/team1/sa/github-agent` — because that's what -# actually scopes it to this one workload. `clientID` below is set to the -# same short name purely for documentation/`kubectl get` display, and must -# satisfy the CRD's DNS-label-style validation regex (no `spiffe://`, no -# `/`). -# -# Identity note: on the INBOUND leg the OPA plugin exposes -# input.identity.{subject, client_id, scopes}, plus audience when the validated -# JWT carries it. On the OUTBOUND leg there is no validated JWT, so the plugin -# synthesizes input.identity from the token-exchange delegation hop: subject -# (delegated caller), client_id (this agent), scopes, and service_id (the -# downstream service the exchanged token targets — the last hop's audience). -# `subject` is the JWT `sub` claim. In this cluster the -# rossoctl-realm tokens carry the username in `sub` (via a username->sub -# protocol mapper on the `rossoctl` client — see A.1 in opa-kind-runbook.md, -# already applied cluster-wide). ---- -apiVersion: agent.rossoctl.dev/v1alpha1 -kind: AuthorizationPolicy -metadata: - name: github-agent - namespace: team1 -spec: - scope: client - clientID: "github-agent" - policies: - - path: "inbound/request.rego" - content: | - package authbridge.client.inbound.request - import rego.v1 - - agent_scopes := ["github-agent.issue_operations", "github-agent.source_operations"] - - subject_roles := { - "dev-user": ["developer"], - "test-user": ["tester"], - } - - source_roles := {} + session_methods := {"initialize", "notifications/initialized", "ping", "tools/list"} - role_scopes := { - "developer": ["github-agent.issue_operations", "github-agent.source_operations"], - "tester": ["github-agent.issue_operations"], - } - - subject_ok if { + subject_allows(tool) if { some role in subject_roles[input.identity.subject] - some scope in role_scopes[role] - scope in agent_scopes - } - - source_ok if { not input.identity.client_id } - source_ok if { input.identity.client_id == "rossoctl"} - source_ok if { - some role in source_roles[input.identity.client_id] - some scope in role_scopes[role] - scope in agent_scopes - } - - default allow := false - allow if { subject_ok; source_ok } - - - path: "outbound/request.rego" - content: | - package authbridge.client.outbound.request - import rego.v1 - - agent_roles := ["github-agent.issue_operations", "github-agent.source_operations"] - subject_roles := { - "dev-user": ["developer"], - "test-user": ["tester"] + tool in subject_role_allow_scopes[role] } - # The deployed github-tool (demo/assets/tools/github_tool) exposes - # exactly four MCP tools — source-read, source-write, issues-read, - # issues-write — one per skill. These names ARE the values that arrive in - # input.mcp.params.name when a specific tool is invoked, so the maps - # below key on them. - subject_role_scopes := { - "developer": ["issues-read", "source-write", "source-read"], - "tester": ["issues-read", "issues-write"], - } - agent_role_scopes := { - "github-agent.issue_operations": ["issues-read", "issues-write"], - "github-agent.source_operations": ["source-write", "source-read"], + subject_allow_ok if { subject_allows(input.mcp.params.name) } + subject_denies(tool) if { + some role in subject_roles[input.identity.subject] + tool in subject_role_deny_scopes[role] } - target_scopes := { - "spiffe://localtest.me/ns/team1/sa/github-tool": ["source-read", "source-write", "issues-read", "issues-write"], + subject_deny_ok if { subject_denies(input.mcp.params.name) } + target_allows(tool) if { + tool in target_allow_scopes[input.identity.service_id] } - subject_ok if { - some role in subject_roles[input.identity.subject] - input.mcp.params.name in subject_role_scopes[role] + target_allow_ok if { target_allows(input.mcp.params.name) } + target_denies(tool) if { + tool in target_deny_scopes[input.identity.service_id] } - target_ok if { - input.mcp.params.name in target_scopes[input.identity.service_id] + target_deny_ok if { target_denies(input.mcp.params.name) } + tool_ok(tool) if { + subject_allows(tool) + target_allows(tool) + not subject_denies(tool) + not target_denies(tool) } default allow := false - allow if { subject_ok; target_ok } + allow if { input.mcp.method == "tools/call"; subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok } + allow if { input.mcp.method in session_methods; some tool in target_allow_scopes[input.identity.service_id]; tool_ok(tool) } diff --git a/docs/specs/components/aiac-agent.md b/docs/specs/components/aiac-agent.md index 4d64ed48..5ea162be 100644 --- a/docs/specs/components/aiac-agent.md +++ b/docs/specs/components/aiac-agent.md @@ -124,7 +124,7 @@ The Controller is a FastAPI routes layer (`controller/routes.py`). Its responsib - Parse the trigger type and entity ID from the request path. - Dispatch to the Service Onboarding Orchestrator (UC1) or directly to the Policy Update / Role Update sub-agents (UC2, UC3). - Receive the `list[PolicyRule]` returned by the Orchestrator or sub-agent (already merged by the sub-agent). -- Call `compute_and_apply(merged_rules)` from `aiac.policy.computation` (PCE) once. +- Call `compute_and_apply(merged_rules)` from `aiac.policy.computation` (PCE) once. For UC1, the onboarding route and the NATS consumer also pass `override` and `focus_service` (the clientId of the service being onboarded, which `onboard_service` returns — not the UUID in the path), so the PCE routing guard keeps the rules of a disabled (quarantined) service that re-onboards. - Return a bare HTTP status code to the caller; write summary and debug info to the log. No per-use-case business logic, retry handling, or state assembly lives in the Controller. PRB calls are owned by the producing sub-agents; the Controller's shared step is the single PCE call. @@ -165,7 +165,7 @@ Every sub-agent (UC1 Provision + Service Policy Builder, UC2 Build + Rebuild, UC A `/health` reply needs a **free event loop** — the process being "up" is not enough. The event path therefore offloads its slow synchronous handler to a threadpool so the loop stays answerable during onboarding (see [NATS Consumer → Failure isolation](#failure-isolation)). As a complementary defence, the k8s Deployment tunes the **liveness** probe tolerant — an explicit `timeoutSeconds` and a raised `failureThreshold` (so several consecutive slow probes, not one, must fail before a restart) — so a brief loop-busy window cannot kill the pod. The concrete probe values are authoritative in `k8s/agent-deployment.yaml`; this spec fixes only the intent. -The `/apply/offboard/{service_id}` path uses the `{service_id:path}` converter (slash-bearing SPIFFE-URI clientIds) and is keyed on the **clientId (SPM key)**, not the Keycloak UUID that `/apply/service/{service_id}` carries — an offboarded client is gone from `get_services()`, so UUID→clientId resolution is impossible. +The `/apply/offboard/{service_id}` path uses the `{service_id:path}` converter (slash-bearing SPIFFE-URI clientIds) and is keyed on the **clientId (SPM key)**, not the Keycloak UUID that `/apply/service/{service_id}` carries — an offboarded client is gone from `get_services()`, so UUID→clientId resolution is impossible. The PCE takes only the clientId; the onboarding resolves it from the UUID in the Orchestrator. The `/apply/*` endpoints return bare HTTP status codes: `200 OK` on success (no response body), and the status codes from the Error Handling table on upstream failure. Success responses carry no body; upstream failures and PRB exceptions are raised as FastAPI `HTTPException`s, so error responses carry a sanitized JSON error body (`{"detail": }`; see [Error Handling → Sanitized body vs. full log](#sanitized-body-vs-full-log)) alongside the status code. Summary, applied-rule details, and debug information are written to the service log. Validation failures surface as an error status and log entry; detailed reporting is specified in [policy-rules-builder.md](aiac-agent/policy-rules-builder.md). A genuine grant/prohibit conflict surfaces on `/apply` as a `422` with a `ConflictReport` body (verbatim policy quotes; see [Error Handling](#error-handling)). There is no separate pre-commit `/policy/check` route — it is retired (see [PRB design decision: identify conflicts, never reconcile](aiac-agent/policy-rules-builder.md#design-decision-identify-conflicts-never-reconcile) / #2503), and the conflict diagnostic is folded into `/apply`. @@ -265,7 +265,7 @@ src/aiac/ ├── shared/ ← project-level shared: run_upstream (upstream.py) — transport retry primitive └── agent/ ├── controller/ - ├── shared/ ← flatten_role (roles.py); focal_entities.py (resolve_focal_entities — D13, shared by live build() + diagnostic); error_logging.py (log_by_type — per-persona named-logger router) + ├── shared/ ← flatten_role (roles.py); focal_entities.py (resolve_focal_entities — D13, shared by live build() + diagnostic; skips the roles and scopes of every disabled service, except the focus service); error_logging.py (log_by_type — per-persona named-logger router) ├── uc/ │ ├── onboarding/ │ │ ├── orchestrator.py ← sequences provision → policy_builder, returns list[PolicyRule] diff --git a/docs/specs/components/aiac-agent/policy-rules-builder.md b/docs/specs/components/aiac-agent/policy-rules-builder.md index db068e9c..f2848e12 100644 --- a/docs/specs/components/aiac-agent/policy-rules-builder.md +++ b/docs/specs/components/aiac-agent/policy-rules-builder.md @@ -580,6 +580,12 @@ and is **not yet enforced** here. | UC3 — Role Update | Role sub-agent | `build_role_rules(role, all_scopes)` — one call | | Conflict diagnostic (folded into `/apply`, [identify conflicts, never reconcile](#design-decision-identify-conflicts-never-reconcile) / #2503) | Apply path | A parallel diagnostic assembly reusing propose / precheck / audit (record-not-raise + a terminal `explain` node); returns a `422` `ConflictReport` | +> **Disabled services are not candidates.** The UC1 caller and the conflict diagnostic get their +> candidates from one shared resolver, `resolve_focal_entities` (`agent/shared/focal_entities.py`). +> It skips the roles and scopes of every disabled service (`Service.enabled == False`, a quarantined +> failed onboarding), except the focus service. So the PRB never judges a pair with a quarantined +> service. See [`uc1-service-onboarding.md` → Service Policy Builder](uc1-service-onboarding.md#sub-agent-service-policy-builder). + > **Door B removed (#2540).** UC1 previously also ran a user-role-focal **deny-only** pass > (`build_role_denies` over each `RoleKind.USER` candidate role, alongside the scope-focal pass) to > derive user-role exclusivity denies. Under digested input that pass is redundant and has been diff --git a/docs/specs/components/aiac-agent/uc1-service-onboarding.md b/docs/specs/components/aiac-agent/uc1-service-onboarding.md index e22ff5e8..724505d4 100644 --- a/docs/specs/components/aiac-agent/uc1-service-onboarding.md +++ b/docs/specs/components/aiac-agent/uc1-service-onboarding.md @@ -53,25 +53,36 @@ flowchart TD `onboarding/orchestrator.py` **Sequence:** +0. Read the `Service` once (`get_service(service_id)`, by the UUID) and take its clientId (`Service.serviceId`) — the only service id the PCE takes. This read comes before Provision, so if it fails, nothing exists yet that needs compensation: the Orchestrator raises `HTTPException(502)`, as Provision does for the same read. The rollback reuses this `Service`. 1. Call `ServiceProvisionGraph.invoke()` → get back `ServiceProvision { roles, scopes }` + `service_type`. 2. Call `ServicePolicyBuilder.build(service_id, service_type)` → get back `list[PolicyRule]`. Service Policy Builder re-resolves the focus service from the IdP catalog by its internal client UUID (`service_id`; Provision has already persisted its roles/scopes), so it needs only the id, not the `ServiceProvision`. -3. Return `(list[PolicyRule], override=False)` to the Controller. +3. Return `(list[PolicyRule], override=False, client_id)` to the Controller. `onboard_service(service_id)` returns this 3-tuple; the caller passes `client_id` to the PCE as `focus_service`, so it makes no second IdP read. It takes no default-effect argument: a pair that no rule mentions is always DENY. No LLM calls or response assembly in the Orchestrator beyond sequencing and the compensating rollback (see [Failure & Rollback](#failure--rollback)). -**Replay safety (at-least-once delivery):** Service Provision IdP writes are **idempotent** (create-or-get by name: `create_service_role` / `create_service_scope` return the existing entity on a duplicate call). The PCE reconcile is also idempotent. If the pod crashes between Service Provision completing and the PCE call, NATS redelivers and the full pipeline re-runs safely to convergence — the success re-run stays idempotent. A build **failure**, however, triggers a **compensating rollback** (see [Failure & Rollback](#failure--rollback)) before the error propagates. +**Replay safety (at-least-once delivery):** Service Provision IdP writes are **idempotent** (create-or-get by name: `create_service_role` / `create_service_scope` return the existing entity on a duplicate call). The PCE reconcile is also idempotent. If the pod crashes between Service Provision completing and the PCE call, NATS redelivers and the full pipeline re-runs safely to convergence — the success re-run stays idempotent. A build **failure**, however, triggers a **compensating rollback** and a PCE **quarantine** (see [Failure & Rollback](#failure--rollback)) before the error propagates. --- ## Failure & Rollback -The Orchestrator wraps the `provision → ServicePolicyBuilder.build` pipeline in a compensating rollback. On any of `PolicyConflictError`, `PolicyRulesBuilderError`, `LLMAccessError`, or `UnparseableLLMResponseError`, the Orchestrator rolls back what Service Provision created, logs the rollback actions (info), and **re-raises** the original error. The Controller then maps the re-raised error to its HTTP status (see [aiac-agent.md → Error Handling](../aiac-agent.md#error-handling)). +The Orchestrator wraps the `provision → ServicePolicyBuilder.build` pipeline in a compensating rollback. On any of `PolicyConflictError`, `PolicyRulesBuilderError`, `LLMAccessError`, or `UnparseableLLMResponseError`, the Orchestrator does these steps in this order. The trigger is the same for agents and tools, and it fires on the first failure (also for the retryable `LLMAccessError`). -**Rollback is a full teardown of what Provision created.** The `provision_service` node now returns a **created-manifest** — exactly the roles and scopes it **created** on this run, not the ones it reused by name. For each entity in that manifest, rollback **unmaps then deletes** it (the order the IdP library requires), and unsets the client `type` attribute. It then **disables the Keycloak client** (`enabled=false`), which is the failed-service marker visible in the admin UI. Because rollback tears down only what this run added, it never removes a pre-existing entity that another service shares. The IdP teardown and disable primitives (`delete_service_role`, `delete_service_scope`, `unset_service_type`, enable/disable) are specified in [`../library-idp.md`](../library-idp.md). +1. It rolls back what Service Provision created (`_rollback`) and logs the rollback actions (info). The last rollback step disables the client. +2. It calls the PCE `quarantine(client_id, created_roles)` (the clientId resolved in step 0, not the UUID) (see [`../policy-computation-engine.md` → Quarantine (failed onboarding)](../policy-computation-engine.md#quarantine-failed-onboarding)). The quarantine deletes the SPM of the service, removes its roles from the other SPMs (also the created roles, which the rollback deleted from the IdP, so the catalog no longer lists them), replaces the CR of a failed agent with a no-rules CR (which denies every request), and re-derives the affected agents. +3. It **re-raises** the original error. -**Rollback fires on every attempt.** A permanent failure rolls back once, because the NATS consumer routes it straight to the dead-letter subject (see [aiac-agent.md → Ack contract](../aiac-agent.md#ack-contract)). A retryable `LLMAccessError` re-provisions idempotently and rolls back again on each NATS redelivery. This repeated provision-then-rollback is accepted. +The disable comes before the quarantine, so no run after the teardown sees the service as enabled. The quarantine runs even when the rollback raises, so a failed rollback never leaves a first onboarding fail-open. If the rollback or the quarantine fails, that error propagates instead of the build error (the build error stays on its `__context__`). This is deliberate: a compensation failure is not a permanent error, so the NATS consumer redelivers and the next run tries the compensation again. If the build error won, a permanent build error would stop redelivery and leave a half-compensated service fail-open. The Orchestrator never imports `aiac.pdp.policy.library`: the PCE owns the PDP. The Controller then maps the re-raised error to its HTTP status (see [aiac-agent.md → Error Handling](../aiac-agent.md#error-handling)). -**The success path re-enables the client — but only after the apply.** The Orchestrator does **not** re-enable the client itself. The caller (Controller route or NATS consumer) re-enables it through `reenable_service(service_id)` **after** its `compute_and_apply` (PCE) call succeeds. `reenable_service` sets the client `enabled=true` (idempotent), which clears a failed-disable left by a prior attempt. The re-enable is deliberately post-apply: if `compute_and_apply` fails, the caller never reaches `reenable_service`, so the client stays disabled (the failed-service marker) instead of being left enabled with no applied policy. +**Rollback is a full teardown of what Provision created.** The `provision_service` node now returns a **created-manifest** — exactly the roles and scopes it **created** on this run, not the ones it reused by name. For each entity in that manifest, rollback **unmaps then deletes** it (the order the IdP library requires). The rollback **keeps the client `type` attribute**: it removes only what this run created, and this run did not create the type. It then **disables the Keycloak client** (`enabled=false`) as the last step. The disabled client is the failed-service marker visible in the admin UI. Because rollback tears down only what this run added, it never removes a pre-existing entity that another service shares. The IdP teardown and disable primitives (`delete_service_role`, `delete_service_scope`, enable/disable) are specified in [`../library-idp.md`](../library-idp.md). + +**Rollback fires on every attempt.** A permanent failure rolls back once, because the NATS consumer routes it straight to the dead-letter subject (see [aiac-agent.md → Ack contract](../aiac-agent.md#ack-contract)). A retryable `LLMAccessError` re-provisions idempotently and rolls back (and quarantines) again on each NATS redelivery. This repeated provision-then-rollback is accepted. + +**The success path re-enables the client — but only after the apply.** The Orchestrator does **not** re-enable the client itself. The caller (Controller route or NATS consumer) calls `compute_and_apply(rules, override, focus_service=client_id)` (the clientId from `onboard_service`) and then re-enables the client through `reenable_service(service_id)` (an IdP call, by the UUID) **after** that call succeeds. `reenable_service` sets the client `enabled=true` (idempotent), which clears a failed-disable left by a prior attempt. The re-enable is deliberately post-apply: if `compute_and_apply` fails, the caller never reaches `reenable_service`, so the client stays disabled (the failed-service marker) instead of being left enabled with no applied policy. + +**Only a successful re-onboarding lifts a quarantine.** The PRB rebuilds the rules. The routing guard of the PCE keeps them, because the service is the `focus_service` (its client is still disabled at this time). `compute_and_apply` writes the real CR over the no-rules CR (same SSA field manager). Then `reenable_service` re-enables the client. The UC2 rebuild route is a stub, so it does not lift a quarantine. + +**Known limit — a quarantined tool cannot be lifted today.** Tool Provision mints its discovery token with the tool client's own secret (`client_credentials`, `GET /services/{id}/discovery-token`). A disabled client cannot get a token, so the re-onboarding of a quarantined tool fails in Provision (a retryable `HTTPException(502)`) before the build. The lift works for an agent only. Lifting a tool needs a decision (for example: mint the discovery token without the tool client, or re-enable the client before Provision). **Rollback is UC1-only.** UC2 (Policy Update) and UC3 (Role Update) provision nothing, so they have nothing to tear down and never disable a client. @@ -150,6 +161,12 @@ START → classify_service → [analyze_agent | analyze_tool] → provision_serv naming the service id) if minting fails. 3. Call `tools/list` (HTTP POST, MCP protocol) on the resolved endpoint, sending the minted token as `Authorization: Bearer `. + **Wait for the endpoint.** The operator registers the tool's client (which fires the onboarding + event) while it still rolls the tool pod onto the AuthBridge-injected template, so the endpoint can + be not ready for some seconds. Discovery tries `tools/list` again every 3 s while the endpoint is not + ready — a connection error (refused, reset, connect timeout) or a `502`/`503`/`504` from the sidecar — + until `AIAC_MCP_DISCOVERY_READY_TIMEOUT` ends (default 120 s, well below the NATS `ACK_WAIT` of 600 s). + A `4xx` or a read timeout fails at once. 4. Produce `ServiceProvision`: - `roles`: `[]` (tools do not initiate further calls) - `scopes`: `[ScopeDefinition(name=f"{workload_name}.{tool.name}", description=tool.description) for tool in manifest.tools]` @@ -224,8 +241,9 @@ class ServiceProvision(BaseModel): **Terminology — own vs candidate (used throughout this section):** - **Own roles / own scopes** — the focus service's `aiac.managed` roles/scopes, found on the `Service` object returned by `get_services()` (matched by `id == service_id`, the internal client UUID). These are exactly the entities Service Provision wrote. -- **Candidate roles** — every role eligible to be mapped onto an own scope: other services' `aiac.managed` roles (`kind=Agent`) plus membership-derived user roles (`kind=User`; realm roles held by at least one user, composite-expanded, and not owned by any service). Never includes the focus service's own roles. -- **Other scopes** — every other service's `aiac.managed` scope (`scope.serviceId != service_id`). Never includes the focus service's own scopes. +- **Candidate roles** — every role eligible to be mapped onto an own scope: other **enabled** services' `aiac.managed` roles (`kind=Agent`) plus membership-derived user roles (`kind=User`; realm roles held by at least one user, composite-expanded, and not owned by any service). Never includes the focus service's own roles. +- **Other scopes** — every other **enabled** service's `aiac.managed` scope (`scope.serviceId != service_id`). Never includes the focus service's own scopes. +- **Disabled services** — a disabled service (`Service.enabled == False`, the failed-service marker of the rollback) is a quarantined service. It gives no candidate role and no other scope, so no build grants access to or from it. The focus service is the exception: it resolves while it is disabled, because a re-onboarding builds its rules before `reenable_service` runs. A disabled service still *owns* its roles, so a user who holds one of them does not make that role a user-kind candidate. **Self-mapping invariant (must hold):** the PRB must **never** be handed an *(own role, own scope)* pair — a service's own role must never be mapped to its own scope. Onboarding only ever grants **cross-service** access: *who else* may call this service, and (agents only) *what else* this service may call. A service's own role reaching its own scope is not something onboarding needs to author (that access is intrinsic and out of scope here) and would pollute the policy set. The Service Policy Builder sub-agent guarantees the invariant **by construction** through two complementary guards: @@ -243,9 +261,9 @@ Neither guard alone is sufficient — ownership-based exclusion keeps own entiti 3. Resolve the focus service: `focus = next((s for s in services if s.id == service_id), None)` (matching on `id`, the internal client UUID carried by the route/`Trigger.entity_id` — **not** `serviceId`/clientId, which may be a slash-bearing SPIFFE URI); if `focus is None`, raise a clear `404` rather than letting `next(...)` raise `StopIteration`. 4. Compute candidate sets, all by ownership: - **own roles/scopes** — `focus.roles`/`focus.scopes` filtered to `aiac.managed` (drops Keycloak's built-in default client scopes, e.g. `profile`, which are stamped with this service's `serviceId` but are not `aiac.managed`). - - **other-agent roles** — `aiac.managed` roles from every *other* service's `roles` (`kind=Agent`, ownership-excluded by `serviceId != focus.serviceId`). - - **user roles** — realm roles linked to at least one subject (via `subjects[*].roles`, composite-expanded through `flatten_role`) and not owned by any service (`role.id` not in the union of every service's role ids). These carry `kind=User`. - - **other scopes** — `aiac.managed` scopes from `all_scopes` with `serviceId != focus.serviceId`. + - **other-agent roles** — `aiac.managed` roles from every *other* **enabled** service's `roles` (`kind=Agent`, ownership-excluded by `serviceId != focus.serviceId`; a disabled service is skipped, see the terminology above). + - **user roles** — realm roles linked to at least one subject (via `subjects[*].roles`, composite-expanded through `flatten_role`) and not owned by any service (`role.id` not in the union of every service's role ids, disabled services included). These carry `kind=User`. + - **other scopes** — `aiac.managed` scopes with `serviceId != focus.serviceId`, from every other **enabled** service (a disabled service is skipped). 5. **Flatten candidate roles to their closure** before any PRB call, via the shared `flatten_role` helper (see [Composite role flattening](#composite-role-flattening)): union of other-agent roles + user roles, deduplicated by `role.id` (`candidate_roles`); on the agent path, also expand each of the focus service's own roles. 6. Call PRB and merge. Wrap each PRB call; catch `PolicyContradictionError`, accumulate `(focal, contradictions)`, and **continue** the fan-out (accumulate-and-merge). A hard failure — `PolicyRulesBuilderError`, `LLMAccessError`, or `UnparseableLLMResponseError` — aborts the fan-out immediately and propagates, so the Orchestrator can roll back (see [Failure & Rollback](#failure--rollback)). The PRB raise semantics are specified in [`policy-rules-builder.md`](policy-rules-builder.md) (handoff 03). - **`service_type = tool`:** call `build_scope_rules(candidate_roles, scope)` for each of the focus service's own scopes. Merge results into a single `list[PolicyRule]`. diff --git a/docs/specs/components/idp-configuration-service.md b/docs/specs/components/idp-configuration-service.md index 2fcbdba7..e59f9964 100644 --- a/docs/specs/components/idp-configuration-service.md +++ b/docs/specs/components/idp-configuration-service.md @@ -158,7 +158,7 @@ Accepts JSON body `{"enabled": true | false}` (rejected with `422` otherwise). I 2. Idempotent — disabling an already-disabled client (or enabling an already-enabled one) is not an error. 3. Returns `200 OK` with the updated client JSON (re-fetched via `admin.get_client`); `502 Bad Gateway` with `{"error": ...}` on `KeycloakError`. -The UC1 compensating rollback (see the AIAC Agent UC1 spec) consumes these four operations: it deletes the roles and scopes Provision created, unsets the type, and disables the client as a failed-service marker. +The UC1 compensating rollback (see the AIAC Agent UC1 spec) consumes the two deletes and the enable/disable: it deletes the roles and scopes Provision created, and then disables the client as a failed-service marker. The rollback keeps the client type. The empty-type clear on `POST /services/{id}/type` stays in the service, but no library primitive or rollback calls it. All endpoints except `/health` require a `?realm=` query parameter specifying the Keycloak realm to operate in. Returns `422 Unprocessable Entity` if the parameter is absent. `/health` accepts no realm parameter — it calls `_get_or_create_admin(os.environ["KEYCLOAK_ADMIN_REALM"])` directly. diff --git a/docs/specs/components/library-idp.md b/docs/specs/components/library-idp.md index 1e4eab94..97965fdf 100644 --- a/docs/specs/components/library-idp.md +++ b/docs/specs/components/library-idp.md @@ -204,13 +204,12 @@ class Configuration: def set_service_type(self, service: Service, service_type: ServiceType) -> Service: ... - # Teardown + disable — consumed by the UC1 compensating rollback. The two deletes obey - # unmap-then-delete order and shared-object safety (see the note below); unset/enable reuse - # the set_service_type read-merge pattern. All four use the same _request / run_upstream - # transport (retry + backoff) as the methods above. + # Teardown + disable — the two deletes and the enable/disable are consumed by the UC1 + # compensating rollback. The two deletes obey unmap-then-delete order and shared-object safety + # (see the note below); enable/disable reuses the set_service_type read-merge pattern. All + # three use the same _request / run_upstream transport (retry + backoff) as the methods above. def delete_service_role(self, service: Service, role: Role) -> None: ... def delete_service_scope(self, service: Service, scope: Scope) -> None: ... - def unset_service_type(self, service: Service) -> Service: ... def set_service_enabled(self, service: Service, enabled: bool) -> Service: ... # Mint a bearer token, minted as the service's own client, whose `aud` contains that @@ -365,12 +364,6 @@ class Configuration: 4. Subject to shared-object safety (below): a scope that another service still references is **not** deleted — it is at most unmapped from this service. 5. Raises `RuntimeError` on non-2xx HTTP status. Returns `None`. -`unset_service_type(service: Service) -> Service`: clears the service type. -1. Issues the clear-type request against `{AIAC_PDP_CONFIG_URL}/services/{service.id}/type` (an empty/clear type), appending `?realm=`. -2. The service clears the **`client.type`** attribute with the same read-merge-`update_client` pattern `set_service_type` uses. -3. Idempotent: clearing an already-clear type is not an error. -4. Raises `RuntimeError` on non-2xx HTTP status. Returns the updated `Service` (`type` now `None`). - `set_service_enabled(service: Service, enabled: bool) -> Service`: the **writer** for the `Service.enabled` field. 1. Issues `POST {AIAC_PDP_CONFIG_URL}/services/{service.id}/enabled` with body `{"enabled": }`, appending `?realm=`. 2. The service calls `update_client(enabled=…)` on the Keycloak client. @@ -384,9 +377,11 @@ class Configuration: > that no other service still references. As defense in depth, the delete path (enforced in the IdP > Configuration Service — see `idp-configuration-service.md`) refuses to delete a role or scope that is > still mapped to another client: such an object is at most unmapped from the caller, never deleted. -> The **consumer** of these four operations is the UC1 compensating rollback (see the AIAC Agent UC1 -> spec, `aiac-agent/uc1-service-onboarding.md`), which deletes what Provision created, unsets the -> type, and disables the client as a failed-service marker. +> The **consumer** of `delete_service_role`, `delete_service_scope` and `set_service_enabled` is the +> UC1 compensating rollback (see the AIAC Agent UC1 spec, `aiac-agent/uc1-service-onboarding.md`), +> which deletes what Provision created and then disables the client as a failed-service marker. The +> rollback **keeps** the client type, because this run did not create it, so the library has no +> clear-type primitive. ### Configuration diff --git a/docs/specs/components/pdp-policy-writer-opa.md b/docs/specs/components/pdp-policy-writer-opa.md index 644a05fb..69fbae18 100644 --- a/docs/specs/components/pdp-policy-writer-opa.md +++ b/docs/specs/components/pdp-policy-writer-opa.md @@ -40,7 +40,6 @@ Complete policy definition for a single agent (service). Contains two sets of `P | Field | Type | Description | |-------|------|-------------| | `agent_id` | `str` | Service ID from the AIAC trigger event (`aiac.apply.service.{id}`) | -| `default_effect` | `RuleEffect` | How the generated Rego treats a `(role, scope)` pair **no rule mentions**: `Allow` / `Deny`. Default `Deny`. Selects which **decision block** the generators emit (see [Per-policy default effect](#per-policy-default-effect-default_effect)); every declaration map and `*_allow_ok` / `*_deny_ok` gate is emitted identically in both modes. | | `agent_roles` | `list[Role]` | Realm roles assigned to this agent. Effect-agnostic identity. | | `agent_scopes` | `list[Scope]` | Scopes this agent exposes. Effect-agnostic identity. | | `source_roles` | `dict[str, list[Role]]` | Inbound: source (calling service) **id** → roles held. Keyed by the inbound `input.identity.client_id`. **Optional** gate input — an absent `client_id`, or a platform bypass client, passes. Effect-agnostic; **includes deny-edge roles**. | @@ -58,7 +57,9 @@ Complete policy definition for a single agent (service). Contains two sets of `P **Outbound target rule semantics (deny-overrides):** this agent acting as realm role `role` may request target scope `scope` iff an allow edge grants it and no deny edge prohibits it. Grouped by role, the allow list becomes the single informational `agent_role_scopes` map, and the effective capability gate materializes into `target_allow_scopes` / `target_deny_scopes`. -**Outbound subject rule semantics (deny-overrides):** a subject holding realm role `role` (a **user** role) may reach a **tool** exposing scope `scope` iff an allow edge grants it and no deny edge prohibits it. Grouped by role, the lists become `subject_role_allow_scopes` / `subject_role_deny_scopes` (user role → tool scopes) that the **outbound** package's subject gate evaluates as `input.mcp.params.name in subject_role_allow_scopes[role]` (mirrored against `subject_role_deny_scopes`); their scope **values** are **de-prefixed** to the bare MCP tool name (Q9). This is distinct from the inbound subject rules (user → *agent* scope): the outbound subject gate answers "may this user reach the tool?", not "may this user call the agent?". +**No default effect.** `AgentPolicyModel` has no default-effect field. A `(role, scope)` pair that no rule mentions is always denied (`default allow := false` in both packages). A legacy payload that still carries `default_effect` is accepted and the field is ignored (`extra='ignore'`). + +**Outbound subject rule semantics (deny-overrides):** a subject holding realm role `role` (a **user** role) may reach a **tool** exposing scope `scope` iff an allow edge grants it and no deny edge prohibits it. Grouped by role, the lists become `subject_role_allow_scopes` / `subject_role_deny_scopes` (user role → tool scopes) that the **outbound** package's subject gate evaluates as `tool in subject_role_allow_scopes[role]` (the function `subject_allows(tool)`, mirrored by `subject_denies(tool)` against `subject_role_deny_scopes`; it is applied to `input.mcp.params.name` for `tools/call`, and to each tool of the target for the MCP session messages); their scope **values** are **de-prefixed** to the bare MCP tool name (Q9). This is distinct from the inbound subject rules (user → *agent* scope): the outbound subject gate answers "may this user reach the tool?", not "may this user call the agent?". **Note on target-map direction:** `target_allow_scopes` / `target_deny_scopes` are keyed by **target service id → scopes** (the inverse of the former `scope_targets`, which was `scope → targets`). The outbound Rego generator emits the **full** target service id as the map key and evaluates `target_allow_scopes[input.identity.service_id]` / `target_deny_scopes[input.identity.service_id]` directly — there is no inversion (see below). Only the scope **values** are de-prefixed to bare MCP tool names; the **keys** stay the full target service id (Q9). @@ -153,9 +154,10 @@ The Rego packages evaluate the `input` document the live AuthBridge OPA plugin p | `input.identity.subject` | The delegated end-user id (JWT `sub`) | inbound + outbound | | `input.identity.client_id` | The calling client — the inbound source | inbound | | `input.identity.service_id` | The downstream target audience the exchanged token was minted for — a **full SPIFFE id** | outbound | -| `input.mcp.params.name` | The **bare** invoked MCP tool name (e.g. `source-read`) | outbound | +| `input.mcp.method` | The MCP JSON-RPC method (`tools/call`, `tools/list`, `initialize`, …). The plugin always sets it (`buildMCPInput` in the cortex OPA plugin, `core/plugins/opa/plugin.go`) | outbound | +| `input.mcp.params.name` | The **bare** invoked MCP tool name (e.g. `source-read`). Only `tools/call` carries it | outbound | -On the outbound leg there is no validated JWT; the plugin synthesizes `input.identity` from the token-exchange delegation hop. A **missing** `input.mcp.params.name` (e.g. a `tools/list` discovery request, which carries no tool name) or an **absent** `input.identity.service_id` matches nothing in the maps and is therefore **denied**. +On the outbound leg there is no validated JWT; the plugin synthesizes `input.identity` from the token-exchange delegation hop. An **absent** `input.identity.service_id` matches nothing in the maps and is therefore **denied**. A request with no tool name (for example `tools/list`) is allowed only as an MCP session message (see [Outbound package](#outbound-package-authbridgeclientoutboundrequest)). The generator embeds these symbols, derived from the `AgentPolicyModel`: @@ -174,24 +176,17 @@ The generator embeds these symbols, derived from the `AgentPolicyModel`: De-prefixing (Q9) is **outbound-only**: provisioned scope names are prefixed with their owning workload (`github-tool.source-read`), but the value that arrives in `input.mcp.params.name` at runtime is the bare tool name (`source-read`), so the outbound map **values** are stripped of a leading `"."` (where `owner = identity_ref(scope.serviceId).name`). The **keys** of `target_allow_scopes` / `target_deny_scopes` stay the full target service id (they match `input.identity.service_id`). Inbound `agent_scopes` and the `*_role_allow_scopes` / `*_role_deny_scopes` maps keep their **full** names — the inbound gate compares scopes internally, never against `input.mcp.params.name`. -### Per-policy default effect (`default_effect`) - -`AgentPolicyModel.default_effect` (`Allow` / `Deny`, default `Deny`) decides how each package treats a `(role, scope)` pair that **no rule mentions**. Three states exist per pair: **explicitly allowed** (an allow rule/edge names it), **explicitly denied** (a deny rule/edge names it), and **unspecified** (no rule names it → resolves to `default_effect`). - -- `Deny` (default) reproduces today's least-privilege output **byte-for-byte**: `default allow := false` plus one incremental `allow if { … }` rule per package (the blocks shown below). -- `Allow` opens the default while explicit denies still override. - -**Only the trailing decision block branches on `default_effect`.** Every declaration map (`subject_role_allow_scopes`, `target_allow_scopes`, the informational `agent_role_scopes`, …) and every `*_allow_ok` / `*_deny_ok` gate is emitted **identically** in both modes. Under `Allow` the allow-side machinery (the allow scope maps, `subject_allow_ok` / `source_allow_ok` / `target_allow_ok`, and the inbound platform-bypass rules) is still generated but **inert** — an allowed pair and an unmentioned pair both resolve to `allow` — mirroring how `agent_role_scopes` is already emitted-but-unreferenced. +### Always DENY by default -**Why a literal flip of the `default allow :=` constant is insufficient.** In Rego, `default allow := ` supplies a value only when every other `allow` rule is undefined, and an incremental `allow if { }` rule can only push `allow` *toward* `true`. Keeping the existing `allow if { …; not …_deny_ok }` body and merely flipping the constant to `true` would leave `allow` `true` whenever that body is undefined, so the `not …_deny_ok` guard subtracts nothing and **every prohibition silently evaporates**. Overriding a permissive default therefore requires **separate** complete rules — `allow := false if { }`, one per deny gate; an assigned `false` wins over `default true` when its body holds, which is exactly deny-overrides. +Each package ends with `default allow := false` and one or more `allow if { … }` rules. A request that no rule allows is denied. There is no permissive default and there are no `allow := false` rules. Each `allow` body carries inline `not …_deny_ok` guards (deny-overrides). -**The generator assumes disjoint allow/deny per `(role, scope)` and never reconciles an overlap.** A genuine grant/deny overlap on the same pair is a real policy conflict surfaced **upstream** as HTTP 422 (the PRB raises `PolicyContradictionError`); the PCE assumes a conflict-free model. The generator therefore adds **no** logic that silently reconciles an allow-vs-deny overlap — doing so would mask a conflict that is *supposed* to surface as a 422. The `allow := false if { }` rules are **not** conflict reconciliation: (1) they give a deny precedence over the permissive default (for a pair unmentioned-by-allow, hence not an overlap), and (2) they let a deny on **one** of a subject's several roles — or on **one** of the two outbound gates — beat an allow arriving from a *different* role / the *other* gate. Each individual `(role, scope)` stays allow-XOR-deny; the denies merely co-occur within a single request. +**The generator assumes disjoint allow/deny per `(role, scope)` and never reconciles an overlap.** A genuine grant/deny overlap on the same pair is a real policy conflict surfaced **upstream** as HTTP 422 (the PRB raises `PolicyContradictionError`); the PCE assumes a conflict-free model. The generator therefore adds **no** logic that silently reconciles an allow-vs-deny overlap — doing so would mask a conflict that is *supposed* to surface as a 422. The inline `not …_deny_ok` guards are **not** conflict reconciliation: they let a deny on **one** of a subject's several roles — or on **one** of the two outbound gates — beat an allow arriving from a *different* role / the *other* gate. Each individual `(role, scope)` stays allow-XOR-deny; the denies merely co-occur within a single request. ### Inbound package: `authbridge.client.inbound.request` Evaluated by the AuthBridge OPA plugin in the **inbound pipeline** — "who may call this agent". `allow` requires `subject_allow_ok` **and** `source_allow_ok` and **neither** `subject_deny_ok` **nor** `source_deny_ok` (deny-overrides). `subject_allow_ok` passes when the subject (`input.identity.subject`) holds a role granting at least one of the agent's own `agent_scopes` via `subject_role_allow_scopes`; `subject_deny_ok` mirrors it against `subject_role_deny_scopes`. `source_allow_ok` passes when (a) there is no calling `input.identity.client_id` (pure end-user traffic), (b) the `client_id` is one of the **platform bypass clients** — `rossoctl` by default, from `PLATFORM_SOURCE_CLIENTS` (Q5); this bypass is **mandatory**, since end-user traffic carries the platform client and would otherwise be denied — or (c) that client holds a role granting an agent scope via `source_role_allow_scopes`; `source_deny_ok` mirrors it against `source_role_deny_scopes`. -The block below mirrors the current `generate_inbound_rego` output (`inbound/request.rego`) under the default `default_effect == Deny`, reproduced with light blank-line spacing for readability — every declaration map, gate, and the trailing decision block are identical to what the generator emits. (The `docs/examples/opa-team1-policy.yaml` golden fixture has been regenerated to these split gates and carries a `default_effect` annotation.) +The block below mirrors the current `generate_inbound_rego` output (`inbound/request.rego`), reproduced with light blank-line spacing for readability — every declaration map, gate, and the trailing decision block are identical to what the generator emits. ```rego package authbridge.client.inbound.request @@ -242,16 +237,6 @@ default allow := false allow if { subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok } ``` -Under `default_effect == Allow`, **only** the trailing decision block changes — every declaration map and gate above is emitted identically; the allow gates and the platform-bypass rules become inert, the package opens by default, and explicit denies still override: - -```rego -default allow := true -allow := false if { subject_deny_ok } -allow := false if { source_deny_ok } -``` - -An unmentioned subject/source (matched by no deny gate) falls through to `default allow := true`; a subject or source named by a deny edge forces `allow := false` (see [Per-policy default effect](#per-policy-default-effect-default_effect) for why this can't be a bare constant flip). - **Deny-overrides:** `allow` fires only when both allow gates pass **and** neither deny gate matches. A subject or source barred by a deny edge is rejected even when an allow edge would otherwise admit it. (An absent `input.identity.client_id` makes `source_allow_ok` true and — because `source_roles[input.identity.client_id]` is undefined — leaves `source_deny_ok` false, so an absent source still passes.) > **Security property — source-side deny reach.** The `source_allow_ok` @@ -281,9 +266,16 @@ An unmentioned subject/source (matched by no deny gate) falls through to `defaul ### Outbound package: `authbridge.client.outbound.request` -Evaluated by the AuthBridge OPA plugin in the **outbound pipeline** — "what this agent may call", **per invoked tool**. `allow` is an AND on the **same** `input.mcp.params.name`, requiring **both** allow gates to pass and **neither** deny gate to match (deny-overrides): `subject_allow_ok` (the delegated user's role admits the tool — `input.mcp.params.name in subject_role_allow_scopes[role]`, de-prefixed values) AND `target_allow_ok` (the target service, keyed by the full `input.identity.service_id`, admits the tool — `input.mcp.params.name in target_allow_scopes[input.identity.service_id]`), with `subject_deny_ok` / `target_deny_ok` mirroring them against `subject_role_deny_scopes` / `target_deny_scopes`. `agent_roles` / `agent_role_scopes` are emitted for debugging but are **not** referenced by `allow` — `target_allow_scopes[input.identity.service_id]` already *is* the per-scope capability gate. This package emits neither `agent_scopes` nor the inbound subject gate: outbound decisions never consider the agent's own audience scopes. +Evaluated by the AuthBridge OPA plugin in the **outbound pipeline** — "what this agent may call". The four outbound gates are Rego **functions over a bare tool name**: `subject_allows(tool)` (the delegated user's role admits the tool — `tool in subject_role_allow_scopes[role]`, de-prefixed values), `target_allows(tool)` (the target service, keyed by the full `input.identity.service_id`, admits the tool — `tool in target_allow_scopes[input.identity.service_id]`), and `subject_denies(tool)` / `target_denies(tool)`, which mirror them against `subject_role_deny_scopes` / `target_deny_scopes`. The named gates apply these functions to the invoked tool: `subject_allow_ok if { subject_allows(input.mcp.params.name) }`, and the same for `subject_deny_ok`, `target_allow_ok` and `target_deny_ok`. `tool_ok(tool)` is the full per-tool check: both allow functions pass and neither deny function matches (deny-overrides). + +The decision has two `allow` rules: -The block below mirrors the current `generate_outbound_rego` output (`outbound/request.rego`) under the default `default_effect == Deny`, annotated with explanatory `#` comments and spacing for readability — the maps, gates, and trailing decision block are identical to what the generator emits (which itself emits only the single `# informational/debugging only` comment). (As above, the `docs/examples/opa-team1-policy.yaml` golden fixture has been regenerated to these split gates with a `default_effect` annotation.) +- **`tools/call` — per invoked tool.** `input.mcp.method == "tools/call"` and both allow gates pass on the **same** `input.mcp.params.name` and neither deny gate matches. +- **MCP session messages.** `session_methods := {"initialize", "notifications/initialized", "ping", "tools/list"}`. These messages carry no tool name. A session message to a target is allowed iff at least one tool of that target (`some tool in target_allow_scopes[input.identity.service_id]`) passes `tool_ok(tool)` for this user. An allow that a deny vetoes gives no session. + +Every other MCP method, and a request with no method, is denied. The caller sees the whole tool list of the target (`tools/list`), but it can call only its granted tools. `agent_roles` / `agent_role_scopes` are emitted for debugging but are **not** referenced by `allow` — `target_allow_scopes[input.identity.service_id]` already *is* the per-scope capability gate. This package emits neither `agent_scopes` nor the inbound subject gate: outbound decisions never consider the agent's own audience scopes. + +The block below mirrors the current `generate_outbound_rego` output (`outbound/request.rego`), annotated with explanatory `#` comments for readability — the maps, gates, and trailing decision block are identical to what the generator emits (which itself emits only the single `# informational/debugging only` comment). ```rego package authbridge.client.outbound.request @@ -292,7 +284,7 @@ import rego.v1 agent_roles := ["github-agent.issue_operations", "github-agent.source_operations"] subject_roles := { "dev-user": ["developer"], - "test-user": ["tester"] + "test-user": ["tester"], } # The deployed github-tool (demo/assets/tools/github_tool) exposes # exactly four MCP tools — source-read, source-write, issues-read, @@ -313,37 +305,46 @@ target_allow_scopes := { "spiffe://localtest.me/ns/team1/sa/github-tool": ["source-read", "source-write", "issues-read", "issues-write"], } target_deny_scopes := {} -# user may reach the tool: holds a role granted the invoked tool (input.mcp.params.name) -subject_allow_ok if { +# the MCP messages that carry no tool name (see the session rule below) +session_methods := {"initialize", "notifications/initialized", "ping", "tools/list"} + +# user may reach the tool: holds a role granted the tool +subject_allows(tool) if { some role in subject_roles[input.identity.subject] - input.mcp.params.name in subject_role_allow_scopes[role] + tool in subject_role_allow_scopes[role] } -subject_deny_ok if { +subject_allow_ok if { subject_allows(input.mcp.params.name) } +subject_denies(tool) if { some role in subject_roles[input.identity.subject] - input.mcp.params.name in subject_role_deny_scopes[role] + tool in subject_role_deny_scopes[role] +} +subject_deny_ok if { subject_denies(input.mcp.params.name) } +# agent may reach the tool: the tool is one the target accepts (direct, per-scope) +target_allows(tool) if { + tool in target_allow_scopes[input.identity.service_id] } -# agent may reach the tool: the invoked tool is one the target accepts (direct, per-scope) -target_allow_ok if { - input.mcp.params.name in target_allow_scopes[input.identity.service_id] +target_allow_ok if { target_allows(input.mcp.params.name) } +target_denies(tool) if { + tool in target_deny_scopes[input.identity.service_id] } -target_deny_ok if { - input.mcp.params.name in target_deny_scopes[input.identity.service_id] +target_deny_ok if { target_denies(input.mcp.params.name) } +# the full per-tool check (both allow gates, no deny) +tool_ok(tool) if { + subject_allows(tool) + target_allows(tool) + not subject_denies(tool) + not target_denies(tool) } default allow := false -allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok } +# tools/call: checked per invoked tool +allow if { input.mcp.method == "tools/call"; subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok } +# session messages: allowed iff at least one tool of the target passes tool_ok +allow if { input.mcp.method in session_methods; some tool in target_allow_scopes[input.identity.service_id]; tool_ok(tool) } ``` -Under `default_effect == Allow`, **only** the trailing decision block changes — the two-gate AND is **dropped** and replaced by **deny-if-either-side**: - -```rego -default allow := true -allow := false if { subject_deny_ok } -allow := false if { target_deny_ok } -``` +**Known limit — A2A and LLM calls through the outbound proxy are denied.** The outbound package allows only a granted `tools/call` and the MCP session messages. Every other request falls to `default allow := false`. An A2A call (`input.a2a`, for example `message/send`) and an LLM call (an OpenAI-shaped chat request) carry no MCP method, so the agent's outbound OPA denies them with HTTP `403` (`policy.forbidden`, `plugin: opa`). This applies to every call that crosses the agent's outbound proxy. The demo `github-agent` sends its LLM traffic through it (`HTTP_PROXY=http://127.0.0.1:8081`, plain-HTTP `LLM_API_BASE`). Checked on the Kind cluster (handoff 11, 2026-10-01): an A2A `message/send` and an LLM `/v1/chat/completions` request from the agent container both got `403` from OPA. This handoff does not change the behavior. Until the outbound package has rules for A2A and inference traffic, wire the outbound OPA only where the agent's LLM endpoint does not cross the proxy (for example HTTPS passthrough, or `NO_PROXY`). -**Why not a negated allow-gate AND.** Today's `allow` is `subject_allow_ok AND target_allow_ok AND not (either deny)` — a conjunction correct only under `Deny`, where a pair must be affirmatively granted by *both* gates. Under `Allow` you must **not** carry that AND forward as `allow := false if { not subject_allow_ok }` / `{ not target_allow_ok }`: every unmentioned `(role, tool)` pair matches neither allow gate and would be wrongly **denied**, defeating the permissive default. With deny-if-either-side an unmentioned pair (no deny on either side) falls through to `default allow := true`, and an explicit deny on **either** the subject side or the target/capability side overrides it. - -A worked example (agent `github-agent`, users `developer`/`tester`, tool `github-tool`) is maintained alongside the tests. The `docs/examples/opa-team1-policy.yaml` mirror has been regenerated to the split ALLOW/DENY gates and annotated with the `default_effect` semantics. +A worked example (agent `github-agent`, users `developer`/`tester`, tool `github-tool`) is maintained alongside the tests. `docs/examples/opa-team1-policy.yaml` is one CR whose two packages are the exact `generate_inbound_rego` / `generate_outbound_rego` output for that demo policy; regenerate it when the generator changes. ### `AuthorizationPolicy` Custom Resource (Q6) @@ -380,6 +381,17 @@ Every upsert writes **both** tiers, each with `default allow := false`. The ship Consequently **deleting a CR is off-boarding, not lockdown**: removing an agent's CR returns that agent to the combiner's **allow-fallback**, it does *not* deny the agent. To actually block an agent while keeping it in the system, **upsert** a CR with empty maps (so `allow` stays `false`) rather than deleting it. +### Quarantined agent — the no-rules CR + +When a UC1 onboarding of an agent fails, the PCE `quarantine` replaces the agent's CR with a **no-rules CR** (see [policy-computation-engine.md → Quarantine (failed onboarding)](policy-computation-engine.md#quarantine-failed-onboarding)). The PCE writes it with `apply_agent_policy`. The model is the `_fresh_apm` shell of the PCE: the `agent_id` and empty identity maps (`agent_roles`, `agent_scopes`, `source_roles`, `subject_roles`), and no rules. The writer renders it with the same generators, so both tiers keep all gates and end with `default allow := false`. + +- **Inbound:** `subject_roles` and `agent_scopes` are empty, so `subject_allow_ok` never passes. Every inbound request is denied. This is also true for the `rossoctl` platform client: the bypass sets only `source_allow_ok`, and `allow` also needs `subject_allow_ok`. +- **Outbound:** `target_allow_scopes` and `subject_roles` are empty, so no `tools/call` passes and no target has a tool for the session rule. Every outbound request is denied. + +**Why not a delete.** The shipped combiner allows a pod that has no client CR (`client_ok if not data.authbridge.client.`, see [Q7](#both-tiers-always-emitted-delete--off-boarding-q7)). A delete would open the failed agent. A tool has no CR, so a quarantined tool gets none. Its callers lose it through their re-derived outbound packages. + +**The lift.** A successful re-onboarding writes the real CR over the no-rules CR. Both writes use the same SSA field manager (`aiac-pdp-policy-writer`), so the second write replaces the first. + --- ## Library: `aiac.pdp.policy.library.api` @@ -498,7 +510,7 @@ docker build -f src/aiac/pdp/service/policy/opa/Dockerfile \ - **Kube config at import:** `_load_kube_config()` tries `config.load_incluster_config()`, falling back to `config.load_kube_config()` (local dev). Both failing is non-fatal — the module stays importable and API calls surface as 502/503 until real config exists. A module-level `client.CustomObjectsApi` handles all CR operations. - **Code constants (never env vars):** `_GROUP = "agent.rossoctl.dev"`, `_VERSION = "v1alpha1"`, `_PLURAL = "authorizationpolicies"`, `_MANAGED_BY_LABEL = {"app.kubernetes.io/managed-by": "aiac-pdp-policy-writer"}`, `_FIELD_MANAGER = "aiac-pdp-policy-writer"` (Q8). - **`identity_ref(agent_id) -> (namespace, name)`** (in `rego.py`): SPIFFE or `/` → DNS-1123-validated `(namespace, name)`; raises `ValueError` (→ 400) when no namespace is derivable or a segment is an invalid label — no fallback. -- **`generate_inbound_rego(model, platform_clients)` / `generate_outbound_rego(model)`** (in `rego.py`): render the two fixed-package strings under the ALLOW/DENY model. The inbound generator emits `subject_roles` / `source_roles` (effect-agnostic) plus the grouped `subject_role_allow_scopes` / `subject_role_deny_scopes` (from `inbound_subject_{allow,deny}_rules`) and `source_role_allow_scopes` / `source_role_deny_scopes` (from `inbound_source_{allow,deny}_rules`), one `source_allow_ok` bypass rule per `platform_clients` entry (plus the no-`client_id` and role-based rules), and the mirrored `subject_deny_ok` / `source_deny_ok` gates; `allow` applies deny-overrides. The outbound generator emits `subject_role_allow_scopes` / `subject_role_deny_scopes` (from `outbound_subject_{allow,deny}_rules`), the single informational `agent_role_scopes` (from `outbound_target_allow_rules`), and `target_allow_scopes` / `target_deny_scopes`, de-prefixing its map values; `allow` is a per-scope AND with deny-overrides. Both generators branch on `model.default_effect`: `Deny` (default) emits today's `default allow := false` + single `allow if { … }` block **byte-for-byte**; `Allow` emits `default allow := true` + one `allow := false if { }` rule per deny gate (`subject_deny_ok` / `source_deny_ok` inbound; `subject_deny_ok` / `target_deny_ok` outbound). Only the decision block differs — all declaration maps and `*_allow_ok` / `*_deny_ok` gates are emitted identically in both modes, and the generator never reconciles an allow-vs-deny overlap (a genuine overlap is an upstream 422; see [Per-policy default effect](#per-policy-default-effect-default_effect)). +- **`generate_inbound_rego(model, platform_clients)` / `generate_outbound_rego(model)`** (in `rego.py`): render the two fixed-package strings under the ALLOW/DENY model (always DENY by default). The inbound generator emits `subject_roles` / `source_roles` (effect-agnostic) plus the grouped `subject_role_allow_scopes` / `subject_role_deny_scopes` (from `inbound_subject_{allow,deny}_rules`) and `source_role_allow_scopes` / `source_role_deny_scopes` (from `inbound_source_{allow,deny}_rules`), one `source_allow_ok` bypass rule per `platform_clients` entry (plus the no-`client_id` and role-based rules), and the mirrored `subject_deny_ok` / `source_deny_ok` gates; `allow` applies deny-overrides. The outbound generator emits `subject_role_allow_scopes` / `subject_role_deny_scopes` (from `outbound_subject_{allow,deny}_rules`), the single informational `agent_role_scopes` (from `outbound_target_allow_rules`), and `target_allow_scopes` / `target_deny_scopes`, de-prefixing its map values; the four outbound gates are functions over a bare tool name (`subject_allows` / `subject_denies` / `target_allows` / `target_denies`), with `tool_ok(tool)` and `session_methods`; `allow` is a per-tool AND with deny-overrides for `tools/call`, plus the MCP session rule. Both generators emit `default allow := false` and `allow if { … }` rule(s) only — there is no default-effect branch. The generator never reconciles an allow-vs-deny overlap (a genuine overlap is an upstream 422; see [Always DENY by default](#always-deny-by-default)). > **Rollout impact.** These identifier renames are symmetric with **no alias / no back-compat**. All generated `.rego` **golden fixtures must be regenerated** to match the split gates. The demo helper `demo/use-cases/uc1-onboarding/lib/_lib.py` (which reads the `target_scopes` Rego map) must **retarget to `target_allow_scopes`**. - **`_build_cr(model)`:** assemble the CR body — `metadata.name`/`.namespace` from `identity_ref`, the managed-by label, `spec.scope: client`, `spec.clientID` = the display name, and `policies[]` = the two rendered packages. Raises `ValueError` (via `identity_ref`) on a malformed `agent_id`. diff --git a/docs/specs/components/policy-computation-engine.md b/docs/specs/components/policy-computation-engine.md index d938732a..24962f68 100644 --- a/docs/specs/components/policy-computation-engine.md +++ b/docs/specs/components/policy-computation-engine.md @@ -23,7 +23,7 @@ A **two-layer** model (see the policy-model component spec, handoff 01): - **`ServicePolicyModel` (SPM)** — one per service, **persistent**, the **source of truth**. It carries the service's own identity (`owned_roles` / `owned_scopes` / `service_type`) and its inbound edges — split by effect into `inbound_allow_rules` + `inbound_deny_rules`: every `(role → scope)` rule whose `scope` this service owns, routed to the allow or deny list by `rule.effect`. `UR→TS` lives durably on `SPM(T)`. -**Two-sided rules (ALLOW / DENY).** Rules carry a `RuleEffect` (`Allow` / `Deny`; see the policy-model spec, handoff 01). The PCE treats effect as a routing/derivation dimension throughout: routing files each rule into the owning SPM's allow or deny list; `override`, reconcile, and `decommission` operate on **both** lists; and derivation classifies each inbound edge by `role.kind` **and** `effect` into the matching APM bucket while still registering deny-edge roles into the effect-agnostic identity maps. Under the no-conflict assumption the PCE applies **no** precedence logic — the two lists are carried through independently and deny-overrides is enforced downstream in generated Rego. The PCE also stamps each derived APM's `default_effect` (default `DENY`), the per-policy switch between least-privilege and permissive-by-default deny-overrides — see [Per-policy default effect](#per-policy-default-effect-threading-default_effect). +**Two-sided rules (ALLOW / DENY).** Rules carry a `RuleEffect` (`Allow` / `Deny`; see the policy-model spec, handoff 01). The PCE treats effect as a routing/derivation dimension throughout: routing files each rule into the owning SPM's allow or deny list; `override`, reconcile, and `decommission` operate on **both** lists; and derivation classifies each inbound edge by `role.kind` **and** `effect` into the matching APM bucket while still registering deny-edge roles into the effect-agnostic identity maps. Under the no-conflict assumption the PCE applies **no** precedence logic — the two lists are carried through independently and deny-overrides is enforced downstream in generated Rego. There is no default effect: the deployed Rego always denies a `(role, scope)` pair that no rule mentions (always DENY). - **`AgentPolicyModel` (APM)** — **derived on demand** from the relevant SPMs and **partial-upserted** to the PDP. Never persisted as source of truth. `compute_and_apply` routes each incoming rule to the effect-appropriate list of `SPM(scope.serviceId)` (`inbound_allow_rules` / `inbound_deny_rules`), persists the changed SPMs, computes the set of **affected agents** from the batch, re-derives each affected agent's APM **entirely from SPMs (zero IdP)**, and partial-upserts them to the PDP in a single `apply_policy` call. @@ -54,7 +54,8 @@ These AIAC invariants (from the policy-model spec, handoff 01) are relied on by 6. As the Policy Computation Engine, I want to partial-upsert only the affected agents' packages to the PDP, so unaffected agents are left untouched. 7. As a developer, I want exceptions from the computation logged **and re-raised**, so a failed IdP / store / PDP interaction surfaces to the caller (the Controller returns HTTP 500; a NATS consumer nacks → at-least-once redelivery) instead of being silently dropped while nothing is applied. 8. As a developer, I want a stable import path, so the calling convention does not change as the module grows. -9. As an onboarding caller, I want to pass a `default_effect` that lands on every derived APM this batch emits, so I can deploy a permissive-by-default (or the default least-privilege) policy without the PRB or the rule lists changing — and with `DENY` as the default so existing callers are unaffected. +9. As the Policy Computation Engine, I want one run at a time to change the SPMs, so two concurrent onboardings that route rules into one shared SPM do not lose the rules of one run. +10. As the UC1 Orchestrator, I want to quarantine a failed onboarding by its clientId, so its policy footprint is removed, a failed agent denies every request, and no later run writes rules back into that footprint. --- @@ -69,30 +70,36 @@ These AIAC invariants (from the policy-model spec, handoff 01) are relied on by ``` src/aiac/policy/ └── computation/ - ├── __init__.py # empty - └── engine.py # compute_and_apply + ├── __init__.py # exports compute_and_apply, decommission, quarantine + └── engine.py # compute_and_apply, decommission, quarantine ``` No FastAPI. No Kubernetes deployment. No container image. Imported as a library by AIAC Agent sub-UC agents. ### Public API -Two entry points — an incremental fold and an authoritative offboard: +Three entry points — an incremental fold, an authoritative offboard, and the teardown of a failed onboarding: ```python def compute_and_apply( rules: list[PolicyRule], override: bool = False, - default_effect: RuleEffect = RuleEffect.DENY, + focus_service: ClientId | None = None, # clientId of the service this onboarding builds ) -> None -def decommission(service_id: str) -> None # service_id = clientId (SPM key), not the Keycloak UUID +def decommission(service_id: ClientId) -> None +def quarantine(service_id: ClientId, deleted_roles: Iterable[Role] = ()) -> None # deleted_roles = roles the rollback deleted ``` -- **No return value; failures propagate:** on success the caller receives no return value. Both functions log exceptions and **re-raise** them — a failure in IdP resolution, Policy Model Store I/O, or PDP Policy Writer push surfaces to the caller (the Controller returns HTTP 500; a NATS consumer nacks → at-least-once redelivery) rather than being silently swallowed while nothing is applied. +**Service ids: the PCE takes only the clientId.** Keycloak gives every client two ids: the internal UUID (`Service.id`, type `ServiceUuid`) and the `clientId` (`Service.serviceId`, a SPIFFE ID, type `ClientId`; both `NewType`s of `str` in `aiac.idp.configuration.models`). Every service id the PCE takes is the clientId — the SPM key, `PolicyRule.scope.serviceId`, and OPA `input.identity.service_id`. The UUID is only for finding the service in the IdP. The asymmetry stays at the HTTP/NATS boundary: an onboarding comes in with a UUID (`/apply/service/{uuid}`, `aiac.apply.service.`), and the UC1 Orchestrator resolves the clientId once, before Provision, while the client still exists; an offboard comes in with the clientId, because after the client is deleted UUID→clientId resolution is impossible. + +- **No return value; failures propagate:** on success the caller receives no return value. All three functions log exceptions and **re-raise** them — a failure in IdP resolution, Policy Model Store I/O, or PDP Policy Writer push surfaces to the caller (the Controller returns HTTP 500; a NATS consumer nacks → at-least-once redelivery) rather than being silently swallowed while nothing is applied. - **`override`:** selects the merge mode (see [Merge Semantics](#merge-semantics)). `False` (default) appends additively at the SPM layer; `True` authoritatively replaces every input role's mappings **across all SPMs** (role-level revocation). Set by the caller (the Controller) from the producing UC's choice — UC1 = `False`, UC3 = `True`, UC2 Rebuild = `True`, UC2 Build = TBD. -- **`default_effect`:** the per-policy default stamped onto every derived APM this batch emits (see [Per-policy default effect](#per-policy-default-effect-threading-default_effect)). `RuleEffect.DENY` (default) reproduces today's least-privilege Rego byte-for-byte; a caller opts into a permissive-by-default policy by passing `RuleEffect.ALLOW`. The default keeps all existing call sites (the four `/apply/*` Controller routes and the NATS consumer) compiling and behaving unchanged. +- **`focus_service`:** the clientId (`Service.serviceId`) of the service that this onboarding builds — not its Keycloak UUID. The [routing guard](#routing-guard-disabled-services) does not drop the rules of this service while its client is disabled. The onboarding route (`POST /apply/service/{uuid}`) and the NATS consumer (`aiac.apply.service.`) pass the clientId that `onboard_service` returns. Other callers pass nothing. +- **No default effect:** there is no default-effect parameter. A `(role, scope)` pair that no rule mentions is always DENY. - **`decommission`:** the authoritative service **offboard** — tears down a decommissioned service's entire policy footprint (see [Decommission (service offboard)](#decommission-service-offboard)). Keyed by the **clientId (SPM key)**, since an offboarded client is gone from `get_services()` and its UUID can no longer be resolved. -- Import path: `from aiac.policy.computation.engine import compute_and_apply, decommission` +- **`quarantine`:** the UC1 failure-path teardown of a failed onboarding (see [Quarantine (failed onboarding)](#quarantine-failed-onboarding)). Keyed by the **clientId (SPM key)**, as `decommission` is. The failed service stays in the catalog (disabled). +- **Serialization:** all three functions hold one PCE lock for their whole body (see [Serialization (the PCE lock)](#serialization-the-pce-lock)). +- Import path: `from aiac.policy.computation import compute_and_apply, decommission, quarantine` ### Rule-builder input contract (upstream) @@ -103,13 +110,15 @@ Each incoming `PolicyRule` arrives with `scope.serviceId`, `role.kind`, and `rol ### Algorithm -Given `rules: list[PolicyRule]` and an `override` flag, `compute_and_apply` executes: +Given `rules: list[PolicyRule]`, an `override` flag and an optional `focus_service`, `compute_and_apply` executes these steps under the [PCE lock](#serialization-the-pce-lock): 1. **Catalog once.** Call `Configuration.get_services()` — the **only** runtime IdP read. For every service touched this batch, seed its SPM's `service_type` / `owned_roles` / `owned_scopes` from its catalog `Service` record, keeping only **AIAC-provisioned** entities (the `aiac.managed` marker on `Role.aiac_managed` / `Scope.aiac_managed`; Keycloak built-ins — the default client scopes `profile`, `email`, `roles`, `web-origins`, `acr`, `basic`, `service_account`, and the `default-roles-` composite — are dropped). This seed drives **P2** identity and the **P4** "only agents modelled" rule. It is a seed, **not** a per-derive dependency. +1b. **Routing guard.** Drop every rule that touches a disabled service, except the focus service (see [Routing guard (disabled services)](#routing-guard-disabled-services)). + 2. **Route each rule to its owning service's SPM, by effect.** For each rule `(role, scope, effect)`, append it to `SPM(scope.serviceId).inbound_allow_rules` (if `effect == Allow`) or `.inbound_deny_rules` (if `effect == Deny`) — fetch the SPM via `get_service_policy_by_scope` / `get_service_policy`. **Append-dedup by `role.id + scope.id + effect`.** There is **no** write-time 3-way P5b classification (the old (user,agent-scope)/(user,tool-scope)/(agent,tool-scope) routing table is gone) — a rule always lands on the effect-appropriate list of the SPM that owns its scope, whatever the kinds. -3. **Override (`override=True`) — role-level revocation.** *Before* appending, purge the **distinct input-role set** from **both** lists (`inbound_allow_rules` + `inbound_deny_rules`) of **every** SPM that contains any of them: one up-front pass using `get_service_policies_by_role` per distinct input role, removing every stored rule (allow or deny) whose `role.id` matches. Then append the fresh rules. Purging once, up-front, ensures a role shared across the input is not wiped after being added. The old algorithm's `target_scopes` reconciliation is **deleted** — the target maps (`target_allow_scopes` / `target_deny_scopes`) are derived, never-stored quantities. +3. **Override (`override=True`) — role-level revocation.** *Before* appending, purge the **distinct input-role set** (taken from the input **before** the routing guard, so a role whose every new rule the guard drops still loses its old grants) from **both** lists (`inbound_allow_rules` + `inbound_deny_rules`) of **every** SPM that contains any of them: one up-front pass using `get_service_policies_by_role` per distinct input role, removing every stored rule (allow or deny) whose `role.id` matches. Then append the fresh rules. Purging once, up-front, ensures a role shared across the input is not wiped after being added. The old algorithm's `target_scopes` reconciliation is **deleted** — the target maps (`target_allow_scopes` / `target_deny_scopes`) are derived, never-stored quantities. 3b. **Reconcile (drift GC) — after routing/override, before persist.** Prune each **touched** SPM against the step-1 `get_services()` catalog (no additional IdP read) so drift cannot accumulate across re-onboarding. Runs under **both** merge modes and is order-independent (drops only edges whose entity no longer exists). See [Reconcile (drift GC)](#reconcile-drift-gc) under Merge Semantics for the keep rules. @@ -121,14 +130,14 @@ Given `rules: list[PolicyRule]` and an `override` flag, `compute_and_apply` exec - if `X` is an **Agent**, `X` is affected (its inbound changed); **and** - every agent **targeting** `s` is affected — namely the owners (`actorIds`) of the **Agent-kind** inbound rules on `SPM(X)` whose scope is `s`. -6. **Derive** each affected agent's APM (see below), collect them into one `PolicyModel`, and **partial-upsert** via `aiac.pdp.policy.library.apply_policy` **exactly once**. Exceptions are logged and re-raised (they propagate to the caller). **Tools get an SPM but no APM** (P4). +6. **Derive** each affected **live** agent's APM (in the catalog and not disabled; the focus service counts as live) (see below), collect them into one `PolicyModel`, and **partial-upsert** via `aiac.pdp.policy.library.apply_policy` **exactly once**. Exceptions are logged and re-raised (they propagate to the caller). **Tools get an SPM but no APM** (P4). ### Derivation of `APM(A)` — 100% from SPMs, zero IdP Let `R_A = SPM(A).owned_roles` (A's client roles) and `S_A = SPM(A).owned_scopes`. - **Identity (P2):** `agent_roles` ← `R_A`; `agent_scopes` ← `S_A`. -- **Default effect:** `default_effect` ← the value threaded into `_derive` (default `DENY`; see [Per-policy default effect](#per-policy-default-effect-threading-default_effect)). +- **No default effect:** the APM carries no default effect. The generated Rego always denies a pair that no rule mentions. - **Inbound:** iterate **both** of `SPM(A)`'s inbound lists. Split each edge by `role.kind` **and** `effect` into the matching APM bucket: - `User` + `Allow` → `inbound_subject_allow_rules`; `User` + `Deny` → `inbound_subject_deny_rules`; - `Agent` + `Allow` → `inbound_source_allow_rules`; `Agent` + `Deny` → `inbound_source_deny_rules`. @@ -138,22 +147,6 @@ Let `R_A = SPM(A).owned_roles` (A's client roles) and `S_A = SPM(A).owned_scopes **Relevance is directional.** An SPM contributes to `A` **iff** it *is* `SPM(A)` (contributes inbound) **or** it contains a rule whose role is one of A's **agent** roles `R_A` (contributes outbound). A merely *shared user role* never confers relevance — this is what prevents a **false outbound edge** to a target (a tool or another agent) `A` does not actually target. This is a **derivation-layer** relevance rule: it does **not** imply the outbound user gate is empty. When the agent holds a per-skill operator role that the PRB maps (by capability-match) to a target's scope, the agent *does* target that callee, and the nested derivation then surfaces the shared-user edges. -### Per-policy default effect (threading `default_effect`) - -`AgentPolicyModel.default_effect` (policy-model spec, handoff 01) decides how the generated Rego treats a `(role, scope)` pair that **no rule mentions** — `DENY` = today's least-privilege default, `ALLOW` = permissive default with deny-overrides preserved. Because the APM is a **pure derived projection** rebuilt on every relevant recompute — never read back from a store — the value must be **produced by the PCE at derive time**; it cannot be stored on the APM and recovered later. - -The **minimal** design threads one optional parameter, default `DENY`, without touching the PRB: - -1. **`compute_and_apply(rules, override=False, default_effect=RuleEffect.DENY)`** takes the parameter. The `DENY` default keeps the four Controller `/apply/*` call sites and the NATS consumer compiling and behaving unchanged. -2. **`_run(rules, override, default_effect)`** receives it and passes it into each `_derive(...)` call. -3. **`_derive(agent_id, spm, default_effect)`** sets `apm.default_effect = default_effect` on the APM it builds (either by giving `_fresh_apm` the parameter or by assigning on the returned APM), so **every** APM this batch emits carries the value. - -A caller **requests `ALLOW`** by forwarding it from the onboarding entry (`onboard_service` → `compute_and_apply`), derived from the onboarding input. The request surface stays tiny: a single optional argument that defaults to `DENY` on every path that does not explicitly opt in. - -> **Caveat — not durable.** With the parameter-only path the value is **not persisted**. A later, *unrelated* recompute that re-derives this agent (another service onboarding, a role update) rebuilds the APM with the default `DENY` unless that call also passes `ALLOW`. If `default_effect` must **survive independent re-derivation**, persist it on the **`ServicePolicyModel`** instead (add `default_effect: RuleEffect = RuleEffect.DENY` to SPM, seed it from onboarding input when the SPM is created/updated, and have `_derive` copy `SPM(agent_id).default_effect` onto the APM). That durable origin is the only one that reproduces across recomputes; adopt it only if durability is a stated requirement. - -The **PRB is untouched** — it never sets `default_effect`. Whichever origin is chosen, the default is `DENY` end-to-end and the value lands on every derived APM. - ### P2 / P4 / P5b reconciliation - **P2 (identity embed):** copy `owned_roles` / `owned_scopes` from `SPM(A)` onto the APM's `agent_roles` / `agent_scopes`. AIAC-managed filter applied at catalog-seed time. Without the embed both generated gates would deny-all (inbound `subject_allow_ok` needs a non-empty `agent_scopes`; outbound `target_allow_ok` needs a non-empty `agent_roles`). @@ -203,21 +196,64 @@ The prune runs over **both** `inbound_allow_rules` and `inbound_deny_rules` — Reconcile is passive and catalog-anchored: it prunes only **touched** SPMs and skips any whose owner is absent from `get_services()`. That leaves the **onboard→offboard** drift species uncovered — once a service `X` is decommissioned (its Keycloak client + roles/scopes deleted), `X` is gone from the catalog forever, so (1) `SPM(X)`'s own inbound edges linger; (2) `X`'s **outbound footprint** (`X_role → other_scope` edges on *other* SPMs) is never pruned; (3) if `X` was an agent, its **APM/Rego stays in the PDP**. `decommission(service_id)` is the **authoritative** teardown for exactly this — it acts on an explicit offboard signal, not the catalog-miss guard. -**Keyed by the clientId, not the UUID.** An offboarded client is gone from `get_services()`, so UUID→clientId resolution is impossible; the offboard contract carries the clientId (`Service.serviceId`, the SPM key) directly. This is the documented asymmetry with onboard's `/apply/service/{uuid}`. +**Keyed by the clientId, not the UUID.** An offboarded client is gone from `get_services()`, so UUID→clientId resolution is impossible; the offboard contract carries the clientId (`Service.serviceId`, the SPM key) directly. The asymmetry with onboard's `/apply/service/{uuid}` is only at the boundary: the PCE takes the clientId in both cases (see [Public API](#public-api)). Steps: 1. **Catalog once** (`get_services()` — the same single allowed IdP read; `X` is absent, used only to seed/classify the still-live agents re-derived in step 8). 2. **Load `SPM(X)`.** **Content guard:** a 404 fresh-empty SPM (never onboarded / already removed) is a **no-op** — no spurious PDP delete. 3. **Targeters** — agents whose *outbound* loses `X`: the `actorIds` of every **Agent**-kind inbound edge on `SPM(X)`, scanning **both** `inbound_allow_rules` and `inbound_deny_rules` (they held `their_role → X_scope` on `SPM(X)`, deleted in step 5). -4. **Purge `X`'s outbound footprint.** For each `r ∈ SPM(X).owned_roles`, find the SPMs referencing it via `get_service_policies_by_role(r)`; on each such SPM `B` (skip `X`), drop edges where `edge.role.id == r.id` from **both** lists; mark `B` changed and, if `B` is an agent, affected (its inbound `source_roles[X]` vanished). +4. **Purge `X`'s outbound footprint.** For each `r ∈ SPM(X).owned_roles` that no other catalog service holds, find the SPMs referencing it via `get_service_policies_by_role(r)`; on each such SPM `B` (skip `X`), drop edges where `edge.role.id == r.id` from **both** lists; mark `B` changed and, if `B` is an agent, affected (its inbound `source_roles[X]` vanished). **Shared role:** an `aiac.managed` realm role can be on the service accounts of several services (a role reused by name). The edges are keyed by `role.id`, so a purge of such a role would also remove the grants of the other services. The purge skips it. `X` stays denied: an offboarded client cannot authenticate, and a quarantined agent has the no-rules CR. 5. **Delete `SPM(X)`** (`delete_service_policy`) — removes every user→X and agent→X inbound edge at once — and evict it from the SPM cache so re-derive can't resurrect it. 6. **Persist** each changed (footprint-purged) SPM (`apply_service_policy`). 7. **Delete `APM(X)`** (`delete_agent_policy`) iff `SPM(X).service_type == Agent` (tools have an SPM but no APM). -8. **Re-derive** `affected = (targeters ∪ purged-agent-owners) − {X}`, filtered to agents; `apply_policy(PolicyModel(agents=…))` **once** if non-empty. Derivation is reused unchanged — it reads the freshly-persisted, `X`-deleted store, so the outbound rule lists / `target_allow_scopes` / `target_deny_scopes` / `source_roles` referencing `X` drop automatically. +8. **Re-derive** `affected = (targeters ∪ purged-agent-owners) − {X}`, filtered to live agents (in the catalog and not disabled — a stored SPM of a deleted agent must not bring its CR back, and a quarantined agent keeps its no-rules CR); `apply_policy(PolicyModel(agents=…))` **once** if non-empty. Derivation is reused unchanged — it reads the freshly-persisted, `X`-deleted store, so the outbound rule lists / `target_allow_scopes` / `target_deny_scopes` / `source_roles` referencing `X` drop automatically. **Invariants preserved:** still exactly one IdP read (`get_services()`); still a per-agent partial upsert. **Not covered** (follow-ups): NATS `aiac.apply.offboard.{id}` consumer wiring; dropped-target GC where the source service survives (via `override=True` re-onboard); batch offboard. +#### Quarantine (failed onboarding) + +`quarantine(service_id, deleted_roles)` is the UC1 failure-path counterpart of `decommission`. The UC1 Orchestrator calls it after the compensating rollback and before it re-raises the build error (see [`aiac-agent/uc1-service-onboarding.md` → Failure & Rollback](aiac-agent/uc1-service-onboarding.md#failure--rollback)). The Orchestrator never calls the PDP library itself. The PCE owns the PDP. + +**Keyed by the clientId, not the UUID.** Like `decommission`, `quarantine` takes the clientId (the SPM key). The UC1 Orchestrator resolves it from the onboarding's UUID once, before Provision. The rollback disables the client. It does not delete it. So the failed service `X` is still in the catalog. + +Steps (under the PCE lock): + +1. **Catalog once** (`get_services()`). A `service_id` that is not a catalog key (for example a UUID passed by mistake) is a logged no-op. +2. **Targeters** — the agents that targeted `X` (the `actorIds` of every Agent-kind inbound edge on `SPM(X)`, allow and deny). +3. **Remove `X`'s roles from the other SPMs** — the same purge as decommission step 4. `X`'s SPM is seeded from the catalog, so this removes the roles that `X` still has. The catalog does not list the roles that the rollback deleted, so the Orchestrator passes them in `deleted_roles` (the run's created-manifest), and this step removes their edges too. A role that another service also holds is skipped (see decommission step 4). Without this, a grant that a concurrent onboarding stored for a deleted role (for example `X_role → B_scope` on `SPM(B)`) would keep allowing `X` in `B`'s inbound policy until a later run [reconciles](#reconcile-drift-gc) `SPM(B)`. +4. **Delete `SPM(X)`** and persist each changed SPM. +5. **Agent: replace its CR with a no-rules CR.** For an agent, write the `_fresh_apm` shell (identity maps only, no rules) through `apply_agent_policy`. Under always-DENY this CR denies every request. This is **not** a delete, on purpose: the bundle-service combiner allows a pod that has no client CR, so a delete would open the agent (see [`pdp-policy-writer-opa.md` → Quarantined agent — the no-rules CR](pdp-policy-writer-opa.md#quarantined-agent--the-no-rules-cr)). A tool gets no CR (a tool has none). +6. **Re-derive** the affected live agents (the targeters, and the agents whose SPMs lost `X`'s roles; `X` excluded) and apply them in one `apply_policy` call. + +Steps 2–4 and 6 are the steps that `decommission` also runs (shared helpers `_remove_footprint` and `_apply_derived`). `quarantine` is idempotent: a second call finds no SPM and no edges, and writes the same no-rules CR. + +**The lift.** Only a successful re-onboarding lifts a quarantine. Its PRB rebuilds the rules, `compute_and_apply` (with `focus_service` = `X`) writes the real CR over the no-rules CR with the same SSA field manager (`aiac-pdp-policy-writer`), and then `reenable_service` re-enables the client. The UC2 rebuild route is a stub, so it does not lift a quarantine. + +#### Routing guard (disabled services) + +A disabled client is a failed (quarantined) service. Under the PCE lock, after the catalog read, `compute_and_apply` drops every rule that: + +- has a scope whose owner (`scope.serviceId`) is disabled, or +- has an Agent-kind role that belongs to a disabled service (`role.actorIds`). + +The focus service is the exception. A re-onboarding applies while its client is still disabled, because `reenable_service` runs after the apply. So the rules of the focus service are kept. Without a `focus_service`, the guard drops every rule that touches a disabled service. + +**Deleted services.** A service that is absent from the catalog is deleted (its client was removed, for example offboarded while its onboarding was still building). The guard also drops every rule whose scope owner, or the owner of whose Agent-kind role, is absent from the catalog. There is no focus exception for this case. The run also derives only live agents — in the catalog and not disabled (except the focus service). A disabled agent keeps its no-rules CR. An agent must be in the catalog because the store returns a placeholder SPM typed `Agent` on a 404, so without this check a late onboarding of a deleted tool wrote a CR for it, and a run that touched the stored SPM of a deleted agent wrote its CR again. Removing a deleted service's footprint is `decommission`'s job. + +The guard prevents a build that started before a quarantine or an offboard from writing its rules back into the removed footprint. The focal resolver applies the disabled-service rule on the build side (see [`aiac-agent/uc1-service-onboarding.md` → Service Policy Builder](aiac-agent/uc1-service-onboarding.md#sub-agent-service-policy-builder)). + +### Serialization (the PCE lock) + +Each public operation reads SPMs, changes them, and writes them back (a read-modify-write). The store has no versions, and its write lock protects one write, not a read-modify-write. Thus two onboardings that route rules into one shared SPM (for example, two agents granted on one tool's scope) both read the old SPM, and the second write removes the rules of the first run. + +One module-level `threading.Lock` (`_pce_lock`) is held for the whole body of `compute_and_apply`, `decommission` and `quarantine`. The PRB (the LLM work) runs before `compute_and_apply`, outside the lock. So concurrent onboardings still do their LLM work in parallel. The part under the lock makes no LLM call. + +Known limits: + +- **One Controller replica only.** The lock serializes one process, like the Orchestrator's per-service lock. More replicas need store versions or a distributed lock. +- **Overlapping onboardings can leave a pair unjudged.** When two onboardings overlap, the resolver of each service reads the catalog before the Provision of the other service. So a pair between the two new services can stay unjudged. That pair gives no grant (fail closed). + ### Dependencies | Module | Purpose | @@ -225,7 +261,7 @@ Steps: | `aiac.policy.model` | `PolicyRule`, `RuleEffect`, `ServicePolicyModel`, `AgentPolicyModel`, `PolicyModel` | | `aiac.idp.configuration` | `Configuration.get_services` — the **only** runtime IdP read (catalog: `service_type` + own roles/scopes for the P2 seed) | | `aiac.policy.model_store.library` | `get_service_policy` / `get_service_policy_by_scope` (fetch SPM), `get_service_policies_by_role` (SPMs containing a role — override purge + outbound derivation), `apply_service_policy` (persist SPM), `delete_service_policy` (offboard) | -| `aiac.pdp.policy.library` | `apply_policy` — partial-upsert derived APMs to OPA; `delete_agent_policy` — remove an offboarded agent's APM/Rego | +| `aiac.pdp.policy.library` | `apply_policy` — partial-upsert derived APMs to OPA; `delete_agent_policy` — remove an offboarded agent's APM/Rego; `apply_agent_policy` — write the no-rules CR of a quarantined agent | Note: the PCE no longer calls `get_services_by_role` / `get_services_by_scope` / `get_subjects_by_role` at routing or classification time — those facts arrive on the rules (input contract) and derivation reads SPMs. The single IdP read is `get_services()` for the identity seed. @@ -251,7 +287,7 @@ Good tests assert external behavior — what the engine writes to the Policy Mod - `aiac.idp.configuration` — mock `Configuration.get_services` (the catalog: `service_type` + each service's own roles/scopes for the P2 seed). - `aiac.policy.model_store.library` — mock `get_service_policy` / `get_service_policy_by_scope`, `get_service_policies_by_role`, `apply_service_policy`, `delete_service_policy`. -- `aiac.pdp.policy.library` — mock `apply_policy`, `delete_agent_policy`. +- `aiac.pdp.policy.library` — mock `apply_policy`, `delete_agent_policy`, `apply_agent_policy`. **Un-freeze `test/unit/policy/computation/`.** These tests were excluded (frozen imports caused collection errors). With the SPM redesign landed, un-freeze the directory so the suite runs under the default `pytest` (marker-only selection; the unit lane is untagged). @@ -275,8 +311,10 @@ Key behaviors to assert: - **Override purges both lists.** `override=True` with an input role present in a target SPM's allow **and** deny lists purges it from both before re-appending. - **Reconcile prunes both lists.** A dangling deny edge (retired scope / churned role) is GC'd exactly as a dangling allow edge; a live deny edge survives; the pass is idempotent. - **Decommission clears both lists.** Offboard tears down the target's own inbound (allow + deny) and its outbound footprint (allow + deny edges keyed by its roles on other SPMs). -- **`default_effect` threaded onto every derived APM.** `compute_and_apply(..., default_effect=RuleEffect.ALLOW)` yields derived APMs whose `default_effect == ALLOW`; omitting the argument (and every existing call site) yields `DENY`. Assert the value reaches **every** agent in the emitted `PolicyModel`, and that `decommission` re-derivations are unaffected. -- **Failures propagate.** An exception from any dependency is logged and **re-raised** (it propagates to the caller, which surfaces it — e.g. the Controller returns HTTP 500); on success `compute_and_apply` / `decommission` return `None`. +- **PCE lock.** Two concurrent runs that route rules into one shared SPM keep the rules of both runs. `compute_and_apply`, `decommission` and `quarantine` each hold the PCE lock. +- **Routing guard.** A rule whose scope owner is disabled, or whose agent role belongs to a disabled service, is dropped. The rules of the focus service are kept while it is disabled. Without a focus service, every rule that touches a disabled service is dropped. When all services are enabled, every rule is kept. Under override, a role whose every new rule the guard drops still loses its old grants. +- **Quarantine.** For an agent: `SPM(X)` is deleted, its roles are removed from the other SPMs, its CR is replaced with the no-rules CR, and the affected agents are re-derived in one call. For a tool: no CR is written, and its callers are re-derived. A second call gives the same result. An unknown UUID is a no-op. `quarantine` and `decommission` keep the grants of a role that another service also holds, and never derive an agent that is absent from the catalog or disabled. +- **Failures propagate.** An exception from any dependency is logged and **re-raised** (it propagates to the caller, which surfaces it — e.g. the Controller returns HTTP 500); on success `compute_and_apply` / `decommission` / `quarantine` return `None`. **Prior art:** `3.14-unit-tests-write-api.md` (mock boundary pattern — apply the same approach at the library import boundary here). diff --git a/docs/specs/components/policy-model.md b/docs/specs/components/policy-model.md index b661ffbd..56d2a0b2 100644 --- a/docs/specs/components/policy-model.md +++ b/docs/specs/components/policy-model.md @@ -41,7 +41,7 @@ The relationship maps (`source_roles`, `subject_roles`, `target_allow_scopes` / **Two-sided rules (ALLOW / DENY).** Every rule carries a `RuleEffect` — `Allow` or `Deny` — and both kinds are stored side by side as first-class facts in **explicitly separated** parallel lists (never one intermixed list). A DENY rule is a durable prohibition that **subtracts** from what the ALLOW rules grant, honored uniformly at every gate (inbound subject, inbound source, outbound subject, outbound target). Generated policy applies **deny-overrides**: a request is allowed only if some ALLOW gate passes **and** no DENY gate matches, so a later broad grant can no longer silently re-open a denied path. For now the model assumes **no conflict** — no `(role, scope)` is ever both ALLOW and DENY for the same subject — so there is **no precedence/tie-break logic**; DENY simply subtracts. Cross-role conflict resolution is a deliberate later concern (see [Out of Scope](#out-of-scope)). -**Per-policy default effect.** `AgentPolicyModel` carries a `default_effect: RuleEffect` (default `Deny`) that decides how the deployed Rego treats a `(role, scope)` pair that **no rule mentions**. Three states exist per pair: **explicitly ALLOWed** (an allow rule names it), **explicitly DENYed** (a deny rule names it), and **unspecified** (no rule names it → resolves to `default_effect`). `default_effect = Deny` reproduces today's least-privilege behavior exactly (`default allow := false`, granting only what an allow gate matches); `default_effect = Allow` opens the default while explicit denies still override via deny-overrides. The allow/deny rule lists and the effect-agnostic identity maps are emitted **identically** in both modes — only the generated decision block differs (see [`pdp-policy-writer-opa.md`](pdp-policy-writer-opa.md)). `default_effect` is itself effect-agnostic: it is **not** an allow/deny rule split. +**Always DENY by default.** `AgentPolicyModel` has no default-effect field. The deployed Rego always denies a `(role, scope)` pair that **no rule mentions** (`default allow := false`, granting only what an allow gate matches). A legacy payload that still carries `default_effect` is accepted and the field is ignored (`extra='ignore'`). `RuleEffect` stays: each rule is still `Allow` or `Deny` (see [`pdp-policy-writer-opa.md`](pdp-policy-writer-opa.md)). --- @@ -59,8 +59,8 @@ The relationship maps (`source_roles`, `subject_roles`, `target_allow_scopes` / 10. As a consumer, I want `PolicyRule.effect` to default to `Allow`, so that existing allow-only producers keep working without change. 11. As a consumer, I want ALLOW and DENY rule sets held in separate lists, so that a gate can evaluate each side without filtering an intermixed list by effect. 12. As the PDP Policy Writer, I want a role that appears **only** in DENY edges still registered into the effect-agnostic identity maps (`subject_roles` / `source_roles`), so that the Rego deny lookup can resolve it at request time. -13. As a policy author, I want to choose a policy's **default effect** (allow or deny for pairs no rule mentions), so that I can deploy a permissive-by-default policy that still honors explicit denies, without touching the allow/deny rule lists. -14. As a consumer, I want `AgentPolicyModel.default_effect` to default to `Deny`, so that existing constructions and serialized models keep today's least-privilege behavior unchanged. +13. As a policy author, I want a pair that no rule mentions to be always denied, so that every re-derivation of an agent gives the same least-privilege behavior. +14. As a consumer, I want a legacy `AgentPolicyModel` payload that still carries `default_effect` to load, so that old serialized models do not break deserialization (the field is ignored). --- @@ -138,7 +138,6 @@ classDiagram } class AgentPolicyModel { +agent_id : str - +default_effect : RuleEffect +agent_scopes : Scope[] +target_allow_scopes : Dict~str,Scope[]~ +target_deny_scopes : Dict~str,Scope[]~ @@ -163,7 +162,6 @@ classDiagram PolicyModel "1" *-- "0..*" AgentPolicyModel : agents AgentPolicyModel "1" o-- "0..*" PolicyRule : inbound_subject/source_allow/deny_rules · outbound_target/subject_allow/deny_rules - AgentPolicyModel --> RuleEffect : default_effect PolicyRule --> RuleEffect : effect ``` @@ -230,7 +228,6 @@ The rule lists split into **8 entity×effect lists** — {inbound subject, inbou | Field | Type | Description | |-------|------|-------------| | `agent_id` | `str` | Service ID from the AIAC trigger event (`aiac.apply.service.{id}`) | -| `default_effect` | `RuleEffect` | How the deployed Rego treats a `(role, scope)` pair that **no rule mentions**: `Allow` / `Deny`. Defaults to `Deny` (least-privilege — reproduces today's `default allow := false` byte-for-byte). Effect-agnostic — not an allow/deny rule split. See **Per-policy default effect** above and [`pdp-policy-writer-opa.md`](pdp-policy-writer-opa.md). | | `agent_roles` | `list[Role]` | Realm roles assigned to this agent. **Effect-agnostic identity.** | | `agent_scopes` | `list[Scope]` | Scopes this agent exposes. **Effect-agnostic identity.** | | `source_roles` | `dict[str, list[Role]]` | Inbound: source (calling service) **id** → roles held. **Optional** gate input — an absent source passes. **Effect-agnostic identity** (see deny-inclusion note). | @@ -248,7 +245,7 @@ The rule lists split into **8 entity×effect lists** — {inbound subject, inbou > **Effect-agnostic identity maps must include deny-edge roles.** `subject_roles` / `source_roles` (and `agent_roles` / `agent_scopes`) carry **no** allow/deny split — the split lives only in the rule lists and target maps. A role or subject that appears **only** in DENY edges **must still be registered** into `subject_roles` / `source_roles`, or the Rego deny lookup (`subject_roles[input.subject]` → deny-scope map) cannot resolve the role and the prohibition silently fails to fire. -> **`default_effect` is PCE-produced, not stored.** Because `AgentPolicyModel` is a **pure derived projection** rebuilt on every relevant recompute, `default_effect` must be set by the PCE at derive time — it is never read back from a store. The minimal design threads an optional `default_effect: RuleEffect = RuleEffect.DENY` through `compute_and_apply` → `_run` → `_derive`, so every existing call site keeps the `Deny` default and only a caller that explicitly opts in (e.g. an onboarding request) asks for `Allow`. The Policy Rules Builder is **not** involved — it never sets `default_effect`. **Caveat:** this parameter path is **not durable** — an unrelated later recompute that re-derives this agent rebuilds the APM with the `Deny` default unless that call also passes `Allow`. If the value must survive independent re-derivation, persist it on `ServicePolicyModel` (add the field there, seed from onboarding input, and have `_derive` copy it onto the APM); that is the only origin that reproduces across recomputes. +> **No default effect.** The model has no default-effect field, so the PCE sets none at derive time. A pair that no rule mentions is always DENY. **Inbound rule semantics (deny-overrides):** a subject holding realm role `role` is permitted to invoke this agent for the agent scope `scope` iff an `inbound_subject_allow` edge grants it **and no** `inbound_subject_deny` edge prohibits it; the same allow-and-not-deny logic applies to the source gate. The PDP Policy Writer consumes the allow/deny lists as separate role → agent-scope maps; its inbound gate is keyed on the subject id (mandatory), with the calling source id optional. @@ -330,7 +327,7 @@ The two-layer model rests on three invariants. All three are **AIAC invariants** Key behaviors to assert: - `Scope.serviceId` is present; `Role.kind` (a `RoleKind`) and `Role.actorIds` (a `list[str]`) are present; the `Role` `model_validator` accepts a valid `kind` + `list[str]` `actorIds` and rejects a malformed one. - `RuleEffect` values serialize as `"Allow"` / `"Deny"`; `PolicyRule.effect` defaults to `RuleEffect.ALLOW` when omitted. -- `AgentPolicyModel.default_effect` defaults to `RuleEffect.DENY` when omitted (existing constructions and serialized models unchanged), accepts `RuleEffect.ALLOW`, and round-trips as `"Deny"` / `"Allow"`. +- `AgentPolicyModel` has no `default_effect` field; a legacy payload that carries `default_effect` validates, and the field is dropped (`extra='ignore'`). - The same `(role, scope)` coexists as one `Allow` and one `Deny` rule (dedup identity `(role.id, scope.id, effect)` keeps them distinct). - `ServicePolicyModel` constructs with `service_id`, `service_type`, `owned_roles`, `owned_scopes`, `inbound_allow_rules`, `inbound_deny_rules`, and round-trips via `model_dump(mode="json")` / `model_validate()` (string keys only) with typed `Role` / `Scope` / `PolicyRule` values preserved. - `PolicyRule` accepts typed `Role` and `Scope` objects; rejects plain `str` where `Role`/`Scope` is expected. diff --git a/docs/testing/uc1-onboarding-pipeline.md b/docs/testing/uc1-onboarding-pipeline.md index 16a1aaa2..b213c424 100644 --- a/docs/testing/uc1-onboarding-pipeline.md +++ b/docs/testing/uc1-onboarding-pipeline.md @@ -10,7 +10,7 @@ > **Ladder, not one test.** This spec was previously a single "complete two-policy" test that assumed a > **two-stack** topology (one AIAC stack per `policy.md` variant) which is **not deployed** and so could > never run. It is now a **ladder** of three gradual, runnable happy-path tests against **one** AIAC -> stack, plus two **deferred** rungs (two-policy and failure-path rollback): +> stack, one failure-path rung (rung 5), and one **deferred** rung (two-policy): > > | Rung | Issue | Onboards | Proves | > |---|---|---|---| @@ -18,7 +18,7 @@ > | 2 | `testing/5.4.2-uc1-onboard-agent-then-tool.md` | agent → tool | onboarding the tool **after** the agent completes the agent's outbound gate (PCE additive merge) | > | 3 | `testing/5.4.3-uc1-onboard-tool-then-agent.md` | tool → agent | the happy path; **and, vs rung 2, onboarding-order-independence** | > | 4 | `testing/5.4.4-uc1-onboard-two-policies.md` | two policies | **deferred / TBD**; two-stack impl discarded | -> | 5 | `testing/5.4.5-uc1-onboard-failure-rollback.md` | agent (PRB failure) | **deferred / no test file**; UC-1 **compensating rollback** under the event model (failure surfaces via NATS redelivery→DLQ, not an HTTP status). Tracked by the PRB-failure issue opened alongside the harness work | +> | 5 | `testing/5.4.5-uc1-onboard-failure-rollback.md` | agent / tool (build failure) | UC-1 **compensating rollback + PCE quarantine**: a failed agent denies every request, a failed tool is unreachable, and a successful re-onboarding lifts the quarantine. Also the outbound **MCP session** for a granted user. Test file: `test/system/test_uc1_onboard_failure_rollback.py` | > **Relationship to `policy-pipeline`.** This is the **onboarding-order-focused sibling** of > [policy-pipeline.md](policy-pipeline.md). Identical *scenario facts and truth tables* (same three users, @@ -32,8 +32,8 @@ `test/system/` — pytest modules marked `@pytest.mark.system`, one per **implemented** rung (`test_uc1_onboard_agent_only.py`, `test_uc1_onboard_agent_then_tool.py`, -`test_uc1_onboard_tool_then_agent.py`). Rungs 4 (two-policy) and 5 (failure-rollback) are **deferred and -have no test file**. Each implemented module is a thin module that wraps the shared harness in a +`test_uc1_onboard_tool_then_agent.py`, and `test_uc1_onboard_failure_rollback.py` for rung 5). Rung 4 +(two-policy) is **deferred and has no test file**. Each implemented module is a thin module that wraps the shared harness in a one-line session fixture and supplies only its own rung's oracle (verdicts computed from `scenario_uc1.py`) and live assertions. They import three shared modules: @@ -278,25 +278,33 @@ it is deterministic and real: at the unit level by the grant-set oracle (`test/unit/agent/uc/onboarding/test_uc1_grant_set_oracles.py`) and live by `test_no_tool_scopes_provisioned` (no `github-tool.*` scope in Keycloak). Inbound is unaffected. -## Failure path — compensating rollback (rung 5) — **deferred** +## Failure path — compensating rollback and quarantine (rung 5) -Rungs 1–3 prove the happy path. Rung 5 would prove the **failure path**: a build failure must leave **no -partial footprint** and a **visible failed-service marker**, per the UC-1 +Rungs 1–3 prove the happy path. Rung 5 (`test/system/test_uc1_onboard_failure_rollback.py`) proves the +**failure path**, per the UC-1 [Failure & Rollback](../specs/components/aiac-agent/uc1-service-onboarding.md#failure--rollback) contract -(Service Provision's non-LLM nodes succeed and create the agent's roles/scopes; the LLM is first reached -inside `ServicePolicyBuilder.build`, so a broken PRB LLM seam raises `LLMAccessError` after Provision has -written its entities — exactly the provision-succeeded / build-failed shape the rollback exists for). - -**Rung 5 is deferred and has no test file.** Its assertions were written around the **synchronous** POST -trigger — a `POST /apply/service/{id}` returning **`502`** with a sanitized body — which **no longer -applies** under the event model: onboarding is now fired asynchronously by `CLIENT_CREATED` over NATS, -with **no HTTP status to assert on**. Under the event model a PRB failure surfaces via **NATS -redelivery → dead-letter queue (DLQ)**, not an HTTP error, so the failure-observation surface must be -respecified before the rung can be written. This is tracked by the **PRB-failure issue opened alongside -the harness work** (Handoff `04`). The observable end state the rung must eventually assert is unchanged -(observable Keycloak + CR state only): no partial footprint (provisioned roles/scopes absent, `client.type` -unset), a failed-service marker (`enabled=false`), no `AuthorizationPolicy` CR, and a clean re-onboard -that re-enables the client and upserts the CR — the live counterpart of the UC-1 rollback unit coverage. +and the PCE [Quarantine](../specs/components/policy-computation-engine.md#quarantine-failed-onboarding). +Service Provision succeeds and creates the service's roles/scopes; the build then fails (a +`_ROLLBACK_ERRORS` failure, for example a broken PRB LLM seam). The Orchestrator rolls back, calls the PCE +`quarantine`, and re-raises. Under the event model the failure surfaces via **NATS redelivery → DLQ**, not +an HTTP status, so the rung asserts only observable end state (Keycloak, CRs, the policy store, and real +requests through AuthBridge + OPA): + +- **Failed agent:** + - the agent's `AuthorizationPolicy` CR is **present** and is the **no-rules CR** (not deleted — a missing + CR would open the agent through the combiner's allow-fallback); + - a real inbound request through AuthBridge + OPA is **denied**; + - the Keycloak client is **disabled** (`enabled=false`), and its `client.type` is **kept**; + - the agent has **no SPM** in the policy store. +- **Failed tool:** + - the tool has **no CR** (a tool never has one) and **no SPM**; + - the agent's outbound **denies** a call to the tool. +- **Lift:** a successful re-onboarding writes the **real CR** over the no-rules CR and **re-enables** the + client. The test proves the lift on the agent only: a quarantined tool cannot be re-onboarded today + (see the known limit in `docs/specs/components/aiac-agent/uc1-service-onboarding.md`), so the failed-tool + phase runs last. +- **Outbound MCP session:** through the agent's outbound leg, `initialize`, `tools/list` and `tools/call` + work for a granted user (a user who holds a grant on at least one tool of the target). ## Expected output @@ -323,6 +331,10 @@ and 3** (with a tool onboarded): | test-user | ❌ | ❌ | ✅ | ✅ | | devops-user | ❌ | ❌ | ❌ | ❌ | +The table covers `tools/call`. The MCP session messages (`initialize`, `notifications/initialized`, +`ping`, `tools/list`) to `github-tool` are allowed for a user who holds a grant on at least one of its +tools (`dev-user`, `test-user`) and denied otherwise (`devops-user`). + **Rung 1 (agent only):** the outbound user gate is **empty** (no tool scopes), so this table has no live counterpart on rung 1 — the outbound leg is **not probed**. The emptiness is asserted at the unit level (grant-set oracle) and live by `test_no_tool_scopes_provisioned`, not by a synthetic outbound deny. @@ -448,11 +460,12 @@ CLI or a container runtime makes the load (and thus the test) **fail loudly**, n - **Onboarding-order-independence is asserted, not assumed** (rungs 2 vs 3). Rung 3's intended end state is checked identical to rung 2's published expectations, and the real plugin's decisions are asserted in the tool→agent order. A divergence is a bug. -- **The failure path is deferred** (rung 5). Its old `POST`-`502` assertions do not apply under the event - model — a PRB failure now surfaces via **NATS redelivery→DLQ**, not an HTTP status — so the rung is - respecified and tracked by the PRB-failure issue (Handoff `04`). The observable end state it must - eventually assert is unchanged (no partial footprint, `client.type` unset, `enabled=false`, no CR; a - clean re-onboard re-enables), on observable Keycloak + CR state only. +- **The failure path asserts observable end state only** (rung 5). A build failure surfaces via **NATS + redelivery→DLQ**, not an HTTP status, so the rung does not assert a `POST` status. It asserts Keycloak, + CR and policy-store state and real requests through AuthBridge + OPA: a failed agent has the no-rules + CR and is denied, its client is disabled with `client.type` kept, and it has no SPM; a failed tool has + no CR and no SPM and is unreachable from the agent; a clean re-onboard writes the real CR and + re-enables the client. - **Per-scope two-gate AND.** UC-1's per-skill operator roles are mapped to the tool scopes by capability-match, so the capability gate is populated; the plugin enforces the real per-scope AND. The agent reaches all four tool scopes, so the user gate discriminates. @@ -481,7 +494,7 @@ CLI or a container runtime makes the load (and thus the test) **fail loudly**, n wired. Tracking issues: `testing/5.4-uc1-onboarding-integration-test.md` (epic) + `5.4.1`/`5.4.2`/`5.4.3` (rungs) -+ `5.4.4` (deferred two-policy) + `5.4.5` (deferred failure-path rollback). ++ `5.4.4` (deferred two-policy) + `5.4.5` (failure-path rollback and quarantine). ## Out of Scope @@ -494,9 +507,8 @@ Tracking issues: `testing/5.4-uc1-onboarding-integration-test.md` (epic) + `5.4. Keycloak SPI + enabling the realm listener, and deploying the NATS Event Broker are **preconditions**, not test steps. (Loading the demo images, then deploying and tearing down the *workloads*, by contrast, **is** a test step — the fixture runs `kind-load.sh` before deploy — see *[Per-rung flow](#per-rung-flow)*.) -- **Two-policy (rung 4) and failure-rollback (rung 5)** — both **deferred**. Rung 4's two-stack topology - is discarded and the in-cluster approach is TBD (`testing/5.4.4-uc1-onboard-two-policies.md`); rung 5 - is respecified for the event model and tracked by the PRB-failure issue (Handoff `04`). +- **Two-policy (rung 4)** — **deferred**. Rung 4's two-stack topology is discarded and the in-cluster + approach is TBD (`testing/5.4.4-uc1-onboard-two-policies.md`). - **The agent's CrewAI reasoning flow / real A2A message content** — the probes drive synthetic requests through AuthBridge to exercise the enforced gates; they do not run the agent's task graph. - **Default-CI wiring** — `@pytest.mark.system`; runs on demand. diff --git a/k8s/agent-deployment.yaml b/k8s/agent-deployment.yaml index 60c6faf2..0dc28d3e 100644 --- a/k8s/agent-deployment.yaml +++ b/k8s/agent-deployment.yaml @@ -171,17 +171,6 @@ spec: periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 5 - # Optional per-onboarding default effect for pairs no rule mentions. - # Unset/unrecognised => "Deny" (least-privilege, today's behavior). - # "Allow" opts an onboarding into permissive-default posture (explicit - # DENY rules still override). This is an integration-harness knob today - # (patched onto this Deployment before onboarding); it is NOT persisted - # on the SPM, so an unrelated recompute re-derives the agent at "Deny" - # (see the non-durability caveat in policy/computation/engine.py). - # Uncomment to set it deployment-wide: - # env: - # - name: AIAC_DEFAULT_EFFECT - # value: "Allow" # or "Deny" envFrom: - configMapRef: name: aiac-pdp-config diff --git a/k8s/opa-kind-enable.sh b/k8s/opa-kind-enable.sh index 3c78938d..ebd278fe 100755 --- a/k8s/opa-kind-enable.sh +++ b/k8s/opa-kind-enable.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# opa-kind-enable.sh — authbridge/docs/opa-kind-runbook.md Steps 1-5, on the fly. +# opa-kind-enable.sh — k8s/opa-kind-runbook.md Step 1 (the enable Steps 1-5), on the fly. # # Wires the OPA plugin into every agent's inbound AND outbound AuthBridge # pipeline on a Kind cluster, alongside the full parser set (a2a-parser, @@ -17,7 +17,10 @@ # # Requires: kubectl, helm, kind, docker (or podman), python3 not needed here. # Env vars: -# OPERATOR_DIR path to the rossoctl/operator repo clone (bundle-service) +# OPERATOR_DIR path to the rossoctl/operator repo clone; Step 1 builds +# the operator image (which carries the bundle-service +# binary) and renders the bundle-service templates from +# its local chart (default: ../operator) # ROSSOCTL_DIR path to the rossoctl/rossoctl repo clone (the chart) # CORTEX_DIR path to the rossoctl/cortex repo clone; the authbridge # source built in Step 2 lives there, not in this repo @@ -27,6 +30,11 @@ # RELEASE_NAMESPACE namespace the chart is installed in (default: rossoctl-system) # AGENT_NAMESPACE namespace to restart agent pods in (default: team1) # IMAGE_TAG local authbridge-proxy image tag (default: localhost/authbridge:local) +# GO_BUILD_TAGS authbridge plugin build tags (default: the cortex "full" +# profile, from scripts/profile-tags; derived with a local +# `go`, or in a golang container when go is absent) +# OPERATOR_IMAGE local operator image (bundle-service runs from it) +# (default: localhost/rossoctl-operator:) # CONTAINER_RUNTIME docker | podman (default: docker, auto-falls back to podman) set -euo pipefail @@ -46,20 +54,24 @@ RELEASE_NAMESPACE="${RELEASE_NAMESPACE:-rossoctl-system}" AGENT_NAMESPACE="${AGENT_NAMESPACE:-team1}" IMAGE_TAG="${IMAGE_TAG:-localhost/authbridge:local}" -if [ -z "$OPERATOR_DIR" ] || [ ! -d "$OPERATOR_DIR" ]; then +if [ -z "$OPERATOR_DIR" ] || [ ! -f "$OPERATOR_DIR/operator/Dockerfile" ] || [ ! -d "$OPERATOR_DIR/charts/operator/templates/bundleservice" ]; then echo "ERROR: Set OPERATOR_DIR to point to your rossoctl/operator repo clone" >&2 + echo " (Step 1 needs \$OPERATOR_DIR/operator/Dockerfile and the bundle-service" >&2 + echo " templates in \$OPERATOR_DIR/charts/operator/templates/bundleservice/)" >&2 exit 1 fi if [ -z "$ROSSOCTL_DIR" ] || [ ! -d "$ROSSOCTL_DIR" ]; then echo "ERROR: Set ROSSOCTL_DIR to point to your rossoctl/rossoctl repo clone" >&2 exit 1 fi -if [ -z "$CORTEX_DIR" ] || [ ! -d "$CORTEX_DIR/authbridge" ]; then +if [ -z "$CORTEX_DIR" ] || [ ! -f "$CORTEX_DIR/cmd/authbridge-proxy/Dockerfile" ]; then echo "ERROR: Set CORTEX_DIR to point to your rossoctl/cortex repo clone" >&2 - echo " (Step 2 builds the authbridge-proxy image from \$CORTEX_DIR/authbridge," >&2 - echo " which lives in the cortex monorepo, not in this repo)" >&2 + echo " (Step 2 builds the authbridge-proxy image from" >&2 + echo " \$CORTEX_DIR/cmd/authbridge-proxy/Dockerfile, which lives in the cortex" >&2 + echo " monorepo, not in this repo)" >&2 exit 1 fi +OPERATOR_IMAGE="${OPERATOR_IMAGE:-localhost/rossoctl-operator:$(git -C "$OPERATOR_DIR" rev-parse --short HEAD)}" VALUES_FILE="${ROSSOCTL_DIR}/charts/rossoctl/values.yaml" CHART_DIR="${ROSSOCTL_DIR}/charts/rossoctl" @@ -100,12 +112,62 @@ load_image_to_kind() { OVERLAY_FILE="$(mktemp "${TMPDIR:-/tmp}/opa-kind-enable-overlay.XXXXXX")" TMPFILES+=("$OVERLAY_FILE") -echo "==> Step 1/5: deploying bundle-service (${OPERATOR_DIR})" -( cd "$OPERATOR_DIR" && ./operator/hack/bundle-service-kind.sh "$CLUSTER_NAME" "$RELEASE_NAMESPACE" ) +echo "==> Step 1/5: deploying bundle-service from the operator chart (${OPERATOR_DIR}, image ${OPERATOR_IMAGE})" +# The bundle service ships inside the operator image (selected by the container +# `command:`) and is installed by the operator chart behind +# bundleService.enabled. No released operator chart carries it yet, so — like +# operator/hack/kind-reload-all.sh — build the image from the clone and render +# only the bundle-service templates from its local chart. The rest of the +# operator (the controller-manager) is left as installed. +"$CONTAINER_RUNTIME" build -t "$OPERATOR_IMAGE" -f "$OPERATOR_DIR/operator/Dockerfile" "$OPERATOR_DIR/operator" +load_image_to_kind "$OPERATOR_IMAGE" +kubectl apply -f "$OPERATOR_DIR/operator/config/crd/bases/agent.rossoctl.dev_authorizationpolicies.yaml" +# A bundle-service from the removed operator/hack/bundle-service-kind.sh selects +# on `app: bundle-service` only. The chart's selector adds the +# app.kubernetes.io/{name,instance} labels, and a Deployment selector is +# immutable, so delete that legacy Deployment before the apply. +# +# The chart's bundle-service NetworkPolicy is NOT applied: it admits only pods +# labelled rossoctl.dev/authbridge=true, and nothing (operator webhook, chart, +# AuthBridge) sets that label today. On a CNI that enforces NetworkPolicy it +# would block every AuthBridge bundle fetch. The removed hack script applied no +# NetworkPolicy either, so this keeps the earlier dev-cluster behavior. +if kubectl get deployment bundle-service -n "$RELEASE_NAMESPACE" >/dev/null 2>&1 \ + && [ -z "$(kubectl get deployment bundle-service -n "$RELEASE_NAMESPACE" \ + -o jsonpath='{.spec.selector.matchLabels.app\.kubernetes\.io/instance}')" ]; then + kubectl delete deployment bundle-service -n "$RELEASE_NAMESPACE" --wait=true +fi +helm template rossoctl-operator "$OPERATOR_DIR/charts/operator" \ + --namespace "$RELEASE_NAMESPACE" \ + --set bundleService.enabled=true \ + --set bundleService.container.image.repository="${OPERATOR_IMAGE%:*}" \ + --set bundleService.container.image.tag="${OPERATOR_IMAGE##*:}" \ + --set bundleService.container.image.pullPolicy=Never \ + --show-only templates/bundleservice/serviceaccount.yaml \ + --show-only templates/bundleservice/rbac.yaml \ + --show-only templates/bundleservice/deployment.yaml \ + --show-only templates/bundleservice/service.yaml \ + --show-only templates/bundleservice/default-policy.yaml \ + | kubectl apply -f - +kubectl rollout status deployment/bundle-service -n "$RELEASE_NAMESPACE" --timeout=180s kubectl get pods -n "$RELEASE_NAMESPACE" -l app=bundle-service echo "==> Step 2/5: building + loading authbridge-proxy (${IMAGE_TAG}) via ${CONTAINER_RUNTIME}" -( cd "$CORTEX_DIR/authbridge" && "$CONTAINER_RUNTIME" build -t "$IMAGE_TAG" -f cmd/authbridge-proxy/Dockerfile . ) +# AuthBridge plugins are opt-in build tags: an untagged build registers none and +# the Dockerfile refuses it. Use the "full" profile, as the cortex CI does for +# the authbridge image (scripts/profile-tags). GOWORK=off: the profile tool is a +# standalone module, and the cortex go.work would want to write go.work.sum. +if [ -z "${GO_BUILD_TAGS:-}" ]; then + if command -v go &> /dev/null; then + GO_BUILD_TAGS="$(GOWORK=off go -C "$CORTEX_DIR/scripts/profile-tags" run . full)" + else + GO_BUILD_TAGS="$("$CONTAINER_RUNTIME" run --rm -e GOWORK=off -v "$CORTEX_DIR:/src:ro" -w /src \ + docker.io/library/golang:1.26-alpine go -C scripts/profile-tags run . full)" + fi +fi +echo " GO_BUILD_TAGS=${GO_BUILD_TAGS}" +( cd "$CORTEX_DIR" && "$CONTAINER_RUNTIME" build -t "$IMAGE_TAG" -f cmd/authbridge-proxy/Dockerfile \ + --build-arg GO_BUILD_TAGS="$GO_BUILD_TAGS" . ) load_image_to_kind "$IMAGE_TAG" echo "==> Step 3/5: writing throwaway pipeline overlay (${VALUES_FILE} stays untouched)" diff --git a/k8s/opa-kind-runbook.md b/k8s/opa-kind-runbook.md index 93f1f31e..4f5de1e6 100644 --- a/k8s/opa-kind-runbook.md +++ b/k8s/opa-kind-runbook.md @@ -4,18 +4,19 @@ > to its release as part of the Rossoctl system. On release, this document > should be updated accordingly. -This is the AIAC-scoped companion to `authbridge/docs/opa-kind-runbook.md` (in -the separate AuthBridge repository). +The OPA plugin itself lives in the separate cortex repository +(`core/plugins/opa/`, see its `README.md`). The underlying mechanism — OPA as an AuthBridge pipeline plugin, policy distributed via `bundle-service`, enforcement via the `AuthorizationPolicy` CRD — is identical. This document gives the **exact, copy-paste** steps to run the AIAC scenario end-to-end on a local Kind cluster, using the two helper scripts that wire OPA in and out: -- [`opa-kind-enable.sh`](opa-kind-enable.sh) — rebuilds - the `authbridge-proxy` image from the current tree, loads it into Kind, and - wires the `opa` plugin (plus the parser set) into **both** the inbound and - outbound pipeline of every `team1` agent. +- [`opa-kind-enable.sh`](opa-kind-enable.sh) — builds the + operator image from the operator clone and deploys the bundle service from + the operator chart, builds the `authbridge-proxy` image from the cortex clone, + loads both images into Kind, and wires the `opa` plugin (plus the parser set) + into **both** the inbound and outbound pipeline of every `team1` agent. - [`opa-kind-restore.sh`](opa-kind-restore.sh) — reverts the pipeline to its shipped state (no OPA overlay) and restarts the agents. @@ -49,10 +50,19 @@ read the delegation chain (see [Part B](#part-b--outbound-token-exchange--opa)). - A Kind cluster named `rossoctl` with the `rossoctl` platform installed and `github-agent` + `github-tool` deployed in namespace `team1`. -- The two sibling repo clones the enable/restore scripts need: - - `OPERATOR_DIR` → `rossoctl/operator` clone (default: `../operator`) +- The three sibling repo clones the enable/restore scripts need: + - `OPERATOR_DIR` → `rossoctl/operator` clone (default: `../operator`). The + enable script builds the operator image from `operator/Dockerfile` and + renders the bundle-service templates from `charts/operator`. It needs a + clone at or after operator commit `5e4c991`, which puts the bundle service + in the operator image. - `ROSSOCTL_DIR` → `rossoctl/rossoctl` clone, i.e. the Helm chart (default: `../rossoctl`) + - `CORTEX_DIR` → `rossoctl/cortex` clone (default: `../cortex`). The enable + script builds the `authbridge-proxy` image from + `cmd/authbridge-proxy/Dockerfile` at the cortex repo root. This clone is + required: the enable script stops if it is missing. The restore script does + not need it. - `kubectl`, `helm`, `kind`, and `docker` (or `podman`) on `PATH`. - If `kubectl` reports `connection refused` reaching the API server, the Kind node was likely restarted and reassigned its API-server host port, leaving @@ -138,14 +148,39 @@ All commands below are run from the repo root. ## Step 1 — Enable OPA in both legs ```bash -OPERATOR_DIR=../operator ROSSOCTL_DIR=../rossoctl ./aiac/k8s/opa-kind-enable.sh +OPERATOR_DIR=../operator ROSSOCTL_DIR=../rossoctl CORTEX_DIR=../cortex ./k8s/opa-kind-enable.sh ``` -This rebuilds `localhost/authbridge:local` from the current tree, loads it into -the `rossoctl` Kind cluster, and `helm upgrade`s the chart with a temporary -overlay that inserts `opa` (after `token-exchange` on the outbound leg) and the -parser set into every `team1` agent's pipeline. It does **not** modify -`charts/rossoctl/values.yaml` on disk. +The script does these steps: + +1. **Bundle service.** No released operator chart carries the bundle service + yet, so the script deploys it from the operator clone: + - It builds the operator image from `$OPERATOR_DIR/operator/Dockerfile` + (`OPERATOR_IMAGE`, default `localhost/rossoctl-operator:`) + and loads it into Kind. The bundle service runs from this image. + - It applies the `AuthorizationPolicy` CRD from the operator clone. + - It deletes a legacy `bundle-service` Deployment whose selector is only + `app: bundle-service` (from the removed `operator/hack/bundle-service-kind.sh`). + The chart selector adds `app.kubernetes.io` labels, and a Deployment + selector is immutable. + - It renders and applies only the bundle-service templates from + `$OPERATOR_DIR/charts/operator` (`bundleService.enabled=true`, the local + image, `pullPolicy: Never`). The rest of the operator stays as installed. + - It does **not** apply the chart's bundle-service NetworkPolicy. That policy + admits only pods labelled `rossoctl.dev/authbridge=true`, and nothing sets + that label today. On a CNI that enforces NetworkPolicy it would block every + AuthBridge bundle fetch. +2. **AuthBridge image.** It builds `localhost/authbridge:local` from + `$CORTEX_DIR/cmd/authbridge-proxy/Dockerfile` (build context: the cortex repo + root) and loads it into the `rossoctl` Kind cluster. AuthBridge plugins are + opt-in build tags, so the build passes `GO_BUILD_TAGS` with the cortex `full` + profile (`scripts/profile-tags`, as the cortex CI does). The script derives it + with a local `go`, or in a `golang` container when `go` is not installed. Set + `GO_BUILD_TAGS` to override it. +3. **Pipeline.** It `helm upgrade`s the chart with a temporary overlay that + inserts `opa` (after `token-exchange` on the outbound leg) and the parser set + into every `team1` agent's pipeline. It does **not** modify + `charts/rossoctl/values.yaml` on disk. Confirm OPA is wired into **both** legs (expect **2**): @@ -429,20 +464,22 @@ kubectl exec -i -n team1 "$POD" -c agent -- python3 - < /tmp/probe.py > `method` and an `id`) as an application-layer error frame so the caller's MCP > client sees a single failed tool call rather than a transport break — see > `writeMCPRejection` in -> `authbridge/authlib/listener/httpx/render.go`. The request is **denied and +> `core/listener/httpx/render.go` in the cortex repo. The request is **denied and > never reaches `github-tool`**; the `HTTP 200` is only the JSON-RPC transport > envelope. Classify the outcome by the response **body** (an `error` frame = > denied, a `result` frame = allowed), not the HTTP status. > -> The rule admits a call only when the delegated user's role and the target -> service both list the request's `input.mcp.params.name` (the invoked tool). -> A `tools/list` call carries no `params.name`, so neither gate matches and -> `allow` is `false`. A non-MCP-shaped rejection (no parser, or a JSON-RPC +> The example CR is generated output (`generate_outbound_rego`, see +> [`pdp-policy-writer-opa.md`](../docs/specs/components/pdp-policy-writer-opa.md#outbound-package-authbridgeclientoutboundrequest)). +> It admits a `tools/call` whose `input.mcp.params.name` (the invoked tool) both +> the delegated user's role and the target service list. It also allows the MCP +> session messages (`initialize`, `notifications/initialized`, `ping`, +> `tools/list`) to a target when the user holds a grant on at least one tool of +> that target. So this `tools/list` probe as `dev-user` is **allowed** (a +> `result` frame), and the same probe as a user with no grant on a +> `github-tool` tool is **denied**. A non-MCP-shaped rejection (no parser, or a JSON-RPC > *notification* with no `id`) instead falls through to a plain HTTP `403`; a -> `token-exchange` failure surfaces as `503` before OPA is even consulted. To -> see the full allow path (a `result` frame at HTTP 200), apply only the inbound -> tier of the CR, or drive a real tool invocation whose tool name is present in -> `subject_role_scopes` and `target_scopes` in the outbound rego. +> `token-exchange` failure surfaces as `503` before OPA is even consulted. ## B.5 — The outbound OPA input, exactly @@ -510,7 +547,7 @@ built: inbound identity, where `jwt-validation` surfaces the validated JWT's audience; on the outbound leg the equivalent "who is this token for" signal is the exchange target, exposed as `service_id`. A policy keys on it via - `target_scopes[input.identity.service_id]`. Omitted when the last hop is a + `target_allow_scopes[input.identity.service_id]`. Omitted when the last hop is a non-exchange hop that recorded no audience. - `input.delegation` carries the full RFC 8693 chain for policies that need per-hop detail (`audience`, `strategy`, `from_cache`, `depth`). @@ -525,13 +562,14 @@ emits on this leg: the synthesized `input.identity.subject`, as shown above), and `input.mcp.params.name` (the specific tool being invoked). It gates **per tool**: allowing only when the delegated user's role **and** the target service both admit the invoked tool — an AND across the user→tool and -service→tool gates. The `subject_role_scopes` / `target_scopes` maps in the -example are keyed by the actual MCP tool names exposed by the deployed +service→tool gates. The `subject_role_allow_scopes` / `target_allow_scopes` maps +in the example are keyed by the actual MCP tool names exposed by the deployed github-tool (`demo/assets/tools/github_tool`): `source-read`, -`source-write`, `issues-read`, `issues-write`. (Because the gate is on -`params.name`, MCP methods that don't invoke a specific tool — like the -`tools/list` probe below — carry no `params.name`, so they never match and are -denied.) +`source-write`, `issues-read`, `issues-write`. The outbound rego also reads +`input.mcp.method` (always set by the plugin). It allows a `tools/call` per tool, and it allows the session messages +(`initialize`, `notifications/initialized`, `ping`, `tools/list`) to a target +iff at least one tool of that target passes the full per-tool check for this +user. It denies every other MCP method. --- @@ -565,7 +603,7 @@ curl -s -o /dev/null -w "remove scope HTTP %{http_code}\n" -X DELETE -H "Authori "$KC/admin/realms/rossoctl/clients/$CID/optional-client-scopes/$SID" # 4. revert the pipeline (removes the OPA overlay, restarts the agents) -ROSSOCTL_DIR=../rossoctl ./aiac/k8s/opa-kind-restore.sh +ROSSOCTL_DIR=../rossoctl ./k8s/opa-kind-restore.sh ``` Confirm OPA is gone from the pipeline (expect **0**): diff --git a/src/aiac/agent/controller/routes.py b/src/aiac/agent/controller/routes.py index bf0dcd2c..27edc94e 100644 --- a/src/aiac/agent/controller/routes.py +++ b/src/aiac/agent/controller/routes.py @@ -11,8 +11,6 @@ code is authoritative (the accompanying default JSON error body is incidental). """ -import os - import uvicorn from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, Response @@ -35,8 +33,8 @@ from aiac.agent.uc.policy_update.build import build_policy from aiac.agent.uc.policy_update.rebuild import rebuild_policy from aiac.agent.uc.role_update.role import update_role +from aiac.idp.configuration.models import ClientId, ServiceUuid from aiac.policy.computation import compute_and_apply, decommission -from aiac.policy.model.models import RuleEffect app = FastAPI(lifespan=lifespan) @@ -105,21 +103,6 @@ def _policy_builder_base_error(_request: Request, exc: PolicyRulesBuilderBaseErr return _sanitized(exc, 500, "The policy build failed unexpectedly.") -# Live on-ramp for the per-onboarding default_effect. The PCE-threading side (#146) exposes -# default_effect as an onboard_service parameter; the integration harness (#149) requests a -# non-default value by patching AIAC_DEFAULT_EFFECT ("Allow"/"Deny") onto the Controller -# deployment before onboarding. This is the single point where those two halves meet. Absent or -# unrecognised env → DENY, today's least-privilege default, so existing deployments are unchanged. -DEFAULT_EFFECT_ENV = "AIAC_DEFAULT_EFFECT" - - -def _default_effect_from_env() -> RuleEffect: - try: - return RuleEffect(os.environ.get(DEFAULT_EFFECT_ENV, RuleEffect.DENY.value)) - except ValueError: - return RuleEffect.DENY - - @app.get("/health") def health() -> dict[str, str]: # The Controller is stateless — it holds no local state and opens no @@ -131,11 +114,15 @@ def health() -> dict[str, str]: @app.post("/apply/service/{service_id}") def apply_service(service_id: str) -> Response: - rules, override, default_effect = onboard_service(service_id, _default_effect_from_env()) - compute_and_apply(rules, override, default_effect) + # service_id is the Keycloak UUID (an IdP key). onboard_service resolves the clientId once; the + # PCE routing guard takes it as the focus service and keeps its rules while its client is still + # disabled (a re-onboarding of a quarantined service). + uuid = ServiceUuid(service_id) + rules, override, client_id = onboard_service(uuid) + compute_and_apply(rules, override, focus_service=client_id) # Re-enable the client only AFTER the PCE apply succeeds — a compute_and_apply failure above # propagates and leaves the client disabled (the failed-service marker), never enabled-with-no-policy. - reenable_service(service_id) + reenable_service(uuid) return Response(status_code=200) @@ -167,7 +154,7 @@ def apply_role(role_id: str) -> Response: # (rules, override) → compute_and_apply path and calls the PCE's decommission directly. @app.post("/apply/offboard/{service_id:path}") def apply_offboard(service_id: str) -> Response: - decommission(offboard_service(service_id)) + decommission(offboard_service(ClientId(service_id))) return Response(status_code=200) diff --git a/src/aiac/agent/eventbus/consumer.py b/src/aiac/agent/eventbus/consumer.py index e15efa2a..9799a566 100644 --- a/src/aiac/agent/eventbus/consumer.py +++ b/src/aiac/agent/eventbus/consumer.py @@ -40,8 +40,9 @@ from aiac.agent.uc.onboarding.orchestrator import onboard_service, reenable_service from aiac.agent.uc.policy_update.build import build_policy from aiac.agent.uc.role_update.role import update_role +from aiac.idp.configuration.models import ClientId, ServiceUuid from aiac.policy.computation import compute_and_apply -from aiac.policy.model.models import PolicyRule, RuleEffect +from aiac.policy.model.models import PolicyRule logger = logging.getLogger(__name__) @@ -69,21 +70,20 @@ ) -def _handle(subject: str) -> tuple[list[PolicyRule], bool, RuleEffect]: - # Normalize every handler to ``(rules, override, default_effect)``. Only onboarding carries a - # caller-requestable ``default_effect``; the others always emit least-privilege ``DENY``. +def _handle(subject: str) -> tuple[list[PolicyRule], bool, ClientId | None]: + """Dispatch ``subject`` to its use-case handler — the consumer's only subject switch. Returns + ``(rules, override, focus_service)``: ``focus_service`` is the onboarded service's clientId (from + ``onboard_service``), and ``None`` for every other subject.""" if subject.startswith(_SERVICE_PREFIX): - return onboard_service(subject[len(_SERVICE_PREFIX) :]) + return onboard_service(ServiceUuid(subject[len(_SERVICE_PREFIX) :])) if subject.startswith(_ROLE_PREFIX): # Mirror image of the Keycloak SPI's SubjectMapper.encodeSubjectToken: role names may # contain '.', which NATS treats as a token separator, so the SPI percent-encodes them # into a single token before publishing. unquote() is the general-purpose inverse; safe # here because every literal '%' in the original name was itself escaped to "%25". - rules, override = update_role(unquote(subject[len(_ROLE_PREFIX) :])) - return rules, override, RuleEffect.DENY + return *update_role(unquote(subject[len(_ROLE_PREFIX) :])), None if subject == _POLICY_BUILD_SUBJECT: - rules, override = build_policy() - return rules, override, RuleEffect.DENY + return *build_policy(), None raise ValueError(f"no handler for subject {subject!r}") @@ -149,13 +149,16 @@ async def _dispatch(self, msg: Msg) -> None: # redelivered the unacked message and the race repeated). Offload both to the default # threadpool so the loop stays free to answer ``/health`` while onboarding runs. loop = asyncio.get_running_loop() - rules, override, default_effect = await loop.run_in_executor(None, _handle, msg.subject) - await loop.run_in_executor(None, functools.partial(compute_and_apply, rules, override, default_effect)) - # UC1 only: re-enable the client AFTER a successful compute_and_apply, mirroring the - # HTTP route. If compute_and_apply raised above, this is skipped and the client stays - # disabled (the failed-service marker), never enabled-with-no-policy. - if msg.subject.startswith(_SERVICE_PREFIX): - reenable_service(msg.subject[len(_SERVICE_PREFIX) :]) + # UC1 only: ``focus`` is the onboarded service's clientId, so the PCE routing guard keeps + # its rules while its client is still disabled (a re-onboarding of a quarantined service). + rules, override, focus = await loop.run_in_executor(None, _handle, msg.subject) + await loop.run_in_executor(None, functools.partial(compute_and_apply, rules, override, focus_service=focus)) + # UC1 only (only an onboarding has a focus service): re-enable the client AFTER a + # successful compute_and_apply, mirroring the HTTP route. If compute_and_apply raised + # above, this is skipped and the client stays disabled (the failed-service marker), never + # enabled-with-no-policy. The re-enable is an IdP call, so it takes the subject's UUID. + if focus is not None: + reenable_service(ServiceUuid(msg.subject.removeprefix(_SERVICE_PREFIX))) except Exception as exc: # Log exactly once, routed by exception TYPE to its per-persona named logger. # FastAPI's exception handlers never fire on this path (there is no request), so diff --git a/src/aiac/agent/shared/focal_entities.py b/src/aiac/agent/shared/focal_entities.py index 51610335..0e6b614d 100644 --- a/src/aiac/agent/shared/focal_entities.py +++ b/src/aiac/agent/shared/focal_entities.py @@ -17,6 +17,12 @@ - ``other_scopes`` — other services' ``aiac.managed`` scopes, sourced from ``get_services()`` so each scope carries its owning ``serviceId`` (the SPM routing key the PCE needs). +A **disabled** service (``Service.enabled`` is false — the UC1 failed-service marker set by the +rollback) contributes no candidate role and no other-scope, so no build grants anything to or from +a quarantined service. The focus service is the exception: it resolves while it is disabled, +because a re-onboarding builds its rules before ``reenable_service`` runs. A disabled service +still *owns* its roles, so a user holding one does not turn it into a user-kind candidate. + IdP access is via the **idp-library** ``Configuration`` seam. Callers that already hold a ``Configuration`` (e.g. the live builder, whose ``_config`` seam existing tests patch) pass it in via ``config``; callers that don't (e.g. the diagnostic) let the resolver create the @@ -97,10 +103,12 @@ def resolve_focal_entities( own_roles = [r for r in focus.roles if r.aiac_managed] own_scopes = [s for s in focus.scopes if s.aiac_managed] + # The other services that can take part in a rule: every enabled service except the focus. A + # disabled one is a failed (quarantined) service — see the module docstring. + others = [s for s in services if s.serviceId != focus.serviceId and s.enabled] + # kind=Agent rides through unchanged from get_services() → routes to source_roles in the PCE. - other_agent_roles = [ - r for other in services if other.serviceId != focus.serviceId for r in other.roles if r.aiac_managed - ] + other_agent_roles = [r for other in others for r in other.roles if r.aiac_managed] # User roles are membership-derived, not aiac.managed: a realm role qualifies iff a user # holds it directly or via a composite parent they hold, and no service owns it. @@ -121,9 +129,7 @@ def resolve_focal_entities( # PCE needs. The global get_scopes() endpoint returns scopes with an empty serviceId, which # would both (a) fail to exclude the focus's own scopes (``"" != focus.serviceId`` is always # true) and (b) route any resulting rule to ``SPM("")``, a 422 dead-end. - other_scopes = [ - s for other in services if other.serviceId != focus.serviceId for s in other.scopes if s.aiac_managed - ] + other_scopes = [s for other in others for s in other.scopes if s.aiac_managed] candidate_roles = _flatten_dedup(user_roles + other_agent_roles) diff --git a/src/aiac/agent/uc/offboarding/offboard.py b/src/aiac/agent/uc/offboarding/offboard.py index e19f6b82..67096722 100644 --- a/src/aiac/agent/uc/offboarding/offboard.py +++ b/src/aiac/agent/uc/offboarding/offboard.py @@ -7,13 +7,17 @@ whole-service teardown, not a rule fold). Identity asymmetry with onboard. Onboarding is keyed by the Keycloak **internal -UUID** (``Service.id``) and resolves it to the clientId via ``get_services()``. -Offboarding cannot: an offboarded client is gone from ``get_services()``, so -UUID→clientId resolution is impossible. The offboard contract therefore carries -the **clientId (the SPM key)** directly, and this stub returns it unchanged. -Full validation/resolution lands with the UC4 implementation (issue 3.21). +UUID** (``Service.id``) and the Orchestrator resolves it to the clientId with one +``get_service()`` read before Provision. Offboarding cannot: an offboarded client +is gone from the IdP, so UUID→clientId resolution is impossible. The offboard +contract therefore carries the **clientId (the SPM key)** directly, and this stub +returns it unchanged. The PCE takes only the clientId, so the asymmetry stays at +the HTTP/NATS boundary. Full validation/resolution lands with the UC4 +implementation (issue 3.21). """ +from aiac.idp.configuration.models import ClientId -def offboard_service(service_id: str) -> str: + +def offboard_service(service_id: ClientId) -> ClientId: return service_id diff --git a/src/aiac/agent/uc/onboarding/orchestrator.py b/src/aiac/agent/uc/onboarding/orchestrator.py index 32e112f5..a19a6c7a 100644 --- a/src/aiac/agent/uc/onboarding/orchestrator.py +++ b/src/aiac/agent/uc/onboarding/orchestrator.py @@ -2,7 +2,8 @@ The only use case with an Orchestrator, because it is a **two-stage** pipeline. Invoked by the Controller for the ``aiac.apply.service.{id}`` / ``POST /apply/service/{service_id}`` -trigger, it sequences the two sub-agents and returns ``(list[PolicyRule], override=False)``: +trigger, it sequences the two sub-agents and returns ``(list[PolicyRule], override=False, client_id)`` +(``client_id`` is the service's clientId, which the Controller passes to the PCE as ``focus_service``): 1. Service Provision — classifies the service and writes its roles/scopes into the IdP, producing the discovered ``service_type`` and the **created-manifest** (exactly the @@ -16,13 +17,17 @@ **Replay safety (at-least-once delivery):** Provision IdP writes are idempotent and the PCE reconcile is idempotent, so a crash between stages simply re-runs the full pipeline to convergence on NATS redelivery. A build **failure**, however, triggers a **compensating -rollback** (UC1-only) before the error propagates — see :func:`_rollback`. +rollback** (UC1-only) and then the PCE ``quarantine`` before the error propagates — see +:func:`_rollback` and :func:`onboard_service`. The PCE owns the PDP: this module never imports +``aiac.pdp.policy.library``. """ import contextlib import logging import threading +from fastapi import HTTPException + from aiac.agent.policy_rules_builder.conflict_detection import PolicyConflictError from aiac.agent.policy_rules_builder.graph import ( LLMAccessError, @@ -33,7 +38,9 @@ from aiac.agent.uc.onboarding.provision.graph import build_provision_graph from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger from aiac.idp.configuration.api import Configuration -from aiac.policy.model.models import PolicyRule, RuleEffect +from aiac.idp.configuration.models import ClientId, Service, ServiceUuid +from aiac.policy.computation import quarantine +from aiac.policy.model.models import PolicyRule logger = logging.getLogger(__name__) @@ -132,31 +139,33 @@ def _loggable(value: object) -> str: return str(value).replace("\r", "").replace("\n", "") -def _rollback(config: Configuration, service_id: str, created_roles, created_scopes) -> None: - """Compensating rollback (UC1-only): tear down exactly what Provision created on this run, - unset the client type, then disable the client as a failed-service marker. +def _rollback(config: Configuration, service: Service, created_roles, created_scopes) -> None: + """Compensating rollback (UC1-only): tear down exactly what Provision created on this run, then + disable the client as a failed-service marker. ``created_roles`` / ``created_scopes`` are the **created-manifest** — only the entities this run added (reused-by-name entities are absent, so a role/scope another service shares is never removed). Each ``delete_service_*`` unmaps-then-deletes and is idempotent, so a retry that finds an object already gone does not crash. The disable lands **last**, after the teardown, so an interrupted rollback never leaves a disabled-but-still-provisioned client. Actions are - logged at INFO.""" - service = config.get_service(service_id) - safe_id = _loggable(service_id) + logged at INFO. + + The client **type is kept**: the rollback removes only what this run created, and the type + was not created by it. The + policy side of the teardown is the PCE's :func:`~aiac.policy.computation.quarantine`, which + the caller runs next.""" + safe_id = _loggable(service.id) for role in created_roles: config.delete_service_role(service, role) logger.info("UC1 rollback: deleted role %r (service %s)", _loggable(getattr(role, "name", role)), safe_id) for scope in created_scopes: config.delete_service_scope(service, scope) logger.info("UC1 rollback: deleted scope %r (service %s)", _loggable(getattr(scope, "name", scope)), safe_id) - config.unset_service_type(service) - logger.info("UC1 rollback: unset client type (service %s)", safe_id) config.set_service_enabled(service, False) logger.info("UC1 rollback: disabled client — failed-service marker (service %s)", safe_id) -def reenable_service(service_id: str) -> None: +def reenable_service(service_id: ServiceUuid) -> None: """Re-enable the Keycloak client (UC1-only, idempotent), clearing any prior failed-disable marker. The **caller** (Controller route or NATS consumer) invokes this AFTER a successful @@ -170,23 +179,34 @@ def reenable_service(service_id: str) -> None: config.set_service_enabled(config.get_service(service_id), True) -def onboard_service( - service_id: str, default_effect: RuleEffect = RuleEffect.DENY -) -> tuple[list[PolicyRule], bool, RuleEffect]: - """Sequence Provision → Policy Builder and return ``(rules, override=False, default_effect)``. - - On any of the four typed build failures (see ``_ROLLBACK_ERRORS``) the Orchestrator runs the - compensating :func:`_rollback` (UC1-only) and **re-raises** the original error unchanged. On +def onboard_service(service_id: ServiceUuid) -> tuple[list[PolicyRule], bool, ClientId]: + """Sequence Provision → Policy Builder and return ``(rules, override=False, client_id)``. + + ``service_id`` is the Keycloak internal client UUID that the trigger carries. The Orchestrator + reads the ``Service`` from the IdP **once**, before Provision, and resolves its clientId + (``Service.serviceId``) — the only service id the PCE takes. The caller passes the returned + ``client_id`` to ``compute_and_apply`` as ``focus_service``, so it makes no second IdP read. If + the read fails, nothing exists yet that needs compensation: it raises ``HTTPException(502)`` + (as Provision's ``classify_service`` does) before Provision. + + On any of the four typed build failures (see ``_ROLLBACK_ERRORS``, for agents and tools, on the + first failure — also the retryable ``LLMAccessError``) the Orchestrator runs the compensating + :func:`_rollback` (delete this run's created roles/scopes; disable the client last), then the + PCE's ``quarantine(client_id, created_roles)`` (delete the SPM, remove the service's roles — + including the created roles the rollback deleted — from the other SPMs, + replace an agent's CR with a no-rules CR, re-derive the affected agents), and **re-raises** the + original error unchanged. The disable comes before the quarantine, so no run after the teardown + sees the service as enabled. The quarantine runs even when the rollback raises, so a failed + rollback never leaves a first onboarding fail-open. A rollback or quarantine failure + propagates in place of the build error (the build error stays on its ``__context__``): the + compensation failure is not in the consumer's permanent set, so NATS redelivers and the next + run tries the compensation again. If the build error won, a permanent build error would + ``term()`` the message and leave the half-compensated service fail-open. The + quarantine is lifted only by a successful re-onboarding. On success it does **not** re-enable the client here: the client is re-enabled by the caller via :func:`reenable_service`, but only AFTER the caller's ``compute_and_apply`` (PCE) call succeeds, so a PCE failure leaves the client disabled rather than enabled-with-no-policy. - ``default_effect`` is passed straight back to the Controller so it reaches the single - ``compute_and_apply`` call and lands on every derived ``AgentPolicyModel``. It defaults to - ``DENY`` (least-privilege); a caller onboarding a service that should default to ``ALLOW`` - supplies it here. This is the caller-facing surface for requesting a permissive default - end-to-end (onboard → PCE → derived APM → OPA). - The full provision → build → rollback lifecycle is serialized per ``service_id`` (see :func:`_service_lock`): a concurrent same-service run cannot corrupt the created-manifest or roll back a shared entity, while different service_ids run concurrently. The registry @@ -194,16 +214,30 @@ def onboard_service( both the success and failure paths, without breaking serialization. This is an in-process lock (one agent replica only); cross-replica serialization is out of scope.""" with _service_lock(service_id): + config = _config() + try: + service = config.get_service(service_id) + except Exception as e: + # The same boundary as Provision's classify_service: an IdP outage or an unknown UUID + # is a 502, not a raw error that the Controller turns into a 500. + raise HTTPException(502, f"IdP config unavailable resolving service {service_id!r}: {e}") from e + client_id = ClientId(service.serviceId) + provision = build_provision_graph().invoke(OnboardingProvisionState(trigger=Trigger(entity_id=service_id))) service_type = provision["service_type"] created_roles = provision["created_roles"] created_scopes = provision["created_scopes"] - config = _config() try: rules = ServicePolicyBuilder.build(service_id, service_type) except _ROLLBACK_ERRORS: - _rollback(config, service_id, created_roles, created_scopes) + try: + _rollback(config, service, created_roles, created_scopes) + finally: + # A failed rollback must not leave a first onboarding fail-open. + # Pass the created roles: the rollback deleted them from the IdP, so the quarantine + # cannot find them in the catalog, but their grants can be on other SPMs. + quarantine(client_id, created_roles) raise - return rules, False, default_effect + return rules, False, client_id diff --git a/src/aiac/agent/uc/onboarding/provision/nodes.py b/src/aiac/agent/uc/onboarding/provision/nodes.py index af7cdd8e..2052b974 100644 --- a/src/aiac/agent/uc/onboarding/provision/nodes.py +++ b/src/aiac/agent/uc/onboarding/provision/nodes.py @@ -100,13 +100,47 @@ def _discovery_token(service_id: str) -> str: # block the onboarding request indefinitely (there was previously no timeout). _MCP_TIMEOUT = (5, 30) +# How long discovery waits for the tool's MCP endpoint to become ready. The operator registers the +# tool's Keycloak client (which fires the onboarding event) while it still rolls the tool pod onto +# the AuthBridge-injected template, so the Service can have no ready endpoint for some seconds +# ("connection refused"), or the sidecar can answer 502/503/504 while the app starts. Failing here +# is retryable, but the NATS redelivery comes only after ACK_WAIT (600 s), so discovery waits for +# the endpoint instead. Read from the env at call time; keep it well below ACK_WAIT. +_MCP_READY_TIMEOUT_ENV = "AIAC_MCP_DISCOVERY_READY_TIMEOUT" +_MCP_READY_TIMEOUT_DEFAULT = 120.0 +_MCP_READY_INTERVAL = 3.0 # seconds between readiness attempts (patched in unit tests) + + +def _mcp_ready_timeout() -> float: + """The discovery readiness budget in seconds; an unset, non-numeric or negative value falls + back to the default.""" + try: + value = float(os.getenv(_MCP_READY_TIMEOUT_ENV, str(_MCP_READY_TIMEOUT_DEFAULT))) + except (TypeError, ValueError): + return _MCP_READY_TIMEOUT_DEFAULT + return value if value >= 0 else _MCP_READY_TIMEOUT_DEFAULT + + +def _endpoint_not_ready(exc: Exception) -> bool: + """True for the failures of an MCP endpoint that is not ready yet: a connection-level error + (refused, reset, connect timeout) or a 502/503/504 from the sidecar. A read timeout (the tool + accepted the connection and hangs) and any other status are not waited for.""" + import requests + + if isinstance(exc, requests.ConnectionError): # includes ConnectTimeout + return True + response = getattr(exc, "response", None) + return isinstance(exc, requests.HTTPError) and getattr(response, "status_code", None) in (502, 503, 504) + def _mcp_tools_list(endpoint: str, token: str | None = None) -> list[dict]: """POST a JSON-RPC `tools/list` to an MCP endpoint and return the tool manifest list. Each tool is a dict with `name` and (optional) `description`. When `token` is provided it is sent as an `Authorization: Bearer` header (the tool's MCP endpoint is fronted by an AuthBridge sidecar that validates inbound JWTs). Bounded transport retries are applied here so callers - just map the final failure to a 502.""" + just map the final failure to a 502. Around them, an endpoint that is not ready yet (see + ``_endpoint_not_ready``) is tried again every ``_MCP_READY_INTERVAL`` seconds until the + ``AIAC_MCP_DISCOVERY_READY_TIMEOUT`` budget ends (default 120 s).""" import requests def _do(): @@ -122,7 +156,14 @@ def _do(): resp.raise_for_status() return (resp.json().get("result") or {}).get("tools", []) - return run_upstream(_do) + deadline = time.monotonic() + _mcp_ready_timeout() + while True: + try: + return run_upstream(_do) + except requests.RequestException as exc: + if not _endpoint_not_ready(exc) or time.monotonic() >= deadline: + raise + time.sleep(_MCP_READY_INTERVAL) def _select_pod(pods, workload_name: str): diff --git a/src/aiac/idp/configuration/api.py b/src/aiac/idp/configuration/api.py index 02d29326..7cff7e46 100644 --- a/src/aiac/idp/configuration/api.py +++ b/src/aiac/idp/configuration/api.py @@ -196,23 +196,6 @@ def set_service_type(self, service: Service, service_type: ServiceType) -> Servi ) return Service.model_validate(resp.json()) - def unset_service_type(self, service: Service) -> Service: - """Clear a service's type — consumed by the UC1 rollback. - - Reuses the ``set_service_type`` transport: ``POST /services/{service.id}/type`` with an - empty/clear type body. The config service clears the ``client.type`` attribute with the - same read-merge-``update_client`` pattern ``set_service_type`` uses. Idempotent — clearing - an already-clear type is not an error. Raises ``RuntimeError`` on a non-OK status. Returns - the updated ``Service`` (``type`` now ``None``). - """ - resp = self._request( - "POST", - f"/services/{service.id}/type", - json={"type": ""}, - params=self._params(), - ) - return Service.model_validate(resp.json()) - def set_service_enabled(self, service: Service, enabled: bool) -> Service: """The **writer** for ``Service.enabled`` — consumed by the UC1 rollback (disable a failed service's client) and the success re-enable path. diff --git a/src/aiac/idp/configuration/models.py b/src/aiac/idp/configuration/models.py index 7b30afbf..dd287082 100644 --- a/src/aiac/idp/configuration/models.py +++ b/src/aiac/idp/configuration/models.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any +from typing import Any, NewType from pydantic import BaseModel, ConfigDict, model_validator @@ -100,6 +100,18 @@ def _validate_kind_and_actor_ids(self) -> "Role": return self +# The two ids Keycloak gives every client. They cannot merge: after an offboard the client is gone, so +# UUID→clientId resolution is impossible. The UUID is only for finding the service in the IdP; +# inside the PCE every service id is the clientId. +ServiceUuid = NewType("ServiceUuid", str) +"""``Service.id`` — the Keycloak internal client UUID (slash-free). Carried by ``CLIENT_CREATED``, +``/apply/service/{uuid}``, UC1 Provision and the PRB.""" + +ClientId = NewType("ClientId", str) +"""``Service.serviceId`` — the Keycloak ``clientId`` (a SPIFFE ID, can contain ``/``). The SPM key, +``PolicyRule.scope.serviceId``, the ``decommission`` key and OPA ``input.identity.service_id``.""" + + class Service(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/src/aiac/idp/service/configuration/keycloak/main.py b/src/aiac/idp/service/configuration/keycloak/main.py index d4acc6dd..91517c8f 100644 --- a/src/aiac/idp/service/configuration/keycloak/main.py +++ b/src/aiac/idp/service/configuration/keycloak/main.py @@ -432,7 +432,7 @@ def delete_role_from_service(service_id: str, role_id: str, admin: KeycloakAdmin # Idempotent teardown: an already-gone role or mapping surfaces as 404 # (python-keycloak raises KeycloakGetError/KeycloakDeleteError with # response_code 404). A UC1 rollback retry must treat that as success, so it - # proceeds to unset-type + disable instead of aborting here. Other errors → 502. + # proceeds to the disable instead of aborting here. Other errors → 502. if getattr(e, "response_code", None) == 404: return JSONResponse(status_code=200, content={}) return JSONResponse(status_code=502, content={"error": str(e)}) @@ -516,7 +516,7 @@ def delete_scope_from_service(service_id: str, scope_id: str, admin: KeycloakAdm except KeycloakError as e: # Idempotent teardown: an already-gone scope or mapping surfaces as 404 # (python-keycloak raises KeycloakDeleteError with response_code 404). A UC1 - # rollback retry must treat that as success, so it proceeds to unset-type + + # rollback retry must treat that as success, so it proceeds to the # disable instead of aborting here. Other errors → 502. if getattr(e, "response_code", None) == 404: return JSONResponse(status_code=200, content={}) diff --git a/src/aiac/pdp/service/policy/opa/main.py b/src/aiac/pdp/service/policy/opa/main.py index 8dca8e98..c840eee4 100644 --- a/src/aiac/pdp/service/policy/opa/main.py +++ b/src/aiac/pdp/service/policy/opa/main.py @@ -256,7 +256,10 @@ def _op(): return _run_write(_op) -@app.post("/policy/agents/{agent_id}", status_code=204) +# Both by-id routes take ``{agent_id:path}``: the id is a SPIFFE URI or ``/``, and the +# server decodes the library's ``%2F`` back to ``/`` before routing, so a single-segment +# ``{agent_id}`` never matches it (HTTP 404). +@app.post("/policy/agents/{agent_id:path}", status_code=204) def upsert_agent(agent_id: str, model: AgentPolicyModel): # The ``{agent_id}`` path segment is intentionally ignored: the request # body is authoritative. ``_upsert_agent`` derives namespace/name from @@ -267,7 +270,7 @@ def upsert_agent(agent_id: str, model: AgentPolicyModel): return _run_write(lambda: _upsert_agent(model)) -@app.delete("/policy/agents/{agent_id}", status_code=204) +@app.delete("/policy/agents/{agent_id:path}", status_code=204) def delete_agent(agent_id: str): return _run_write(lambda: _delete_agent(agent_id)) diff --git a/src/aiac/pdp/service/policy/opa/rego.py b/src/aiac/pdp/service/policy/opa/rego.py index 29bf8e09..a9e5d817 100644 --- a/src/aiac/pdp/service/policy/opa/rego.py +++ b/src/aiac/pdp/service/policy/opa/rego.py @@ -17,19 +17,36 @@ client (outbound). - ``input.identity.service_id`` — the downstream target audience the exchanged token was minted for (a full SPIFFE ID); outbound only. +- ``input.mcp.method`` — the MCP JSON-RPC method (``tools/call``, ``tools/list``, + …); outbound only. - ``input.mcp.params.name`` — the **bare** invoked MCP tool name (e.g. - ``source-read``); outbound only. A missing ``params.name`` (e.g. ``tools/list``) - or an absent ``service_id`` matches nothing and is therefore denied. + ``source-read``); outbound only, carried by ``tools/call``. -**ALLOW/DENY (deny-overrides).** Each gate is emitted twice — an ``*_allow_ok`` -gate driven by the ALLOW scope maps and a symmetric ``*_deny_ok`` gate driven by -the DENY scope maps. A request is permitted iff every ALLOW gate passes and no -DENY gate matches:: +**ALLOW gates and DENY gates, always deny by default.** Each gate is emitted +twice — an ``*_allow_ok`` gate driven by the ALLOW scope maps and a symmetric +``*_deny_ok`` gate driven by the DENY scope maps. ``default allow := false``: a +request that no rule allows is denied. A request is permitted iff every ALLOW +gate passes and no DENY gate matches:: # inbound allow if { subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok } - # outbound - allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok } + # outbound — tools/call, per invoked tool + allow if { input.mcp.method == "tools/call"; subject_allow_ok; target_allow_ok; + not subject_deny_ok; not target_deny_ok } + # outbound — the MCP session messages (no tool name) + allow if { input.mcp.method in session_methods; + some tool in target_allow_scopes[input.identity.service_id]; tool_ok(tool) } + +**Outbound MCP session.** ``initialize``, ``notifications/initialized``, ``ping`` +and ``tools/list`` carry no tool name. They are allowed to a target iff at least +one tool of that target passes the same per-tool check as ``tools/call`` +(``tool_ok``: the user gate allows it, the target gate allows it, no deny vetoes +it). The caller then sees the target's whole tool list, but can call only its +granted tools. Every other MCP method (and a request with no ``tools/call`` +method) is denied. The four outbound gates are one set of Rego functions over a +tool name (``subject_allows`` / ``subject_denies`` / ``target_allows`` / +``target_denies``); the named ``*_ok`` gates apply them to +``input.mcp.params.name``. The identity maps (``subject_roles`` / ``source_roles``) are **effect-agnostic**, so a principal that appears only in a DENY rule still resolves and its @@ -39,7 +56,7 @@ import json import re -from aiac.policy.model.models import AgentPolicyModel, PolicyRule, RuleEffect +from aiac.policy.model.models import AgentPolicyModel, PolicyRule __all__ = ["identity_ref", "generate_inbound_rego", "generate_outbound_rego"] @@ -235,64 +252,71 @@ def _inbound_source_deny_gate() -> str: # --- outbound gate templates ------------------------------------------------ # -# The outbound decision is a per-tool two-gate AND, both keyed on the invoked -# tool ``input.mcp.params.name`` (the delegated user reaching a downstream -# target): -# subject gate — the delegated user's role admits the invoked tool -# capability gate — the target service admits the invoked tool -# Each gate is emitted twice (allow/deny). ``allow`` is deny-overrides: both -# ALLOW gates pass on the invoked tool and neither DENY gate matches it. +# The outbound decision is a per-tool two-gate AND (the delegated user reaching a +# downstream target): +# subject gate — the delegated user's role admits the tool +# capability gate — the target service admits the tool +# Each gate is emitted twice (allow/deny) as a Rego FUNCTION over a bare tool +# name, so ``tools/call`` (the invoked ``input.mcp.params.name``) and the MCP +# session (any tool of the target) use one definition of the per-tool check. +# ``allow`` holds only when both ALLOW gates pass on the tool and neither DENY gate +# matches it. +# The MCP messages that carry no tool name and open / keep a session with a target. +_SESSION_METHODS = ("initialize", "notifications/initialized", "ping", "tools/list") -def _outbound_subject_gate(gate: str, scope_map: str) -> str: + +def _outbound_subject_gate(fn: str, gate: str, scope_map: str) -> str: return ( - f"{gate} if {{\n" + f"{fn}(tool) if {{\n" " some role in subject_roles[input.identity.subject]\n" - f" input.mcp.params.name in {scope_map}[role]\n" - "}" + f" tool in {scope_map}[role]\n" + "}\n" + f"{gate} if {{ {fn}(input.mcp.params.name) }}" ) -def _outbound_target_gate(gate: str, scope_map: str) -> str: - return f"{gate} if {{\n input.mcp.params.name in {scope_map}[input.identity.service_id]\n}}" +def _outbound_target_gate(fn: str, gate: str, scope_map: str) -> str: + return ( + f"{fn}(tool) if {{\n tool in {scope_map}[input.identity.service_id]\n}}\n" + f"{gate} if {{ {fn}(input.mcp.params.name) }}" + ) -# --- trailing decision block (the only thing default_effect changes) -------- +def _outbound_tool_ok() -> str: + """The full per-tool check as one function: ``tool_ok(tool)``.""" + return ( + "tool_ok(tool) if {\n" + " subject_allows(tool)\n" + " target_allows(tool)\n" + " not subject_denies(tool)\n" + " not target_denies(tool)\n" + "}" + ) + + +# --- trailing decision block ------------------------------------------------- # # CRITICAL: the generator assumes disjoint ALLOW/DENY per (role, scope). A # genuine grant/deny overlap on the same pair is an upstream policy conflict # surfaced as HTTP 422 (PRB ``PolicyContradictionError``) and is NEVER -# reconciled here. The ``allow := false if { }`` rules below are not -# conflict reconciliation: they give an explicit deny precedence over a -# permissive default, and resolve co-occurring-but-disjoint denies at request -# time (a subject holding multiple roles; the outbound two-gate decision) — -# each individual (role, scope) stays allow-XOR-deny. - - -def _decision_block(default_effect: RuleEffect, allow_body: str, deny_gates: tuple[str, ...]) -> str: - """Render the trailing ``allow`` decision — the *only* part that varies by mode. +# reconciled here. The inline ``not …_deny_ok`` guards resolve +# co-occurring-but-disjoint denies at request time (a subject holding multiple +# roles; the outbound two-gate decision) — each individual (role, scope) stays +# allow-XOR-deny. - ``DENY`` (least-privilege) reproduces today's output byte-for-byte: - ``default allow := false`` plus the single ``allow if { }`` rule - (an allow-conjunction with inline ``not …_deny_ok`` guards). - ``ALLOW`` opens the default and lets explicit denies override: ``default - allow := true`` plus one ``allow := false if { }`` rule per deny gate. - A literal flip of the constant alone is insufficient — an incremental - ``allow if { … }`` body can only push ``allow`` toward ``true``, so the deny - guards must become separate ``allow := false if`` rules to pull it back down - (deny-overrides over a permissive default).""" - if default_effect == RuleEffect.ALLOW: - lines = ["default allow := true"] - lines += [f"allow := false if {{ {gate} }}" for gate in deny_gates] - return "\n".join(lines) - return "default allow := false\n" + f"allow if {{ {allow_body} }}" +def _decision_block(*allow_bodies: str) -> str: + """Render the trailing ``allow`` decision: ``default allow := false`` plus one + ``allow if { }`` rule per body (each an allow-conjunction with inline ``not …_deny_ok`` + guards). The default is always DENY — a request that no rule allows is denied.""" + return "\n".join(["default allow := false"] + [f"allow if {{ {body} }}" for body in allow_bodies]) def generate_inbound_rego(model: AgentPolicyModel, platform_clients: tuple[str, ...] = ("rossoctl",)) -> str: """Render the fixed ``authbridge.client.inbound.request`` Rego package. - Gates a caller reaching the agent. The decision is deny-overrides: + Gates a caller reaching the agent. A matching DENY gate blocks the request: ``allow`` requires ``subject_allow_ok`` (the subject holds a role granting >=1 of ``agent_scopes`` via the ALLOW map) AND ``source_allow_ok``, and fires only when neither ``subject_deny_ok`` nor ``source_deny_ok`` matches. @@ -331,15 +355,7 @@ def generate_inbound_rego(model: AgentPolicyModel, platform_clients: tuple[str, _inbound_subject_gate("subject_deny_ok", "subject_role_deny_scopes"), _inbound_source_allow_gate(platform_clients), _inbound_source_deny_gate(), - # Branch ONLY the trailing decision block on model.default_effect. Under - # ALLOW the allow gates / allow scope maps above are inert-but-emitted - # (kept for structural symmetry and downstream tooling); the decision - # is deny-if-either-side. - _decision_block( - model.default_effect, - "subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok", - ("subject_deny_ok", "source_deny_ok"), - ), + _decision_block("subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok"), ] ) parts = [ @@ -355,7 +371,7 @@ def generate_outbound_rego(model: AgentPolicyModel) -> str: """Render the fixed ``authbridge.client.outbound.request`` Rego package. Gates the agent's token-exchanged call to a downstream target, per invoked - tool. The decision is deny-overrides on the **same** ``input.mcp.params.name``: + tool. A matching DENY gate blocks the request, on the **same** ``input.mcp.params.name``: ``allow`` requires ``subject_allow_ok`` (the delegated user's role admits the tool, via de-prefixed ``subject_role_allow_scopes``) AND ``target_allow_ok`` (the target service — keyed by the full ``input.identity.service_id`` SPIFFE @@ -366,6 +382,11 @@ def generate_outbound_rego(model: AgentPolicyModel) -> str: **not** referenced by ``allow`` — ``target_allow_scopes[input.identity.service_id]`` already *is* the capability gate. This package emits neither ``agent_scopes`` nor the inbound scope gates. + + A ``tools/call`` is checked per invoked tool. The MCP session messages + (``initialize``, ``notifications/initialized``, ``ping``, ``tools/list``) carry + no tool name; they are allowed to a target iff at least one tool of that target + passes the same per-tool check (``tool_ok``). Every other method is denied. """ declarations = "\n".join( [ @@ -389,24 +410,21 @@ def generate_outbound_rego(model: AgentPolicyModel) -> str: ), _render_map("target_allow_scopes", _name_map_deprefixed(model.target_allow_scopes)), _render_map("target_deny_scopes", _name_map_deprefixed(model.target_deny_scopes)), + "session_methods := {" + ", ".join(json.dumps(m) for m in _SESSION_METHODS) + "}", ] ) rules = "\n".join( [ - _outbound_subject_gate("subject_allow_ok", "subject_role_allow_scopes"), - _outbound_subject_gate("subject_deny_ok", "subject_role_deny_scopes"), - _outbound_target_gate("target_allow_ok", "target_allow_scopes"), - _outbound_target_gate("target_deny_ok", "target_deny_scopes"), - # Branch ONLY the trailing decision block on model.default_effect. - # Under ALLOW this drops the old subject_allow_ok AND target_allow_ok - # conjunction (deny-if-either-side): a negated allow-gate AND would - # wrongly DENY every unmentioned pair. An unmentioned (role, tool) - # pair falls through to the permissive default; an explicit deny on - # EITHER gate overrides it. + _outbound_subject_gate("subject_allows", "subject_allow_ok", "subject_role_allow_scopes"), + _outbound_subject_gate("subject_denies", "subject_deny_ok", "subject_role_deny_scopes"), + _outbound_target_gate("target_allows", "target_allow_ok", "target_allow_scopes"), + _outbound_target_gate("target_denies", "target_deny_ok", "target_deny_scopes"), + _outbound_tool_ok(), _decision_block( - model.default_effect, + 'input.mcp.method == "tools/call"; ' "subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok", - ("subject_deny_ok", "target_deny_ok"), + "input.mcp.method in session_methods; " + "some tool in target_allow_scopes[input.identity.service_id]; tool_ok(tool)", ), ] ) diff --git a/src/aiac/policy/computation/__init__.py b/src/aiac/policy/computation/__init__.py index 4b51b593..c277a978 100644 --- a/src/aiac/policy/computation/__init__.py +++ b/src/aiac/policy/computation/__init__.py @@ -1,3 +1,3 @@ -from aiac.policy.computation.engine import compute_and_apply, decommission +from aiac.policy.computation.engine import compute_and_apply, decommission, quarantine -__all__ = ["compute_and_apply", "decommission"] +__all__ = ["compute_and_apply", "decommission", "quarantine"] diff --git a/src/aiac/policy/computation/engine.py b/src/aiac/policy/computation/engine.py index 5415182d..c8e04cf9 100644 --- a/src/aiac/policy/computation/engine.py +++ b/src/aiac/policy/computation/engine.py @@ -30,18 +30,49 @@ other_scope`` edges on *other* SPMs) would linger, and its ``APM(X)`` would stay in the PDP. ``decommission(service_id)`` is the authoritative counterpart: it acts on an explicit offboard signal (not the catalog-miss guard), tears down X's entire footprint, and re-derives every agent -whose policy changed. It is keyed by the **clientId (SPM key)**, not the Keycloak UUID — after the -client is deleted, ``get_services()`` can no longer resolve UUID→clientId. - -Fire-and-forget — ``compute_and_apply`` and ``decommission`` re-raise dependency failures. +whose policy changed. + +Service ids. Every service id the PCE takes is the **clientId** (``Service.serviceId``, the SPM key, +type ``ClientId``) — never the Keycloak internal UUID (``ServiceUuid``). The UUID is only for finding +the service in the IdP. The asymmetry stays at the HTTP/NATS boundary: an onboarding comes in with a +UUID, and the Orchestrator resolves the clientId once while the client still exists; an offboard +comes in with the clientId, because after the client is deleted UUID→clientId resolution is +impossible. + +Quarantine. ``quarantine(service_id, deleted_roles)`` is the UC1 failure-path counterpart of +``decommission``: a failed onboarding leaves the service in the catalog (disabled). It tears down +the same store footprint, but replaces an agent's CR with a no-rules CR (deny everything) instead +of deleting it. ``compute_and_apply``'s routing guard then drops every later +rule that touches a disabled service, so a build that started before the quarantine cannot write +its rules back. + +Fire-and-forget — ``compute_and_apply``, ``decommission`` and ``quarantine`` re-raise dependency +failures. + +Serialization (the PCE lock). Every public operation reads SPMs, changes them, and writes them +back. The store has no versions, and its own write lock protects one write, not a +read-modify-write. So two runs that route rules into one shared SPM (for example two agents +granted on one tool's scope) would both read the old SPM, and the second write would silently +remove the first run's rules. One module-level lock, ``_pce_lock``, is held for the whole body of +``compute_and_apply``, ``decommission`` and ``quarantine``. The PRB (the LLM work) runs before +``compute_and_apply``, outside the lock, so concurrent onboardings still build their rules in +parallel; the part under the lock makes no LLM call. Known limits: + +- It serializes one process only (one Controller replica, as for the orchestrator's per-service + lock). More replicas need store versions or a distributed lock. +- When two onboardings overlap, a pair between the two new services can stay unjudged (each + service's resolver read the catalog before the other's Provision). That pair then gives no + grant (fail closed). """ import logging +import threading +from collections.abc import Iterable from typing import TypeVar from aiac.idp.configuration.api import Configuration -from aiac.idp.configuration.models import Role, RoleKind, Scope, Service, ServiceType -from aiac.pdp.policy.library.api import apply_policy, delete_agent_policy +from aiac.idp.configuration.models import ClientId, Role, RoleKind, Scope, Service, ServiceType +from aiac.pdp.policy.library.api import apply_agent_policy, apply_policy, delete_agent_policy from aiac.policy.model.models import ( AgentPolicyModel, PolicyModel, @@ -58,6 +89,9 @@ logger = logging.getLogger(__name__) +# The PCE lock — see "Serialization" in the module docstring. +_pce_lock = threading.Lock() + _Entity = TypeVar("_Entity", Role, Scope) @@ -217,14 +251,11 @@ def is_agent(service_id: str) -> bool: return spms, spm, is_agent -def _fresh_apm(agent_id: str, default_effect: RuleEffect = RuleEffect.DENY) -> AgentPolicyModel: +def _fresh_apm(agent_id: str) -> AgentPolicyModel: # Identity/aggregate maps are the only required fields; the split target maps and the eight - # entity x effect rule lists default to empty and are filled by ``_derive``. ``default_effect`` - # rides through onto the derived projection (see ``_derive``); it defaults to ``DENY`` so every - # existing caller keeps today's least-privilege behavior. + # entity x effect rule lists default to empty and are filled by ``_derive``. return AgentPolicyModel( agent_id=agent_id, - default_effect=default_effect, agent_roles=[], agent_scopes=[], source_roles={}, @@ -235,7 +266,7 @@ def _fresh_apm(agent_id: str, default_effect: RuleEffect = RuleEffect.DENY) -> A def compute_and_apply( rules: list[PolicyRule], override: bool = False, - default_effect: RuleEffect = RuleEffect.DENY, + focus_service: ClientId | None = None, ) -> None: """Route, persist, derive, and apply ``rules`` — fire-and-forget. @@ -246,37 +277,39 @@ def compute_and_apply( input-role set is purged from **both** inbound lists of **every** SPM containing it, once, up-front, before the fresh rules are appended (role-level revocation). - ``default_effect`` is stamped onto **every** ``AgentPolicyModel`` this run derives — it decides - how the deployed Rego treats a ``(role, scope)`` pair that no rule mentions. It defaults to - ``DENY`` (today's least-privilege behavior), so all existing call sites — the four Controller - ``/apply/*`` routes and the eventbus consumer — keep compiling unchanged; a caller opts into - ``ALLOW`` explicitly (e.g. the onboarding path forwarding a caller-requested value). + There is no default effect to pass: the deployed Rego always denies a ``(role, scope)`` pair + that no rule mentions, so every re-derivation of an agent gives the same behavior. - NON-DURABILITY CAVEAT: ``AgentPolicyModel`` is a pure derived projection, rebuilt from the - persisted SPMs on every relevant recompute — ``default_effect`` is **not** persisted here. A - later, *unrelated* recompute that re-derives the same agent (another onboarding, a role update) - rebuilds its APM with ``DENY`` unless that call also passes ``ALLOW``. Making the value survive - independent re-derivation would require persisting it on ``ServicePolicyModel`` — a separate, - out-of-scope decision. + Routing guard. A disabled client is a failed (quarantined) service; a service absent from the + catalog is deleted. Under the PCE lock, after the catalog read, the run drops each rule whose + scope owner, or the owner of whose agent role (``role.actorIds``), is disabled or absent, so a + build that started before a quarantine or an offboard cannot write rules back into the removed + footprint. The run also derives only agents that are in the catalog. ``focus_service`` — the + clientId (``Service.serviceId``, the SPM key) of the service this onboarding builds, not its + Keycloak UUID — is exempt: a re-onboarding applies while its client is still disabled + (``reenable_service`` runs after the apply). The onboarding route and the NATS consumer pass the + clientId that ``onboard_service`` returns; every other caller passes nothing, so every rule that + touches a disabled service is dropped. Exceptions from any dependency (IdP, Policy Store, PDP) are logged and **re-raised** so the caller (the Controller) surfaces the failure — e.g. as a 500 — instead of returning success while silently applying nothing. """ try: - _run(rules, override, default_effect) + with _pce_lock: + _run(rules, override, focus_service) except Exception: logger.exception("compute_and_apply failed for %d rule(s)", len(rules)) raise -def decommission(service_id: str) -> None: +def decommission(service_id: ClientId) -> None: """Authoritatively remove a decommissioned service's entire policy footprint. - ``service_id`` is the **clientId (the SPM key)**, not the Keycloak internal UUID: an offboarded - client is gone from ``get_services()``, so UUID→clientId resolution is impossible — the offboard - contract carries the clientId directly (the documented asymmetry with onboard's - ``/apply/service/{uuid}``). + ``service_id`` is the **clientId (the SPM key)**, as for every PCE function. An offboarded client + is gone from the IdP, so the offboard contract carries the clientId directly; an onboarding + resolves it from its UUID in the Orchestrator. The asymmetry is only at the HTTP/NATS boundary, + not in the PCE. Tears down everything reconcile's catalog-anchored GC cannot: deletes ``SPM(X)`` (removing every user→X and agent→X inbound edge), purges X's **outbound footprint** (``X_role → other_scope`` @@ -288,31 +321,105 @@ def decommission(service_id: str) -> None: Controller surfaces the failure instead of reporting a phantom success. """ try: - _decommission(service_id) + with _pce_lock: + _decommission(service_id) + except Exception: + logger.exception("decommission failed for service %r", _loggable(service_id)) + raise + + +def _disabled_services(catalog: dict[str, Service], focus_service: str | None) -> set[str]: + """The ``serviceId`` of every disabled (quarantined) service in ``catalog``, except the focus + service (given by its clientId, the catalog key).""" + return {sid for sid, svc in catalog.items() if not svc.enabled and sid != focus_service} + + +def _live_services(catalog: dict[str, Service], focus_service: str | None = None) -> set[str]: + """The ``serviceId`` of every live service: in ``catalog`` and not disabled (the focus service + counts as live). Only a live agent is derived: an agent absent from the catalog is deleted, and a + disabled one is quarantined — its no-rules CR must stay until a re-onboarding replaces it.""" + return catalog.keys() - _disabled_services(catalog, focus_service) + + +def _routable(rule: PolicyRule, catalog: dict[str, Service], disabled: set[str]) -> bool: + """True iff every service ``rule`` touches — its scope owner, and the owner of its agent role — + is live: present in ``catalog`` and not in ``disabled``. A service absent from the catalog is + gone (its client was deleted, e.g. offboarded while its onboarding was still building).""" + owners = {rule.scope.serviceId} + if rule.role.kind == RoleKind.AGENT: + owners.update(rule.role.actorIds) + return all(owner in catalog and owner not in disabled for owner in owners) + + +def quarantine(service_id: ClientId, deleted_roles: Iterable[Role] = ()) -> None: + """Tear down a failed onboarding's policy footprint — the UC1 failure path (after the rollback). + + ``service_id`` is the **clientId (the SPM key, ``Service.serviceId``)**, as for ``decommission`` + — not the Keycloak internal UUID. The orchestrator resolves it from the onboarding's UUID while + the client still exists. The failed service X is still in the catalog (disabled). Holds the PCE + lock. For X: + + 1. delete ``SPM(X)`` from the store; + 2. remove X's roles from the other SPMs (as ``decommission`` step 4 does) — the roles X still + has in the catalog, plus ``deleted_roles``: the roles the rollback already deleted from the + IdP (the run's created-manifest). The catalog no longer lists those, but a concurrent run can + have stored their grants on another SPM, which would keep allowing X. A role that another + service also holds (a realm role reused by name) is kept: its grants are that service's too; + 3. for an agent, **replace** its CR with a CR that has no rules (the ``_fresh_apm`` shell), which + denies every request — not a delete, because the bundle-service combiner allows a missing CR; + a tool gets no CR (it has none); + 4. re-derive the affected live agents (those that targeted X, and those whose SPMs lost X's + roles; not a deleted or disabled agent) and apply them in one call. + + Idempotent: a second call finds no SPM and no edges, and writes the same no-rules CR. A + ``service_id`` that is not in the catalog is a logged no-op. The quarantine is lifted only by a + successful re-onboarding (its ``compute_and_apply`` writes the real CR over the no-rules CR with + the same SSA field manager, then ``reenable_service`` re-enables the client). + + Exceptions from any dependency are logged and **re-raised**. + """ + try: + with _pce_lock: + _quarantine(service_id, list(deleted_roles)) except Exception: - # Strip CR/LF before logging so a hostile service_id cannot forge log records. - safe_id = service_id.replace("\r", "").replace("\n", "") - logger.exception("decommission failed for service %r", safe_id) + logger.exception("quarantine failed for service %r", _loggable(service_id)) raise -def _run(rules: list[PolicyRule], override: bool, default_effect: RuleEffect = RuleEffect.DENY) -> None: +def _loggable(value: str) -> str: + """Strip CR/LF so a hostile id cannot forge log records.""" + return value.replace("\r", "").replace("\n", "") + + +def _run(rules: list[PolicyRule], override: bool, focus_service: str | None = None) -> None: config = Configuration.for_default_realm() # (1) Catalog once — the only runtime IdP read. Carries each service's type (agent vs tool, # for P4) and its own roles/scopes (embedded on the APM for P2, filtered to aiac.managed). catalog = {svc.serviceId: svc for svc in config.get_services()} - # SPM cache: fetch each SPM from the store at most once, seed its identity from the catalog, - # mutate in place, and persist the changed ones. - spms, spm, is_agent = _spm_cache(catalog) - # Distinct input roles (dedup by id) — the set purged under override and the seed of the - # affected-agent set. + # affected-agent set. Built from the input BEFORE the routing guard: under override, a role + # whose every new rule the guard drops must still lose its old grants. distinct_roles: dict[str, Role] = {} for rule in rules: distinct_roles.setdefault(rule.role.id, rule.role) + # (1.5) Routing guard — drop every rule that touches a service that is not live: disabled + # (quarantined, except the focus service) or absent from the catalog (deleted). See + # ``compute_and_apply``. + disabled = _disabled_services(catalog, focus_service) + kept = [rule for rule in rules if _routable(rule, catalog, disabled)] + if len(kept) != len(rules): + logger.warning( + "routing guard dropped %d rule(s) that touch a disabled or deleted service", len(rules) - len(kept) + ) + rules = kept + + # SPM cache: fetch each SPM from the store at most once, seed its identity from the catalog, + # mutate in place, and persist the changed ones. + spms, spm, is_agent = _spm_cache(catalog) + changed: set[str] = set() # (3) Override — role-level revocation, once up-front, BEFORE any fresh append (so a role @@ -369,10 +476,10 @@ def _run(rules: list[PolicyRule], override: bool, default_effect: RuleEffect = R affected.update(edge.role.actorIds) # (6) Derive each affected agent's APM (zero IdP) and partial-upsert once. Tools get an SPM - # but no APM (P4). - derived = [_derive(agent_id, spm, default_effect) for agent_id in sorted(affected) if is_agent(agent_id)] - if derived: - apply_policy(PolicyModel(agents=derived)) + # but no APM (P4). Only live agents: an agent absent from the catalog is deleted, and its + # stored SPM (or the store's 404 placeholder, typed AGENT) must not bring its CR back — + # removing it is ``decommission``'s job; a disabled agent keeps its no-rules CR. + _apply_derived(affected & _live_services(catalog, focus_service), spm, is_agent) def _decommission(service_id: str) -> None: @@ -390,6 +497,58 @@ def _decommission(service_id: str) -> None: if not (spm_x.owned_roles or spm_x.owned_scopes or spm_x.inbound_allow_rules or spm_x.inbound_deny_rules): return + # (3)-(6) Tear down X's footprint in the store (see ``_remove_footprint``). + was_agent = spm_x.service_type == ServiceType.AGENT + affected = _remove_footprint(service_id, catalog, spms, spm, is_agent) + + # (7) Delete APM(X) from the PDP iff X was an agent (tools have an SPM but no APM). + if was_agent: + delete_agent_policy(service_id) + + # (8) Re-derive every affected live agent (X excluded) in one partial upsert. + _apply_derived(affected & _live_services(catalog), spm, is_agent) + + +def _quarantine(service_id: str, deleted_roles: list[Role]) -> None: + config = Configuration.for_default_realm() + + # (1) Catalog once. X is still in it (the rollback disables the client, it does not delete it). + # spm() seeds X's current roles from the catalog, so step 4 removes the roles X still has. The + # roles the rollback deleted are not in the catalog any more, so the caller passes them in + # ``deleted_roles`` and step 4 removes their edges too — else an edge a concurrent run stored + # (X_role → other_scope) keeps allowing X until a later run reconciles that SPM. + catalog = {svc.serviceId: svc for svc in config.get_services()} + if service_id not in catalog: + logger.warning("quarantine: service %r is not in the IdP catalog — nothing to do", _loggable(service_id)) + return + spms, spm, is_agent = _spm_cache(catalog) + + # (3)-(6) Tear down X's footprint in the store (see ``_remove_footprint``). + was_agent = is_agent(service_id) + affected = _remove_footprint(service_id, catalog, spms, spm, is_agent, deleted_roles) + + # (7) An agent's CR is REPLACED with a no-rules CR (the ``_fresh_apm`` shell), which denies + # every inbound and outbound request. Not a delete, on purpose: the bundle-service combiner + # allows a pod that has no client CR, so a delete would open the agent. A tool has no CR — its + # rules leave its callers' outbound packages through the re-derive below. + if was_agent: + apply_agent_policy(service_id, _fresh_apm(service_id)) + + # (8) Re-derive every affected live agent (X excluded) in one partial upsert. + _apply_derived(affected & _live_services(catalog), spm, is_agent) + + +def _remove_footprint( + service_id: str, catalog: dict[str, Service], spms, spm, is_agent, extra_roles: list[Role] = () +) -> set[str]: + """Tear down service X's footprint in the store — the steps ``decommission`` and ``quarantine`` + share. ``extra_roles`` are X's roles that ``SPM(X)`` no longer lists (``quarantine``'s + rollback-deleted roles); their edges are purged as X's own. A role that another service in + ``catalog`` also holds (a realm role reused by name) is not purged: its grants are that + service's grants too. Returns the affected agents (X excluded): the agents that targeted X, and + the agents whose SPMs lost X's roles.""" + spm_x = spm(service_id) + # (3) Targeters — agents whose outbound loses X: they hold an Agent-kind inbound edge (allow or # deny) on SPM(X) (their_role → X_scope), which vanishes when SPM(X) is deleted in step 5. affected: set[str] = { @@ -399,8 +558,13 @@ def _decommission(service_id: str) -> None: changed: set[str] = set() # (4) Purge X's outbound footprint — X_role → other_scope edges (allow AND deny) stored on OTHER - # services' SPMs. - for role in spm_x.owned_roles: + # services' SPMs. Dedup by id: an extra role can also still be on SPM(X). Skip a role that + # another service holds: the edges are keyed by role id, so a purge would also remove that + # service's grants. X itself stays denied — a quarantined agent's CR has no rules, and an + # offboarded client cannot authenticate. + held_elsewhere = {r.id for sid, svc in catalog.items() if sid != service_id for r in svc.roles} + roles = {role.id: role for role in [*spm_x.owned_roles, *extra_roles] if role.id not in held_elsewhere} + for role in roles.values(): for stored in get_service_policies_by_role(role): if stored.service_id == service_id: continue @@ -412,7 +576,6 @@ def _decommission(service_id: str) -> None: # (5) Delete SPM(X) — removes every user→X and agent→X inbound edge in one shot — and evict it # from the cache so re-derive cannot resurrect it. - was_agent = spm_x.service_type == ServiceType.AGENT delete_service_policy(service_id) spms.pop(service_id, None) @@ -420,16 +583,13 @@ def _decommission(service_id: str) -> None: for changed_id in changed: apply_service_policy(changed_id, spms[changed_id]) - # (7) Delete APM(X) from the PDP iff X was an agent (tools have an SPM but no APM). - if was_agent: - delete_agent_policy(service_id) - - # (8) Re-derive every affected agent (X excluded) from the freshly-persisted, X-deleted store — - # outbound/target_scopes/source_roles referencing X drop automatically. One partial upsert. affected.discard(service_id) - # Re-derive with the ``DENY`` default: decommission carries no caller-supplied ``default_effect``, - # and per the non-durability caveat (``compute_and_apply``) an unrelated recompute like this one - # rebuilds each APM at least-privilege unless ``default_effect`` is persisted on the SPM. + return affected + + +def _apply_derived(affected: set[str], spm, is_agent) -> None: + """Derive each affected agent's APM from the freshly-persisted store and partial-upsert them in + one call (outbound/target_scopes/source_roles referencing a removed service drop out).""" derived = [_derive(agent_id, spm) for agent_id in sorted(affected) if is_agent(agent_id)] if derived: apply_policy(PolicyModel(agents=derived)) @@ -445,19 +605,16 @@ def _register_identity(apm: AgentPolicyModel, edge: PolicyRule) -> None: _add_by_id(target.setdefault(actor, []), edge.role) -def _derive(agent_id, spm, default_effect: RuleEffect = RuleEffect.DENY) -> AgentPolicyModel: +def _derive(agent_id, spm) -> AgentPolicyModel: """Build ``APM(agent_id)`` entirely from the persisted SPMs (zero IdP). Each inbound edge on ``SPM(A)`` is classified by ``role.kind`` (User → subject, Agent → source) **and** ``effect`` (allow/deny) into one of four inbound buckets; each outbound edge (one of A's own roles referenced on another SPM) is classified by ``effect`` into the target allow/deny bucket and grows ``target_allow_scopes`` / ``target_deny_scopes``. Identity/aggregate maps stay - effect-agnostic — a deny-only role or subject still registers into them. - - ``default_effect`` is stamped onto the emitted APM (via ``_fresh_apm``); it is derived data, not - read back from any store (see the non-durability caveat on ``compute_and_apply``).""" + effect-agnostic — a deny-only role or subject still registers into them.""" sa = spm(agent_id) - apm = _fresh_apm(agent_id, default_effect) + apm = _fresh_apm(agent_id) # Identity (P2) — the agent's own aiac.managed roles/scopes, seeded from the catalog. apm.agent_roles = list(sa.owned_roles) diff --git a/src/aiac/policy/model/models.py b/src/aiac/policy/model/models.py index 25eb1f6f..2417a47b 100644 --- a/src/aiac/policy/model/models.py +++ b/src/aiac/policy/model/models.py @@ -67,13 +67,9 @@ class AgentPolicyModel(BaseModel): model_config = ConfigDict(extra="ignore") agent_id: str - # How the deployed Rego treats a (role, scope) pair that NO rule mentions. - # DENY (default) reproduces today's least-privilege `default allow := false`. - # ALLOW opens the default while explicit denies still override (see the OPA - # generator). Effect-agnostic maps and the 8 rule lists are unchanged. - # Defaulting to DENY keeps every existing caller and serialized model - # byte-for-byte compatible (no required-field break). - default_effect: RuleEffect = RuleEffect.DENY + # There is no default-effect field: the deployed Rego always denies a (role, scope) pair + # that NO rule mentions. A legacy payload that still carries ``default_effect`` is accepted + # and ignored (``extra="ignore"``). # Identity / aggregate maps — effect-agnostic (no allow/deny split). A role or subject that # appears **only** in a DENY edge must still be registered here, or the Rego deny lookup # cannot resolve it and the prohibition silently fails to fire. Relationship maps are keyed diff --git a/src/aiac/policy/model_store/library/api.py b/src/aiac/policy/model_store/library/api.py index 03d0925c..41bbaa28 100644 --- a/src/aiac/policy/model_store/library/api.py +++ b/src/aiac/policy/model_store/library/api.py @@ -28,8 +28,9 @@ def _fresh_empty(service_id: str) -> ServicePolicyModel: # The "engine creates a fresh model on 404" convention: the first time a service is # seen the store has no row, so callers get an empty SPM to append to. ``service_type`` # is required by the model but genuinely unknown here; the PCE re-seeds it (along with - # ``owned_roles`` / ``owned_scopes``) from the IdP catalog before it is ever consulted, - # so the placeholder below never reaches a policy decision. + # ``owned_roles`` / ``owned_scopes``) from the IdP catalog when the service is in it. For a + # service absent from the catalog (deleted) the placeholder AGENT type would stay, so the PCE + # drops every rule for such a service and never derives it (routing guard in ``_run``). return ServicePolicyModel( service_id=service_id, service_type=ServiceType.AGENT, diff --git a/test/system/disabled/test_policy_pipeline_denyworld.py b/test/system/disabled/test_policy_pipeline_denyworld.py deleted file mode 100644 index 4487f205..00000000 --- a/test/system/disabled/test_policy_pipeline_denyworld.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Live full-deployment policy-pipeline test for a **DENY-bearing** policy under ``default_effect=ALLOW``. - -The ALLOW-default sibling of ``test_policy_pipeline.py``. It onboards **Policy B** (permissive-default -prose that states only prohibitions + exclusive scoping — ``scenario_uc1_denyworld.POLICY_DENYWORLD``) -through the **same** real UC-1 pipeline, driving nothing mocked: it mounts Policy B's ``policy.md`` and -sets the derived ``AgentPolicyModel.default_effect`` to ``ALLOW`` (the #146 Controller hook the harness -applies), onboards both the ``github-agent`` and the ``github-tool`` through the in-cluster Controller -(``POST /apply/service/{id}``, upserting the ``AuthorizationPolicy`` CR), enables the outbound -token-exchange leg, waits for ``bundle-service`` + the AuthBridge OPA sidecars to recompose and reload -the bundle, then each test drives a **real HTTP request through AuthBridge** and asserts the **real OPA -plugin's** allow/deny against Policy B's §6 matrix (``scenario_uc1_denyworld.py``, the #148 oracle). - -**Why ``default_effect=ALLOW`` — the whole point (handoff §2).** Under the shipped deny-by-default an -explicit ``DENY`` rule is *behaviorally invisible* at the enforced seam: an ungranted ``(role, tool)`` -pair is already denied by the absence of an ALLOW, so ``test_policy_pipeline.py`` would still pass if -the entire DENY-generation machinery silently broke. Flip the default: under ``default=ALLOW`` an -unmentioned pair is **allowed**, so a ``DENY`` rule becomes the *only* thing that can deny a pair. -Dropping any explicit DENY anywhere in the chain (PRB → PCE → Rego → bundle → OPA) flips its cell from -❌ back to ✅ and **fails** this test — the load-bearing DENY-drop property the deny-by-default suite -cannot assert. The ``devops → issues-*`` = ✅ cells are the **default-flip tracer**: they are *deny* -under Policy A (``test_policy_pipeline.py:86-87``) and *allow* here purely by the permissive default, so -the test cannot false-green on a stack still running deny-by-default. - -Policy B carries both ALLOW and DENY rules (the exclusivity idiom emits an ALLOW half), but under -``default=ALLOW`` the ALLOW halves are **inert** (everything not denied is already allowed) and the -prose constrains **user roles only** (no target/capability-gate denies), so the enforced §6 matrix is -driven **purely by the subject-side denies** — see the ``scenario_uc1_denyworld`` docstring. - -The **fixture-independent oracle-contract tests** that pin the §6 matrix directly live in -``test_scenario_uc1_denyworld.py`` (#148, the unit lane — no cluster/Keycloak/LLM); this module reuses -that oracle's ``expected_*`` functions as its single source of expected verdicts rather than -re-pinning a second copy of the matrix. Policy A is **not** re-implemented here (it stays in -``test_policy_pipeline.py``). - -Run (needs a live rossoctl/Kind cluster with the AuthBridge OPA pipeline wired into both legs — see -``k8s/opa-kind-runbook.md`` / ``k8s/opa-kind-enable.sh`` — the demo workloads deployed + registered, -a real LLM in-pod, **and #146's ``default_effect`` hook wired into ``onboarded_stack``**, with -``.env`` sourced): - - .venv/bin/pytest test/system/test_policy_pipeline_denyworld.py -m system -v - -Without ``-m system`` the suite is not collected; without a wired cluster / env it skips cleanly, -and its teardown resets ``default_effect`` to ``Deny`` so a subsequent Policy-A run on the shared stack -is unaffected. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -pytestmark = [ - pytest.mark.system, - pytest.mark.skip(reason="isolated/disabled — moved to test/system/disabled/"), -] - -HERE = Path(__file__).resolve().parent # test/system/disabled/ -REPO_ROOT = HERE.parents[2] # -> aiac/ -sys.path.insert(0, str(REPO_ROOT)) # so ``import test.system.*`` resolves - -from test.system import scenario_uc1 as scn # noqa: E402 -from test.system import scenario_uc1_denyworld as scn_b # noqa: E402 -from test.system import uc1_onboard as uc1 # noqa: E402 - -# The expected verdicts come straight from the #148 Policy-B oracle (``scenario_uc1_denyworld``), keyed -# on the **bare** runtime tool names AuthBridge sends (``source-read``). These thin adapters only turn -# the oracle's bool into the ``"allow"``/``"deny"`` decision string the live probes return — they add -# **no** matrix truth of their own, so the oracle stays the single source of truth (mirrors -# ``uc1.expected_inbound_decision`` / ``expected_outbound_decision`` for Policy A). - - -def _expected_inbound(subject: str) -> str: - return "allow" if scn_b.expected_inbound_denyworld(subject) else "deny" - - -def _expected_outbound(subject: str, tool_bare: str) -> str: - return "allow" if scn_b.expected_outbound_denyworld_bare(subject, tool_bare) else "deny" - - -# ====================================================================================== -# Session fixture — one-time full-stack onboarding of Policy B under default_effect=ALLOW -# ====================================================================================== - - -@pytest.fixture(scope="session") -def pipeline() -> dict: - """Onboard the **full** stack (agent + tool) via the shared harness with **Policy B**'s prose and - ``default_effect=ALLOW`` (the #146 hook), enable the outbound leg, and wait for the live pipeline to - converge; yield the live probe context. Keycloak cleanup + CR delete run before and after, and the - harness resets ``default_effect`` to ``Deny`` on teardown so the shared stack returns to the shipped - default for the Policy-A suite. Skips cleanly if the pipeline is not wired or the env is unset. - - The convergence signal set is Policy-B-aware — its default-flip tracer (``outbound(devops-user, - issues-read) == allow``) is *deny* under Policy A, so a stale Policy-A bundle can never satisfy it. - (The tracer rides the **outbound** leg, not inbound: ``devops-user`` is *deny* inbound under **both** - policies — no grant under A, an explicit source-prohibition deny under B — so an inbound devops - signal would not distinguish the two defaults.) Each signal is polled to a **definitive** allow/deny - (never ``error``), which also waits out the post-restart token-exchange 503 window: - - * ``inbound(dev-user) == allow`` — the agent is reachable (developer is unconstrained); - * ``outbound(devops-user, issues-read) == allow`` — **the default-flip tracer**: this pair is - *deny* under Policy A and *allow* here purely by the permissive default, so it proves *this* - run's ``default=ALLOW`` CR is live, not a stale Policy-A bundle; - * ``outbound(test-user, source-read) == deny`` — proves an explicit ``DENY`` is enforced (the - tester source-DENY), not the permissive default leaking through. - """ - signals = [ - uc1.ReadySignal("inbound", "dev-user", "allow"), - uc1.ReadySignal("outbound", "devops-user", "allow", tool_bare="issues-read"), - uc1.ReadySignal("outbound", "test-user", "deny", tool_bare="source-read"), - ] - with uc1.onboarded_stack( - [scn.AGENT_WORKLOAD, scn.TOOL_WORKLOAD], - policy_md=scn_b.POLICY_DENYWORLD, - default_effect=uc1.DEFAULT_EFFECT_ALLOW, - ready_signals=signals, - ) as ctx: - yield ctx - - -# ====================================================================================== -# Live tests — the real OPA plugin's decisions over Policy B's §6 matrix -# ====================================================================================== - - -@pytest.mark.parametrize("subject", list(scn.USERS)) -def test_inbound(pipeline: dict, subject: str) -> None: - """The enforced inbound gate — a real request through AuthBridge as ``subject``. Policy B's source - prohibitions project onto the agent's ``source_operations`` scope, so under ``default=ALLOW`` the - coarse deny-overrides inbound gate denies ``tester`` and ``devops`` the agent entirely while the - unconstrained ``developer`` is allowed; ``tester`` inbound flips allow→deny vs. Policy A (a - load-bearing observable DENY). The real OPA plugin decides; ``jwt-validation`` builds - ``input.identity`` (no hand-built input).""" - assert uc1.inbound_decision(pipeline, subject) == _expected_inbound(subject), subject - - -@pytest.mark.parametrize("subject", list(scn.USERS)) -@pytest.mark.parametrize("tool_bare", scn.TOOL_REQUEST_NAMES) -def test_outbound(pipeline: dict, subject: str, tool_bare: str) -> None: - """The enforced outbound gate — a real MCP ``tools/call`` for the **bare** tool through AuthBridge's - forward proxy (token-exchange → OPA). Under ``default=ALLOW`` a pair is allowed unless the - subject gate explicitly denies it: every ❌ in Policy B's §6 matrix is a load-bearing explicit - ``DENY`` (tester→source-*, devops→source-*), and the ``devops → issues-*`` ✅ - cells are the default-flip tracer. ``mcp-parser`` surfaces ``input.mcp.params.name`` (no hand-built - input); a denial is a JSON-RPC error frame the harness classifies.""" - assert uc1.outbound_decision(pipeline, subject, tool_bare) == _expected_outbound(subject, tool_bare), ( - f"{subject} / {tool_bare}" - ) - - -# ====================================================================================== -# Negative controls — an unmatched tool name under default=ALLOW (resolved Rego semantics) -# ====================================================================================== -# -# The mirror image of ``test_policy_pipeline.py:142-153``. Under the shipped ``default=DENY`` an -# unknown tool name matches no ALLOW gate and falls through to deny-by-default (``deny``). Under Policy -# B's ``default=ALLOW`` the outbound decision is ``default allow := true`` with ``allow := false if { -# subject_deny_ok }`` / ``{ target_deny_ok }``, and each DENY gate fires only when -# ``input.mcp.params.name`` is in that role's deny map (``rego.py`` ``_outbound_subject_gate`` / -# ``_decision_block``). An unrecognized name is in **no** deny map, so no DENY gate matches and the -# request falls through to the **permissive default = allow**. This is the resolved answer to the -# handoff §7.3 question ("does an unmatched tool fall to the permissive default or is it denied?"): it -# is **allowed**. The oracle agrees — ``expected_outbound_denyworld_bare`` denies only the four explicit -# subject-DENY pairs, so any name outside them is allow. - - -def test_outbound_unknown_tool_allowed_by_permissive_default(pipeline: dict) -> None: - """An otherwise-allowed subject (dev-user) invoking a tool name in **no** map is **allowed** under - ``default=ALLOW`` — it matches no explicit DENY gate, so it falls through to the permissive default - (the mirror of the deny-by-default control, which denies it). Confirms the DENY gate matches - ``input.mcp.params.name`` exactly and does not over-match an unrecognized name into a spurious - deny.""" - assert uc1.outbound_decision(pipeline, "dev-user", "nonexistent-tool") == "allow" - assert uc1.outbound_decision(pipeline, "dev-user", "nonexistent-tool") == _expected_outbound( - "dev-user", "nonexistent-tool" - ) - - -def test_outbound_bogus_tool_shape_allowed_by_permissive_default(pipeline: dict) -> None: - """A bogus, destructive-sounding tool name matching no deny scope is **allowed** under - ``default=ALLOW`` (same permissive-default reasoning). Guards the inverse of the Policy-A control: - here the risk is an over-broad DENY match spuriously denying an unrecognized name, and this pins - that no such over-match happens.""" - assert uc1.outbound_decision(pipeline, "dev-user", "delete_everything") == "allow" - assert uc1.outbound_decision(pipeline, "dev-user", "delete_everything") == _expected_outbound( - "dev-user", "delete_everything" - ) diff --git a/test/system/disabled/test_scenario_uc1_denyworld.py b/test/system/disabled/test_scenario_uc1_denyworld.py deleted file mode 100644 index 25ab4327..00000000 --- a/test/system/disabled/test_scenario_uc1_denyworld.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Fixture-independent oracle-contract tests for the Policy-B (denyworld) oracle. - -Intrinsically cluster-free: these need **no** cluster, Keycloak, or LLM. They are currently parked -disabled under ``test/system/disabled/`` and carry the ``system`` marker, so the bare ``pytest`` unit -lane deselects them exactly as it does the other disabled system tests. They pin the handoff §6 intended -allow/deny matrix **directly** (mirroring ``test_policy_pipeline.py:64-93``) so a wrong oracle cannot -silently validate the live denyworld test — if these are wrong, every live assertion is meaningless. - -Policy B is permissive-default prose that constrains **user roles only**, deployed under -``default_effect=ALLOW`` so every deny in its matrix is a load-bearing explicit ``DENY``. The -``devops → issues-*`` = ✅ cells are the signature that ``default=ALLOW`` is live (they are **deny** -under Policy A). Every prohibition targets a pair the role's own description does not support, so no -DENY contradicts a description-derived capability grant — the developer (whose description consults -issues) is therefore left unconstrained and fully allowed. Source of truth: -``aiac/docs/handoffs/02-policy-b-deny-full-deployment.md`` §5-§7.1. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[3] # -> aiac/ -sys.path.insert(0, str(REPO_ROOT)) - -from test.system import scenario_uc1 as scn # noqa: E402 -from test.system import scenario_uc1_denyworld as scn_b # noqa: E402 - -pytestmark = [ - pytest.mark.system, - pytest.mark.skip(reason="isolated/disabled — moved to test/system/disabled/"), -] - -# ====================================================================================== -# §6 matrix — the oracle contract (pin the intended tables directly) -# ====================================================================================== - - -@pytest.mark.parametrize( - "subject, allowed", - [("dev-user", True), ("test-user", False), ("devops-user", False)], -) -def test_inbound_oracle(subject: str, allowed: bool) -> None: - """Inbound: developer ✅, tester ❌, devops ❌. The source prohibitions project onto the agent's - ``source_operations`` scope, so under the coarse deny-overrides inbound gate tester and devops are - denied the agent entirely; the unconstrained developer is allowed. ``tester`` inbound flips - allow→deny vs. Policy A (a load-bearing observable DENY under ``default=ALLOW``).""" - assert scn_b.expected_inbound_denyworld(subject) is allowed - - -@pytest.mark.parametrize( - "subject, tool_bare, allowed", - [ - # developer → source ✅✅ / issues ✅✅ (unconstrained — no developer prohibition; its - # description covers defects reported in the issue tracker, so a prohibition would contradict the capability grant) - ("dev-user", "source-read", True), - ("dev-user", "source-write", True), - ("dev-user", "issues-read", True), - ("dev-user", "issues-write", True), - # tester → source ❌❌ / issues ✅✅ (exclusivity) - ("test-user", "source-read", False), - ("test-user", "source-write", False), - ("test-user", "issues-read", True), - ("test-user", "issues-write", True), - # devops → source ❌❌ (direct prohibition) / issues ✅✅ (permissive default — the - # default-flip tracer; these two cells are DENY under Policy A) - ("devops-user", "source-read", False), - ("devops-user", "source-write", False), - ("devops-user", "issues-read", True), - ("devops-user", "issues-write", True), - ], -) -def test_outbound_oracle(subject: str, tool_bare: str, allowed: bool) -> None: - """The full user→tool outbound matrix over the **bare** tool names (§6). Every ❌ is a - load-bearing explicit DENY overriding the permissive default; the ``devops → issues-*`` ✅ cells - prove ``default=ALLOW`` is live (they are deny under Policy A).""" - assert scn_b.expected_outbound_denyworld_bare(subject, tool_bare) is allowed - - -# ====================================================================================== -# Internal consistency — one source of truth (the prefixed deny pair-lists) -# ====================================================================================== - - -def test_inbound_denies_are_the_two_source_prohibitions_and_no_target_denies() -> None: - """The source prohibitions project onto the INBOUND gate via the agent's ``source_operations`` - scope, so the inbound subject-deny list is exactly tester/devops → ``github-agent.source_operations``. - There are still no target/capability-gate denies (the prose names no agent-operator prohibition).""" - assert set(scn_b.INBOUND_SUBJECT_DENY_PAIRS) == { - ("tester", "github-agent.source_operations"), - ("devops", "github-agent.source_operations"), - } - assert scn_b.OUTBOUND_TARGET_DENY_PAIRS == [] - - -def test_outbound_subject_deny_pairs_are_the_four_expected() -> None: - """The subject-gate DENY pair-list is exactly the four prefixed (role, tool-scope) entries from - §6: tester→source-*, devops→source-*. There is no developer prohibition — the developer - description consults the issue tracker, so denying developer→issues would contradict a - description-derived capability grant (a precedence conflict deferred to future work).""" - assert set(scn_b.OUTBOUND_SUBJECT_DENY_PAIRS) == { - ("tester", "github-tool.source-read"), - ("tester", "github-tool.source-write"), - ("devops", "github-tool.source-read"), - ("devops", "github-tool.source-write"), - } - assert len(scn_b.OUTBOUND_SUBJECT_DENY_PAIRS) == 4 - - -def test_bare_deny_set_is_derived_from_the_prefixed_pairs_via_shared_bare() -> None: - """The bare-name deny set is derived from the prefixed pair-list via the shared ``bare()`` (one - source of truth), so it matches the prefixed truth de-prefixed on the first ``.``.""" - assert scn_b.OUTBOUND_SUBJECT_DENY_BARE == { - (role, scn.bare(scope)) for role, scope in scn_b.OUTBOUND_SUBJECT_DENY_PAIRS - } - assert scn_b.OUTBOUND_SUBJECT_DENY_BARE == { - ("tester", "source-read"), - ("tester", "source-write"), - ("devops", "source-read"), - ("devops", "source-write"), - } - - -def test_deny_pairs_reference_only_known_roles_and_tool_scopes() -> None: - """Every deny pair keys on a provisioned realm role and a discovered, prefixed scope reused from - ``scenario_uc1`` — guarding against a typo forking the shared deployment truth. Outbound subject - denies reference tool scopes; inbound subject denies reference agent scopes.""" - for role, scope in scn_b.OUTBOUND_SUBJECT_DENY_PAIRS: - assert role in scn.USER_ROLES, role - assert scope in scn.TOOL_SCOPES, scope - for role, scope in scn_b.INBOUND_SUBJECT_DENY_PAIRS: - assert role in scn.USER_ROLES, role - assert scope in scn.AGENT_SCOPES, scope - - -def test_reuses_deployment_fixed_constants_from_scenario_uc1() -> None: - """Policy B is a sibling scenario on the **same** deployed workloads, so it reuses (does not - redefine) the deployment-fixed constants from ``scenario_uc1``.""" - assert scn_b.USERS is scn.USERS - assert scn_b.TOOL_SCOPES is scn.TOOL_SCOPES - assert scn_b.bare is scn.bare diff --git a/test/system/disabled/test_uc1_onboard_policy_agnostic.py b/test/system/disabled/test_uc1_onboard_policy_agnostic.py index 09edf672..999442f7 100644 --- a/test/system/disabled/test_uc1_onboard_policy_agnostic.py +++ b/test/system/disabled/test_uc1_onboard_policy_agnostic.py @@ -8,8 +8,8 @@ The point of these tests is the #149 acceptance property: **every new parameter defaults to today's Policy-A behavior**, so the existing rung callers (which pass only a positional ``workloads`` list) -stay byte-for-byte unchanged, while a second policy can drive the same harness under a different -default effect and its own convergence probe. +stay byte-for-byte unchanged, while a second policy can drive the same harness with its own +convergence probe. """ from __future__ import annotations @@ -105,13 +105,10 @@ def test_ensure_agent_policy_defaults_to_policy_a_abstract() -> None: def test_onboarded_stack_new_params_default_to_policy_a_behavior() -> None: - """``onboarded_stack`` gains ``policy_md`` / ``default_effect`` / ``ready_signals`` but every new - parameter defaults to today's Policy-A behavior, so the rung callers stay unchanged.""" + """``onboarded_stack`` gains ``policy_md`` / ``ready_signals`` but every new parameter defaults + to today's Policy-A behavior, so the rung callers stay unchanged. There is no ``default_effect`` + parameter: the default is always DENY (handoff 11 B3).""" params = inspect.signature(uc1.onboarded_stack).parameters assert params["policy_md"].default == scn.POLICY_ABSTRACT - assert params["default_effect"].default == uc1.DEFAULT_EFFECT_DENY assert params["ready_signals"].default is None - # The shipped default effect is DENY (deny-by-default least-privilege) — the value the harness - # never patches onto the stack, keeping Policy-A runs from touching the Controller env. - assert uc1.DEFAULT_EFFECT_DENY == "Deny" - assert uc1.DEFAULT_EFFECT_ALLOW == "Allow" + assert "default_effect" not in params diff --git a/test/system/launcher.py b/test/system/launcher.py index 7d27ef8b..78d000be 100644 --- a/test/system/launcher.py +++ b/test/system/launcher.py @@ -11,8 +11,8 @@ * **Live-cluster half** — drive a real rossoctl/Kind cluster with the AuthBridge OPA pipeline wired in (see ``k8s/opa-kind-runbook.md``). ``kubectl`` wrappers + ``port_forward`` + ``resolve_pod`` onboard through the in-cluster Controller; ``mint_token`` / ``jwt_claim`` / ``inbound_probe`` / - ``outbound_probe`` send **real HTTP requests through AuthBridge** and classify the **real OPA - plugin's** allow/deny; ``poll_until`` waits for ``bundle-service`` to reflect a CR change; and the + ``outbound_probe`` / ``outbound_session_probe`` send **real HTTP requests through AuthBridge** and + classify the **real OPA plugin's** allow/deny; ``poll_until`` waits for ``bundle-service`` to reflect a CR change; and the skip gates (``require_env_or_skip`` / ``require_pipeline`` / ``verify_subject_mapper``) make the suite skip cleanly — never false-pass — when the cluster is not wired. @@ -469,31 +469,62 @@ def outbound_probe( e.g. ``source-read``) instead of ``tools/list``, so AuthBridge's ``mcp-parser`` surfaces ``input.mcp.params.name`` and OPA's per-tool outbound gate is actually exercised. The agent app container has ``HTTP_PROXY=127.0.0.1:8081`` (the forward proxy) and ``python3``; ``token-exchange`` - uses the carried ``dev-user`` bearer as the RFC 8693 subject token. The python emits an - ``AB_HTTP:`` marker + body (or ``AB_ERR:``) the caller parses. Any exec failure returns - ``(None, )`` -> ``"error"``.""" + uses the carried ``dev-user`` bearer as the RFC 8693 subject token. It is a one-frame + :func:`outbound_session_probe`. Any exec failure returns ``(None, )`` -> ``"error"``.""" + frame = {"jsonrpc": "2.0", "id": "1", "method": "tools/call", "params": {"name": tool_name, "arguments": {}}} + return outbound_session_probe( + token, + [frame], + namespace=namespace, + agent_pod=agent_pod, + tool_url=tool_url, + container=container, + timeout=timeout, + )[0] + + +def outbound_session_probe( + token: str, + frames: list[dict], + *, + namespace: str, + agent_pod: str, + tool_url: str = "http://github-tool:9090/mcp", + container: str = "agent", + timeout: float = 120.0, +) -> list[tuple[int | None, str]]: + """Send a sequence of JSON-RPC ``frames`` (an MCP session: ``initialize``, + ``notifications/initialized``, ``tools/list``, ``tools/call`` …) through AuthBridge's forward proxy, + in order, from one ``kubectl exec`` into the agent pod; return one ``(http_code, body)`` per frame. + + The forward proxy is ``127.0.0.1:8081``, the user bearer is the token-exchange subject token, and + the request carries the MCP ``Accept`` header. The frames are sent as given, so a method that + carries no tool name reaches the outbound OPA session rule; :func:`outbound_probe` sends one + ``tools/call`` frame. Each frame's output starts + with an ``AB_MSG:`` line and carries the ``AB_HTTP:`` / ``AB_ERR:`` markers that + ``_parse_curl_output`` reads. An exec failure returns ``(None, )`` for every frame.""" script = ( "import urllib.request, urllib.error, json\n" f"tok = {json.dumps(token)}\n" - f"name = {json.dumps(tool_name)}\n" f"url = {json.dumps(tool_url)}\n" + f"frames = json.loads({json.dumps(json.dumps(frames))})\n" "op = urllib.request.build_opener(" 'urllib.request.ProxyHandler({"http": "http://127.0.0.1:8081"}))\n' - 'body = json.dumps({"jsonrpc": "2.0", "id": "1", "method": "tools/call",' - ' "params": {"name": name, "arguments": {}}}).encode()\n' - "req = urllib.request.Request(url, data=body, headers={" + "for i, frame in enumerate(frames):\n" + ' print("AB_MSG:%d" % i)\n' + " req = urllib.request.Request(url, data=json.dumps(frame).encode(), headers={" '"Content-Type": "application/json",' ' "Accept": "application/json, text/event-stream",' ' "Authorization": "Bearer " + tok})\n' - "try:\n" - " r = op.open(req, timeout=15)\n" - ' print("AB_HTTP:%d" % r.status)\n' - ' print(r.read().decode("utf-8", "replace"))\n' - "except urllib.error.HTTPError as e:\n" - ' print("AB_HTTP:%d" % e.code)\n' - ' print(e.read().decode("utf-8", "replace"))\n' - "except Exception as e:\n" - ' print("AB_ERR:%s" % e)\n' + " try:\n" + " r = op.open(req, timeout=15)\n" + ' print("AB_HTTP:%d" % r.status)\n' + ' print(r.read().decode("utf-8", "replace"))\n' + " except urllib.error.HTTPError as e:\n" + ' print("AB_HTTP:%d" % e.code)\n' + ' print(e.read().decode("utf-8", "replace"))\n' + " except Exception as e:\n" + ' print("AB_ERR:%s" % e)\n' ) try: out = kubectl( @@ -512,8 +543,32 @@ def outbound_probe( ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc: stderr = getattr(exc, "stderr", "") or getattr(exc, "output", "") or str(exc) - return None, f"outbound probe exec failed: {stderr}".strip() - return _parse_curl_output(out) + return [(None, f"outbound probe exec failed: {stderr}".strip())] * len(frames) + chunks: dict[int, list[str]] = {} + current: int | None = None + for line in out.splitlines(): + if line.strip().startswith("AB_MSG:"): + current = int(line.strip()[len("AB_MSG:") :]) + chunks[current] = [] + elif current is not None: + chunks[current].append(line) + return [ + _parse_curl_output("\n".join(chunks[i])) if i in chunks else (None, "no output for frame") + for i in range(len(frames)) + ] + + +def notification_outcome(code: int | None) -> str: + """Classify the response to a JSON-RPC **notification** (a frame with no ``id``, e.g. + ``notifications/initialized``) sent through the outbound proxy. A notification has no JSON-RPC + reply to carry an error frame, so AuthBridge renders a rejection as a plain HTTP ``403`` (runbook + B.4). HTTP 200/202 (the MCP server accepted it) -> ``"allow"``; 403 -> ``"deny"``; anything else + -> ``"error"``.""" + if code in (200, 202): + return "allow" + if code == 403: + return "deny" + return "error" def inbound_outcome(code: int | None) -> str: diff --git a/test/system/scenario_uc1_denyworld.py b/test/system/scenario_uc1_denyworld.py deleted file mode 100644 index 372d992c..00000000 --- a/test/system/scenario_uc1_denyworld.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Policy-B ("denyworld") oracle — the pure-data truth for the ``default_effect=ALLOW`` full-deployment -integration test (``test_policy_pipeline_denyworld.py``). Source of truth: -``aiac/docs/handoffs/02-policy-b-deny-full-deployment.md`` §5, §6, §7.1. - -**Sibling of ``scenario_uc1``, not a parametrization of it.** ``scenario_uc1`` encodes its truth as -**ALLOW** pair-lists over a deny-by-default base (``default_effect=DENY``); Policy B's truth is -naturally expressed as **DENY** sets over a **permissive** base (``default_effect=ALLOW``). -Parametrizing one module to carry two opposite default semantics would tangle both fact triads, so -each scenario keeps its own coherent triad. The two scenarios run **sequentially on the same shared -stack** (one ``policy.md`` mounted at a time), so the *deployed workloads* are identical — this module -therefore **reuses** the deployment-fixed constants from ``scenario_uc1`` (``USERS``, ``USER_ROLES``, -``AGENT_SCOPES``, ``AGENT_ROLES``, ``TOOL_SCOPES``, ``REALM_DEFAULT``, ``DEMO_NAMESPACE_DEFAULT``, -``AGENT_WORKLOAD``, ``TOOL_WORKLOAD``, ``bare``) rather than redefining them. - -**Why the DENY sets are load-bearing (the whole point).** Under the shipped ``default=DENY`` an -explicit ``DENY`` rule is invisible at the enforced seam: an ungranted pair is already denied by the -absence of an ALLOW. Policy B is deployed under ``default_effect=ALLOW``, where an unmentioned pair is -**allowed** by default — so a ``DENY`` rule is the *only* thing that can deny a pair, and the deny -becomes fully **observable**. Every ❌ in the §6 matrix is therefore a load-bearing explicit ``DENY``; -if any DENY is dropped anywhere in the chain (PRB → PCE → Rego → bundle → OPA) the corresponding cell -flips back to allow and the live test fails. - -**Every prohibition targets a pair the role's own description does NOT support** — so no DENY -contradicts a description-derived capability grant. (Resolving a prohibition that *does* contradict a -capability grant — e.g. denying the developer, whose description covers defects reported in the issue tracker, from issues — is -deferred future work; this scenario avoids it by leaving the developer **unconstrained**: its -description spans source and reading issues, so under the permissive default it is fully allowed and -carries no DENY at all.) - -**Both PRB deny idioms are exercised** (handoff §5), over conflict-free pairs: - - *exclusivity* — "testers may access only issues" ⇒ ALLOW tester→issues-* **and DENY** tester→source-* - (the tester description covers only issue-tracker work and is silent on source, so the DENY contradicts nothing). - The ALLOW half is **inert** under ``default=ALLOW`` — everything not denied is already allowed — so - the enforced matrix and this oracle depend only on the DENY half. - - *direct prohibition* — "DevOps may not access source" ⇒ **DENY** devops→source-* only (no ALLOW - derived; the devops description covers only deployment infrastructure and is silent on source). DevOps is **not** prohibited from - issues and derives no ALLOW there, so ``devops→issues-*`` carries **no explicit rule at all** and is - allowed **purely by the permissive default** — the signature that ``default=ALLOW`` is live (these - cells are **deny** under Policy A). ``developer→*`` is likewise unconstrained and allowed by the - default; ``developer→issues-write`` (deny under Policy A) is a second such default-flip tracer. - -**Both source prohibitions also project onto the INBOUND gate.** The prohibitions are *subject* facts, -and the inbound gate (user→agent) keys on **agent scopes** — and the deployed ``github-agent`` exposes a -``source_operations`` skill. So the PRB emits an inbound DENY for ``tester→source_operations`` and -``devops→source_operations`` as well. The inbound gate is **coarse deny-overrides**: a role denied *any* -agent scope in ``agent_scopes`` is denied the agent **entirely** (even the issue skill it is not -prohibited from). Hence inbound: ``developer`` = allow (unconstrained), ``tester`` = **deny**, ``devops`` -= **deny**. ``tester`` inbound is the inbound default-flip tracer — **allow** under Policy A (which grants -``tester→issue_operations``) but **deny** here — a load-bearing observable DENY, the inbound analogue of -the ``devops→issues-*`` outbound tracer. (An earlier draft assumed Policy B produced *no* inbound denies; -the live pipeline corrected that — the source prohibition bites on the inbound axis too.) - -**Prefixed provisioned names vs. bare runtime names** (same convention as ``scenario_uc1``): the DENY -pair-lists hold the **prefixed** names the PCE writes into the CR data maps (``github-tool.source-read`` -…), while AuthBridge's ``mcp-parser`` puts the **bare** invoked tool name into -``input.mcp.params.name`` (``source-read``). ``OUTBOUND_SUBJECT_DENY_BARE`` derives the bare forms from -the prefixed pair-list via the **shared** ``bare()`` so there is exactly one source of truth. - -This module is **pure data**: it imports only ``scenario_uc1`` (which itself imports nothing, so this -module stays importable before any env-before-import step, exactly like ``scenario_uc1``). -""" - -from __future__ import annotations - -from test.system import scenario_uc1 as scn - -# --- Reused deployment-fixed constants (identical deployed workloads for Policy A and B) ----- -# -# Re-exported by reference so denyworld callers and the contract tests can use a single ``scn_b`` -# handle without reaching back into ``scenario_uc1`` for the shared truth. These describe the -# *deployed workloads*, which are the same for both policies. - -USERS = scn.USERS -USER_ROLES = scn.USER_ROLES -AGENT_SCOPES = scn.AGENT_SCOPES -AGENT_ROLES = scn.AGENT_ROLES -TOOL_SCOPES = scn.TOOL_SCOPES -REALM_DEFAULT = scn.REALM_DEFAULT -DEMO_NAMESPACE_DEFAULT = scn.DEMO_NAMESPACE_DEFAULT -AGENT_WORKLOAD = scn.AGENT_WORKLOAD -TOOL_WORKLOAD = scn.TOOL_WORKLOAD -bare = scn.bare - - -# --- The mounted Policy B prose (handoff §5, verbatim) -------------------------------------- -# -# User-intent prose that includes prohibitions; constrains **user roles only** (never the agent's own -# operator roles), exactly like Policy A's ``POLICY_ABSTRACT``. The AIAC pod mounts its own -# ``policy.md`` (via AIAC_POLICY_FILE); the denyworld harness swaps this in for the Policy-A prose. -POLICY_DENYWORLD = """\ -Grant access on a permissive basis: allow by default; state only the prohibitions and the -exclusive scoping that narrow access. - -- Testers may access only issues; they may not access source. -- DevOps may not access source. -""" - - -# --- DENY pair-lists over the DISCOVERED, PREFIXED names (the single source of truth) -------- -# -# Mirrors ``scenario_uc1``'s prefixed convention. Each maps 1:1 to a generated Rego DENY gate. Every -# deny targets a (role, scope) pair the role's own description does NOT support, so none contradicts a -# capability grant (the developer, whose description covers defects reported in the issue tracker, carries no prohibition — it is -# left unconstrained). -# -# The prose's two source prohibitions ("testers may not access source", "DevOps may not access -# source") project onto BOTH enforced gates, because the deployed ``github-agent`` exposes a -# source-domain skill (``source_operations``) *and* the ``github-tool`` exposes source scopes: -# - OUTBOUND (agent→tool, keyed on TOOL scopes): tester/devops → ``github-tool.source-*``. -# - INBOUND (user→agent, keyed on AGENT scopes): tester/devops → ``github-agent.source_operations``. -# The inbound gate is coarse deny-overrides — a role denied ANY agent scope present in ``agent_scopes`` -# is denied the agent ENTIRELY (even the issue skill it is not prohibited from) — so tester and devops -# are denied inbound outright, while the unconstrained developer is allowed. There are no -# target/capability-gate denies (the prose names no agent-operator prohibition), so the outbound -# *target* gate stays empty. -# -# (An earlier draft of this oracle assumed the prose produced NO inbound denies — conflating "no -# target/capability-gate denies" with "no inbound-subject denies". The live pipeline disproves that: -# the source prohibition is a *subject* fact and the inbound gate keys on the agent's source-domain -# scope, so it fires there too. Corrected against the deployed Rego — see the module docstring.) - -OUTBOUND_SUBJECT_DENY_PAIRS: list[tuple[str, str]] = [ - ("tester", "github-tool.source-read"), # exclusivity complement (tester → issues only) - ("tester", "github-tool.source-write"), - ("devops", "github-tool.source-read"), # direct prohibition (DevOps may not access source) - ("devops", "github-tool.source-write"), -] - -# The same two source prohibitions on the INBOUND axis, keyed on the agent's source-domain scope. Under -# the coarse deny-overrides inbound gate each denies its role from the agent entirely. LOAD-BEARING -# under ``default=ALLOW``: ``tester`` inbound FLIPS allow (Policy A grants ``tester→issue_operations``, -# ``scenario_uc1.INBOUND_PAIRS``) → **deny** here — the inbound analogue of the outbound -# ``devops→issues-*`` default-flip tracer, and a genuine observable DENY. (``devops`` is deny inbound -# under both policies — no grant under A, explicit deny under B — so only its *reason* changes.) -INBOUND_SUBJECT_DENY_PAIRS: list[tuple[str, str]] = [ - ("tester", "github-agent.source_operations"), - ("devops", "github-agent.source_operations"), -] -# No target/capability-gate denies: the prose constrains user roles only (not the agent's operator -# roles), so the outbound target gate emits nothing. -OUTBOUND_TARGET_DENY_PAIRS: list[tuple[str, str]] = [] - - -# --- Bare runtime deny set (what AuthBridge sends; what the live test crafts + expects) ------ -# -# Derived from the prefixed pair-list above via the shared ``bare()`` so the prefixed truth stays the -# single source of truth (one split on the first ``.``, matching ``rego.py``'s ``_deprefix``). -OUTBOUND_SUBJECT_DENY_BARE: set[tuple[str, str]] = {(role, bare(scope)) for role, scope in OUTBOUND_SUBJECT_DENY_PAIRS} - -# Set of role names that carry an explicit inbound DENY (empty here) — the inbound oracle keys on it. -_INBOUND_DENY_ROLES: set[str] = {role for role, _ in INBOUND_SUBJECT_DENY_PAIRS} - - -# --- Deny-based oracle (verdicts computed from the deny sets under default=ALLOW) ------------ -# -# These compute the intended verdict **from the deny sets under ``default=ALLOW``**, never from the -# Rego under test — so the live test's expected values are independent of the artifact it validates. - - -def expected_inbound_denyworld(subject: str) -> bool: - """Inbound verdict for ``subject`` under ``default=ALLOW``. A subject is denied inbound iff an - explicit inbound DENY removes its reach to the agent. Policy B's source prohibitions project onto - the agent's ``source_operations`` scope, so ``tester`` and ``devops`` carry an inbound DENY and — - under the coarse deny-overrides inbound gate — are denied the agent **entirely**; the unconstrained - ``developer`` is allowed. ``tester`` inbound thus flips **allow → deny** vs. Policy A (a load-bearing - observable DENY); ``devops`` is deny under both.""" - return scn.USERS[subject] not in _INBOUND_DENY_ROLES - - -def expected_outbound_denyworld_bare(subject: str, tool_bare: str) -> bool: - """Outbound verdict for ``subject`` calling the **bare** tool name ``tool_bare`` under - ``default=ALLOW``: allowed **unless** the subject gate explicitly denies this ``(role, tool)`` - pair. The target/capability gate emits no denies under Policy B (the prose constrains user roles - only), so it never blocks — the matrix is driven purely by the subject-side denies.""" - return (scn.USERS[subject], tool_bare) not in OUTBOUND_SUBJECT_DENY_BARE diff --git a/test/system/test_uc1_onboard_failure_rollback.py b/test/system/test_uc1_onboard_failure_rollback.py new file mode 100644 index 00000000..bbdac4c0 --- /dev/null +++ b/test/system/test_uc1_onboard_failure_rollback.py @@ -0,0 +1,452 @@ +"""Rung 5 of the UC-1 onboarding ladder — a **failed** onboarding: compensating rollback + PCE quarantine. + +Spec ``docs/testing/uc1-onboarding-pipeline.md`` (§ Failure path); plan +``docs/handoffs/04-uc1-onboarding-prb-failure-rollback-coverage.md``, with the assertions of handoff 11 +(Group 8 — its no-rules CR supersedes handoff 04's "no CR for a failed agent"). Rungs 1–3 prove the happy path; +this rung proves what a build failure leaves behind. Service Provision succeeds and writes the service's +roles/scopes, then ``ServicePolicyBuilder.build`` raises one of ``orchestrator._ROLLBACK_ERRORS``. The +Orchestrator runs ``_rollback`` (delete only this run's roles/scopes, **keep** ``client.type``, disable +the client last), then the PCE ``quarantine`` (delete the SPM, remove the service's roles from the other +SPMs, replace an **agent's** CR with a **no-rules CR**, re-derive the affected agents), and re-raises. +Under the event model there is no HTTP status to assert, so the rung asserts only observable end state: +Keycloak, the ``AuthorizationPolicy`` CRs, the Policy Store, the Controller log, and real requests +through AuthBridge + the deployed OPA plugin. + +One shared stack, four phases in order (``_run_phases``); each records what it observed and the tests +assert on the record, so a failed phase reports its own facts and the later phases report "not reached": + +1. **Failed agent** — deploy the agent with the LLM seam broken. Assert: the client is disabled and its + ``client.type`` is kept; the ``github-agent.*`` roles/scopes Provision created are gone; the no-rules + CR is **present** (every grant binding empty, ``default allow := false`` — a deleted CR would open the + agent through the bundle-service allow-fallback); a real inbound ``dev-user`` request (allowed on the + happy path) is **denied**; no SPM; the failure and the dead-letter move are logged. +2. **Lift** — restore the seam and re-fire the trigger. Assert: the real CR (with grants) is back, the + client is enabled, and ``dev-user`` inbound is allowed again. +3. **Tool onboarded + MCP session** — deploy the tool on the happy path, prepare the outbound leg (Part + B), converge. Assert: one MCP session (``initialize``, ``notifications/initialized``, ``tools/list``, + ``tools/call``) through the agent's outbound is allowed for ``dev-user`` (a grant on some tool of the + target), and the session methods are denied for ``devops-user`` (no grant — the negative control that + keeps the session rule from passing as allow-all). +4. **Failed tool** — break the seam again and re-fire the tool's trigger. Assert: the client is disabled + and ``Tool`` is kept; no CR for the tool; its SPM (written in phase 3) is gone; the agent's outbound + Rego lost every grant to it; the ``dev-user`` outbound call that phase 3 allowed is now blocked. + +**Failure injection — a permanent error, on purpose.** ``controller_llm_unusable`` points the in-cluster +Controller's ``LLM_BASE_URL`` (an explicit container env over the ``aiac-agent-config`` ``envFrom``; the +exact original env is restored in ``finally`` and the Controller rolled back) at a path of the +Controller's own FastAPI app that answers **HTTP 404**. Provision uses no LLM, so it still runs; the +PRB's first chat-completions call gets the 404, which is not transient (no retry) and surfaces as +``UnparseableLLMResponseError`` — a **permanent** consumer error, dead-lettered on the **first** +delivery. An *unreachable* host was rejected: it raises the **retryable** ``LLMAccessError``, which the +consumer leaves un-acked (no ``nak``), so each of the ``MAX_DELIVER=5`` redeliveries waits the full +``ACK_WAIT_SECONDS=600`` — ~40 min to the terminal state — and a still-pending redelivery would re-run +the onboarding after the seam is restored, racing the lift (and the teardown). The 404 is also +independent of the real LLM provider (no bad-key / bad-model behaviour to depend on). The rest of the +path is the same: both errors are in ``_ROLLBACK_ERRORS``, so the rollback and quarantine are identical. + +**Re-onboarding trigger — the NATS subject.** ``publish_service_event`` publishes +``aiac.apply.service.`` (the subject + payload the ``aiac-event-listener`` SPI emits on +``CLIENT_CREATED``) from inside the Controller pod. A redeploy cannot lift a quarantine: the SPI +publishes only on client CREATE, and a re-created client has a new UUID — a new service. + +**Why the failed tool is a re-onboarding.** A quarantined tool cannot be lifted by re-onboarding today: +tool Provision mints its discovery token *as the tool's own client* (``client_credentials``), which a +disabled client cannot do, so Provision fails before the build (see ``reenable_provisioned_clients``). +So the tool phase runs last and the lift is proven on the agent. Failing an onboarded tool is also the +stronger check: its SPM existed and ``dev-user``'s call to it was allowed, so "no SPM" and "blocked" +are real transitions, not an empty start. + +**Outbound "blocked".** After the quarantine the agent's outbound Rego has no grant to the tool, so OPA +denies (a JSON-RPC error frame, ``plugin: opa``). The tool's client is also disabled, so Keycloak may +instead refuse the token exchange to its audience before OPA is consulted (a ``token-exchange`` error +frame). Both mean the call never reaches the tool, so both count as blocked; the OPA-side proof is the +CR assertion. Any other outcome (``allow``, a transport error) fails. + +Run (a live rossoctl/Kind cluster with the AIAC stack + AuthBridge OPA pipeline wired in — see +``k8s/opa-kind-runbook.md`` / ``k8s/opa-kind-enable.sh`` — the event path wired (NATS broker + +``aiac-event-listener`` SPI), the demo images built + ``kind load``ed, a real LLM in-pod for phases 2–3, +and ``.env`` sourced). The flow rolls the Controller four times and takes tens of minutes: + + .venv/bin/pytest -m system -k failure_rollback -v + +Without ``-m system`` the suite is not collected; without a wired cluster / env it skips cleanly. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Callable, Iterator + +import pytest + +pytestmark = pytest.mark.system + +HERE = Path(__file__).resolve().parent # test/system/ +REPO_ROOT = HERE.parents[1] # -> aiac/ +sys.path.insert(0, str(REPO_ROOT)) # so ``import test.system.*`` resolves + +from test.system import scenario_uc1 as scn # noqa: E402 +from test.system import uc1_onboard as uc1 # noqa: E402 + +AGENT = scn.AGENT_WORKLOAD +TOOL = scn.TOOL_WORKLOAD +PROBE_TOOL = "source-read" # dev-user ✅ on the happy path (rung 2 outbound table), devops-user ❌ + +# A failed onboarding (Provision + one immediate 404) settles in one delivery; the lift runs the real +# PRB (tens of seconds to minutes) and then waits for bundle-service + OPA to reflect the new CR. +SETTLE_TIMEOUT = float(os.environ.get("AIAC_ROLLBACK_SETTLE_TIMEOUT", "300")) +LIFT_TIMEOUT = float(os.environ.get("AIAC_ROLLBACK_LIFT_TIMEOUT", "600")) + +# What the injection makes the consumer log (``log_by_type`` + the dead-letter move, both at ERROR). +INJECTED_ERROR = "UnparseableLLMResponseError" +DLQ_SUBJECT = "aiac.apply.dlq" + + +def _dlq_line(uuid: str) -> str: + """The consumer's dead-letter log line for ``uuid`` (``consumer._dispatch``), up to its cause.""" + return f"moved aiac.apply.service.{uuid} to {DLQ_SUBJECT} (permanent failure" + + +# ====================================================================================== +# Phase helpers — each one polls to a terminal state and returns what it saw (never asserts) +# ====================================================================================== + + +def _registered_client(admin, workload: str) -> dict: + """Wait for the operator to register ``{ns}/{workload}`` after its deploy; return the client.""" + client = uc1.workload_client(admin, workload) if uc1.wait_for_registration(admin, workload) else None + if client is None: + raise RuntimeError( + f"operator did not register Keycloak client {uc1.NAMESPACE}/{workload!r} within " + f"{uc1.DEPLOY_TIMEOUT:.0f}s — is the event path wired? See k8s/opa-kind-runbook.md." + ) + return client + + +def _poll_decision(decide: Callable[[], str], want: str, timeout: float = uc1.BUNDLE_TIMEOUT) -> str: + """Poll a live decision until it equals ``want``; return the last one observed (the test asserts).""" + seen = {"decision": "unobserved"} + + def _reached() -> bool: + seen["decision"] = decide() + return seen["decision"] == want + + uc1.poll_until(_reached, timeout=timeout, interval=uc1.BUNDLE_POLL_INTERVAL) + return seen["decision"] + + +def _settle_failed(admin, workload: str, settled: Callable[[dict], bool]) -> dict: + """Wait for a failed onboarding of ``workload`` to reach its terminal state — the client disabled + (the rollback's last step) AND ``settled(client)`` (the quarantine's observable end) — then give the + consumer a short window to log the dead-letter move. Returns ``{"settled", "client", "logs"}``, read + while the injected Controller pod (the one that holds the failure log) is still running.""" + seen: dict = {"client": None} + + def _terminal() -> bool: + seen["client"] = uc1.workload_client(admin, workload) + client = seen["client"] + return client is not None and client.get("enabled") is False and settled(client) + + done = uc1.poll_until(_terminal, timeout=SETTLE_TIMEOUT, interval=5) + logs = {"text": ""} + if done and seen["client"] is not None: + uuid = seen["client"]["id"] + + def _logged() -> bool: + logs["text"] = uc1.controller_logs() + return _dlq_line(uuid) in logs["text"] + + uc1.poll_until(_logged, timeout=60, interval=5) + return {"settled": done, "client": seen["client"], "logs": logs["text"]} + + +def _prefixed(admin, prefix: str) -> dict[str, list[str]]: + """The realm roles and client scopes whose name starts with ``prefix`` (what Provision creates).""" + admin.change_current_realm(uc1.TEST_REALM) + return { + "roles": sorted(r["name"] for r in admin.get_realm_roles() if r.get("name", "").startswith(prefix)), + "scopes": sorted(s["name"] for s in admin.get_client_scopes() if s.get("name", "").startswith(prefix)), + } + + +def _outbound_block(ctx: dict, user: str, tool_bare: str) -> tuple[str, int | None, str]: + """One live outbound ``tools/call``; classify it as ``"deny"`` (OPA error frame), ``"refused"`` + (a token-exchange error frame — Keycloak refused the exchange before OPA, see the module + docstring), ``"allow"``, or ``"error"``. Returns ``(class, http_code, body)``.""" + token = uc1.mint_token(user, scn.USER_PASSWORD, keycloak_url=ctx["keycloak_url"], realm=ctx["realm"]) + code, body = uc1.outbound_probe(token, tool_bare, namespace=ctx["namespace"], agent_pod=uc1.resolve_agent_pod()) + decision = uc1.outbound_outcome(code, body) + if decision == "error" and code == 200: + try: + data = (json.loads(body).get("error") or {}).get("data") or {} + except (ValueError, AttributeError): + data = {} + if data.get("plugin") == "token-exchange" or data.get("error") == "upstream.token-exchange-failed": + return "refused", code, body + return decision, code, body + + +def _poll_blocked(ctx: dict, user: str, tool_bare: str) -> tuple[str, int | None, str]: + """Poll the outbound call until it is blocked (``deny`` / ``refused``) on two probes in a row — the + old bundle may still allow it until bundle-service + OPA pick up the re-derived CR, and two in a + row keeps a one-off exchange blip from ending the wait. Returns the last observation.""" + seen = {"last": ("unobserved", None, ""), "streak": 0} + + def _blocked() -> bool: + seen["last"] = _outbound_block(ctx, user, tool_bare) + seen["streak"] = seen["streak"] + 1 if seen["last"][0] in ("deny", "refused") else 0 + return seen["streak"] >= 2 + + uc1.poll_until(_blocked, timeout=uc1.BUNDLE_TIMEOUT, interval=uc1.BUNDLE_POLL_INTERVAL) + return seen["last"] + + +def _run_phases(ctx: dict, run: dict) -> None: + """Drive the four phases (module docstring) in order on the shared stack, recording into ``run``. + A phase whose outcome the next one needs sets ``run["stopped"]`` and returns early; a harness + failure (a deploy that never registers) raises.""" + admin = ctx["admin"] + + # --- Phase 1 — a failed first onboarding of the agent --------------------------------------- + with uc1.controller_llm_unusable(): + uc1.deploy_workload(AGENT) # fires CLIENT_CREATED -> onboard_service -> build raises + agent = _registered_client(admin, AGENT) + # Terminal: disabled (rollback) AND the no-rules CR written (the quarantine's agent step). + failed = _settle_failed(admin, AGENT, lambda _c: uc1.cr_has_no_grants(uc1.authpolicy_policies(AGENT))) + failed.update( + uuid=agent["id"], + cr=uc1.authpolicy_policies(AGENT), + spm=uc1.spm_present(agent["clientId"]), + provisioned=_prefixed(admin, f"{AGENT}."), + ) + run["agent_failed"] = failed + # The CR is present; wait for bundle-service + OPA to enforce it (with no CR yet, the combiner + # allowed the pod, so dev-user inbound flips from allow to deny). + failed["inbound"] = _poll_decision(lambda: uc1.inbound_decision(ctx, "dev-user"), "deny") + if not failed["settled"]: + run["stopped"] = "the failed agent onboarding did not settle (phase 1)" + return + + # --- Phase 2 — lift: the seam is restored (context exited); re-fire the agent's trigger ------ + uc1.publish_service_event(agent["id"]) + + def _agent_lifted() -> bool: + client = uc1.workload_client(admin, AGENT) + return ( + bool(client and client.get("enabled")) + and uc1.cr_has_grants(uc1.authpolicy_policies(AGENT)) + and uc1.inbound_decision(ctx, "dev-user") == "allow" + ) + + lifted = uc1.poll_until(_agent_lifted, timeout=LIFT_TIMEOUT, interval=uc1.BUNDLE_POLL_INTERVAL) + run["agent_lifted"] = { + "client": uc1.workload_client(admin, AGENT), + "cr": uc1.authpolicy_policies(AGENT), + "inbound": uc1.inbound_decision(ctx, "dev-user"), + } + if not lifted: + run["stopped"] = f"the agent's re-onboarding did not lift its quarantine within {LIFT_TIMEOUT:.0f}s (phase 2)" + return + + # --- Phase 3 — onboard the tool on the happy path, prepare Part B, then one MCP session ------- + uc1.deploy_workload(TOOL) + tool = _registered_client(admin, TOOL) + uc1.ensure_github_tool_route(uc1.NAMESPACE) + uc1.grant_exchange_scope(admin) + uc1.restart_agent(uc1.NAMESPACE) + outbound = _poll_decision(lambda: uc1.outbound_decision(ctx, "dev-user", PROBE_TOOL), "allow", LIFT_TIMEOUT) + if outbound != "allow": + run["stopped"] = f"the tool did not converge on the happy path: dev-user outbound {PROBE_TOOL}={outbound!r}" + return + run["session"] = {user: uc1.mcp_session_decisions(ctx, user, PROBE_TOOL) for user in ("dev-user", "devops-user")} + + # --- Phase 4 — a failed re-onboarding of the (onboarded) tool -------------------------------- + with uc1.controller_llm_unusable(): + uc1.publish_service_event(tool["id"]) + # Terminal: disabled (rollback) AND its phase-3 SPM gone (the quarantine's first step). + failed = _settle_failed(admin, TOOL, lambda c: not uc1.spm_present(c["clientId"])) + failed.update( + uuid=tool["id"], + cr=uc1.authpolicy_policies(TOOL), + spm=uc1.spm_present(tool["clientId"]), + agent_cr=uc1.authpolicy_policies(AGENT), # re-derived in the quarantine, before it returns + ) + run["tool_failed"] = failed + failed["outbound"] = _poll_blocked(ctx, "dev-user", PROBE_TOOL) + + +# ====================================================================================== +# Module fixture — pristine slate → the four phases → teardown to pristine +# ====================================================================================== + + +@pytest.fixture(scope="module") +def run() -> Iterator[dict]: + """Run the four phases once on a pristine stack and yield what each observed. Module-scoped (not + session, unlike rungs 1–3), so this rung's stack is torn down as soon as its tests end. The + harness restores the Controller's env on every exit path; teardown undeploys both workloads and + scrubs every registration + CR back to pristine, verified.""" + with uc1.pristine_stack([AGENT, TOOL]) as ctx: + observed: dict = {} + _run_phases(ctx, observed) + yield observed + + +def _phase(run: dict, key: str) -> dict: + """The record of phase ``key``, or fail naming why the flow stopped before it.""" + if key not in run: + pytest.fail(f"phase {key!r} not reached — {run.get('stopped', 'an earlier phase failed')}") + return run[key] + + +def _failed(run: dict, key: str) -> dict: + """The record of a failed-onboarding phase, failing first if it never reached its terminal state.""" + obs = _phase(run, key) + client = obs["client"] or {} + assert obs["settled"], ( + f"{key}: no rolled-back + quarantined terminal state within {SETTLE_TIMEOUT:.0f}s — client " + f"enabled={client.get('enabled')!r}. Did the injection reach the Controller (onboarding may have " + "succeeded), or did the build fail with an error outside _ROLLBACK_ERRORS?" + ) + return obs + + +# ====================================================================================== +# Phase 1 — failed agent +# ====================================================================================== + + +@pytest.mark.parametrize(("key", "service_type"), [("agent_failed", "Agent"), ("tool_failed", "Tool")]) +def test_failed_service_client_disabled_and_type_kept(run: dict, key: str, service_type: str) -> None: + """The rollback disables the Keycloak client last (the failed-service marker — disabled, not + deleted) and keeps its ``client.type``: the type was not created by the failed run.""" + client = _failed(run, key)["client"] + assert client.get("enabled") is False, f"{key}: client is not disabled: enabled={client.get('enabled')!r}" + attr = (client.get("attributes") or {}).get("client.type") + assert attr == service_type, f"{key}: client.type not kept: {attr!r} (want {service_type!r})" + + +def test_failed_agent_provisioned_entities_removed(run: dict) -> None: + """No partial footprint: every ``github-agent.*`` role and scope Provision created on the failed + run is deleted (the run started from a clean slate, so all of them were created by it).""" + left = _failed(run, "agent_failed")["provisioned"] + assert left == {"roles": [], "scopes": []}, f"roles/scopes the rollback should have deleted: {left}" + + +def test_failed_agent_has_no_rules_cr(run: dict) -> None: + """The agent's ``AuthorizationPolicy`` CR is PRESENT and is the no-rules CR: both packages keep + ``default allow := false`` and every grant binding is empty. Present, not deleted — the + bundle-service combiner allows a pod that has no CR.""" + policies = _failed(run, "agent_failed")["cr"] + assert policies is not None, f"no AuthorizationPolicy CR {AGENT!r} — a missing CR opens the agent" + assert uc1.cr_has_no_grants(policies), f"the agent's CR still carries grants: {policies}" + + +def test_failed_agent_inbound_denied(run: dict) -> None: + """A real inbound request as ``dev-user`` (allowed on the happy path) through AuthBridge + the + deployed OPA plugin is denied once the no-rules CR is in force.""" + decision = _failed(run, "agent_failed")["inbound"] + assert decision == "deny", f"dev-user inbound to the quarantined agent: {decision!r} (want 'deny')" + + +@pytest.mark.parametrize("key", ["agent_failed", "tool_failed"]) +def test_failed_service_has_no_spm(run: dict, key: str) -> None: + """The quarantine leaves no SPM for the failed service in the Policy Store (for the tool, the SPM + its phase-3 onboarding wrote is deleted).""" + assert _failed(run, key)["spm"] is False, f"{key}: the Policy Store still holds an SPM for the service" + + +@pytest.mark.parametrize("key", ["agent_failed", "tool_failed"]) +def test_failure_logged_and_dead_lettered(run: dict, key: str) -> None: + """The Controller logs the build failure (its type) and moves the event to the DLQ as a + **permanent** failure on the first delivery — the injection's error, never a redelivery loop.""" + obs = _failed(run, key) + assert INJECTED_ERROR in obs["logs"], f"{key}: no {INJECTED_ERROR!r} in the Controller log" + assert _dlq_line(obs["uuid"]) in obs["logs"], f"{key}: no dead-letter line {_dlq_line(obs['uuid'])!r}" + + +# ====================================================================================== +# Phase 2 — lift +# ====================================================================================== + + +def test_reonboarding_lifts_agent_quarantine(run: dict) -> None: + """A successful re-onboarding (the seam restored, the trigger re-fired) writes the real CR over the + no-rules CR and re-enables the client, so ``dev-user`` reaches the agent again.""" + lifted = _phase(run, "agent_lifted") + assert uc1.cr_has_grants(lifted["cr"]), f"the agent's CR has no grants after the lift: {lifted['cr']}" + assert (lifted["client"] or {}).get("enabled") is True, "the agent's client was not re-enabled" + assert lifted["inbound"] == "allow", f"dev-user inbound after the lift: {lifted['inbound']!r}" + + +# ====================================================================================== +# Phase 3 — outbound MCP session (happy path) +# ====================================================================================== + + +def _result(body: str) -> dict | None: + """The JSON-RPC ``result`` object of a response body, or ``None``.""" + try: + doc = json.loads(body) + except (ValueError, TypeError): + return None + return doc.get("result") if isinstance(doc, dict) and isinstance(doc.get("result"), dict) else None + + +def test_mcp_session_allowed_for_granted_user(run: dict) -> None: + """For ``dev-user`` (a grant on some tool of the target) the whole MCP session passes the agent's + outbound: ``initialize`` and ``tools/list`` return a ``result`` frame (``tools/list`` names every + tool), the ``notifications/initialized`` notification is accepted, and the ``tools/call`` reaches + the tool.""" + session = _phase(run, "session")["dev-user"] + for method in ("initialize", "notifications/initialized", "tools/list", "tools/call"): + decision, code, body = session[method] + assert decision == "allow", f"dev-user {method}: {decision!r} (HTTP {code}, body={body[:300]!r})" + for method in ("initialize", "tools/list"): + assert _result(session[method][2]) is not None, f"dev-user {method}: no result frame" + names = {t.get("name") for t in (_result(session["tools/list"][2]) or {}).get("tools", [])} + assert set(scn.TOOL_REQUEST_NAMES) <= names, f"tools/list did not name every tool: {sorted(names)}" + + +def test_mcp_session_denied_for_ungranted_user(run: dict) -> None: + """For ``devops-user`` (no grant on any tool) the session methods are denied: no tool of the target + passes the per-tool check, so the session rule does not fire (it is not an allow-all).""" + session = _phase(run, "session")["devops-user"] + for method in ("initialize", "tools/list", "tools/call"): + decision, code, body = session[method] + assert decision == "deny", f"devops-user {method}: {decision!r} (HTTP {code}, body={body[:300]!r})" + + +# ====================================================================================== +# Phase 4 — failed tool +# ====================================================================================== + + +def test_failed_tool_has_no_cr(run: dict) -> None: + """A tool never has an ``AuthorizationPolicy`` CR, and the quarantine does not write one for it.""" + assert _failed(run, "tool_failed")["cr"] is None, f"unexpected AuthorizationPolicy CR {TOOL!r}" + + +def test_failed_tool_removed_from_agent_outbound_cr(run: dict) -> None: + """The quarantine re-derives the agent that targeted the tool: its outbound Rego no longer grants + any user or the agent a tool of the failed tool (every outbound grant binding is empty — the tool + was its only target), while the agent's own inbound grants stay.""" + policies = _failed(run, "tool_failed")["agent_cr"] + assert policies is not None, f"the agent's CR {AGENT!r} is missing" + outbound = policies.get(uc1.CR_OUTBOUND_PATH, "") + for var in uc1.OUTBOUND_GRANT_BINDINGS: + assert uc1.rego_binding_empty(outbound, var) is True, f"agent outbound {var} still grants: {outbound}" + assert uc1.cr_has_grants(policies), "the agent lost its inbound grants in the tool's quarantine" + + +def test_failed_tool_outbound_call_denied(run: dict) -> None: + """The ``dev-user`` call to the tool that phase 3 allowed is now blocked through the agent's + outbound — an OPA deny, or a token-exchange refusal to the disabled tool's audience (module + docstring); never ``allow``, never a transport error.""" + decision, code, body = _failed(run, "tool_failed")["outbound"] + assert decision in ("deny", "refused"), ( + f"dev-user outbound {PROBE_TOOL} to the quarantined tool: {decision!r} (HTTP {code}, body={body[:300]!r})" + ) diff --git a/test/system/uc1_onboard.py b/test/system/uc1_onboard.py index 1aedda8c..e20ac2da 100644 --- a/test/system/uc1_onboard.py +++ b/test/system/uc1_onboard.py @@ -36,6 +36,12 @@ real plugin's response). * **``onboarded_stack``** — the whole per-rung fixture flow, parameterised by the ordered workload list; each rung wraps it in a one-line session fixture and yields a probe context. +* **Failure path (rung 5)** — ``pristine_stack`` (the slate + teardown of ``onboarded_stack`` with no + deploy / convergence, for a flow that must not converge), ``controller_llm_unusable`` (the LLM-seam + failure injection, restored on exit), ``publish_service_event`` (re-fire the onboarding trigger for + an existing client), and the quarantine readers (``workload_client``, ``authpolicy_policies``, + ``cr_has_no_grants`` / ``cr_has_grants``, ``spm_present``, ``controller_logs``) plus the MCP session + probe ``mcp_session_decisions``. It imports only stdlib + ``requests`` + ``launcher`` + the pure-data ``scenario_uc1`` (never ``aiac``), so it is importable before the env-before-import dance, exactly like ``scenario_uc1`` and @@ -44,9 +50,11 @@ from __future__ import annotations +import base64 import json import logging import os +import re import subprocess import sys from contextlib import contextmanager @@ -71,8 +79,10 @@ kubectl_delete, kubectl_rollout_status, mint_token, + notification_outcome, outbound_outcome, outbound_probe, + outbound_session_probe, poll_until, port_forward, require_env, @@ -91,7 +101,7 @@ ADMIN_REALM = os.environ.get("KEYCLOAK_ADMIN_REALM", "master") # Controller (in-cluster) namespace — the ns whose Controller Deployment the test patches (policy.md -# mount, default_effect). The onboarding trigger is event-driven (deploy fires it), so the harness no +# mount). The onboarding trigger is event-driven (deploy fires it), so the harness no # longer port-forwards to the Controller to POST /apply. CONTROLLER_NAMESPACE = os.environ.get("AIAC_CONTROLLER_NAMESPACE", "aiac-system") @@ -147,21 +157,6 @@ ], } -# --- default_effect onboarding hook (#146 coupling seam; see ``_set_controller_default_effect``) ---- -# -# The derived ``AgentPolicyModel.default_effect`` decides whether the generated Rego is deny-by-default -# (the shipped ``Deny``) or allow-by-default (``Allow``). It lives on the *derived* APM built in-cluster -# by the PCE (``engine._fresh_apm``) and defaults to ``Deny``, so a policy-agnostic onboarding run that -# needs allow-by-default must set it **before** onboarding and reset it on teardown. These are plain -# strings (matching ``RuleEffect``'s wire values ``"Allow"`` / ``"Deny"``) so the harness keeps its "no -# ``aiac`` import" property — importable before the env-before-import dance, like ``scenario_uc1``. -DEFAULT_EFFECT_ALLOW = "Allow" -DEFAULT_EFFECT_DENY = "Deny" # the shipped default; the harness never patches this onto the stack -# The Controller/PCE env the #146 hook reads where it mints the APM. Overridable so this test tracks -# whatever name #146 ships without a code edit (verify the shape against #146 — handoff §3). -DEFAULT_EFFECT_ENV = os.environ.get("AIAC_DEFAULT_EFFECT_ENV", "AIAC_DEFAULT_EFFECT") - - # ====================================================================================== # Expected-verdict oracle (pure functions over the scenario_uc1 truth table) # ====================================================================================== @@ -429,44 +424,6 @@ def ensure_agent_policy(namespace: str, policy_md: str = scn.POLICY_ABSTRACT) -> kubectl_rollout_status(f"deployment/{CONTROLLER_DEPLOYMENT}", namespace=namespace) -def _set_controller_default_effect(namespace: str, effect: str) -> None: - """Apply the ``default_effect`` onboarding hook: set the Controller/PCE env the engine reads when - it mints the ``AgentPolicyModel`` (``engine._fresh_apm``), then roll the Controller so the new - value is live **before** the next ``onboard`` derives a policy under it. Mirrors - ``ensure_agent_policy``'s patch-and-rollout precondition-fixup — a test-owned mutation of the - running Controller, never written into a committed manifest. - - This is the single hard coupling to Task 1 (#146), which owns the reader side. The env **name** - (``DEFAULT_EFFECT_ENV``, default ``AIAC_DEFAULT_EFFECT``) and the string values (``"Allow"`` / - ``"Deny"``) are #146's contract — verify/realign them once #146 lands (handoff §3). The strategic - merge patch is keyed on the env-var ``name``, so it upserts just this one var and leaves the - Controller's other env untouched.""" - patch = { - "spec": { - "template": { - "spec": { - "containers": [ - {"name": CONTROLLER_DEPLOYMENT, "env": [{"name": DEFAULT_EFFECT_ENV, "value": effect}]} - ] - } - } - } - } - kubectl( - "patch", - "deployment", - CONTROLLER_DEPLOYMENT, - "-n", - namespace, - "--type", - "strategic", - "-p", - json.dumps(patch), - ) - kubectl("rollout", "restart", f"deployment/{CONTROLLER_DEPLOYMENT}", "-n", namespace) - kubectl_rollout_status(f"deployment/{CONTROLLER_DEPLOYMENT}", namespace=namespace) - - # The demo-asset image loader. ``kind load`` needs the ``kind`` CLI + a container runtime on the pytest # host, and that host must be the one hosting the Kind node — the system suite's local-Kind topology. # There is no ``kubectl`` equivalent, so this is the one place the harness shells out to a demo script. @@ -911,7 +868,6 @@ def onboarded_stack( workloads: list[str], *, policy_md: str = scn.POLICY_ABSTRACT, - default_effect: str = DEFAULT_EFFECT_DENY, ready_signals: Sequence[ReadySignal] | None = None, ) -> Iterator[dict]: """Run one rung's whole live flow and yield a probe ``ctx`` for its assertions. @@ -941,11 +897,6 @@ def onboarded_stack( * ``policy_md`` — the ``policy.md`` prose to mount before onboarding (default: Policy A's ``POLICY_ABSTRACT``). Forwarded to ``ensure_agent_policy``; a prose change reloads the Controller via the existing content-diff rollout. - * ``default_effect`` — the derived ``AgentPolicyModel.default_effect`` this run onboards under - (default: ``DEFAULT_EFFECT_DENY``, the shipped deny-by-default). A non-default value is applied to - the Controller **before** onboarding via ``_set_controller_default_effect`` and **reset to - ``Deny`` on teardown** so a subsequent Policy-A run on the shared stack is unaffected. ``Deny`` is - a no-op (the stack is never patched), keeping Policy-A runs from touching the Controller env. * ``ready_signals`` — the convergence probe set to poll before yielding (default: the Policy-A ``_default_ready_signals``). A policy whose truth differs from Policy A (e.g. denyworld, where ``devops-user`` inbound is *allow*, not *deny*) supplies its own deterministic signals so the run @@ -992,13 +943,7 @@ def onboarded_stack( tool_onboarded = scn.TOOL_WORKLOAD in workloads signals = list(ready_signals) if ready_signals is not None else _default_ready_signals(tool_onboarded) - # A non-default effect is patched onto the Controller here and reset in ``finally``; ``Deny`` (the - # shipped default) never touches the stack, so Policy-A runs are unchanged. Tracked so teardown - # only resets what this run actually applied. - default_effect_applied = default_effect != DEFAULT_EFFECT_DENY try: - if default_effect_applied: - _set_controller_default_effect(CONTROLLER_NAMESPACE, default_effect) # BEFORE deploying ensure_agent_policy(CONTROLLER_NAMESPACE, policy_md=policy_md) # mount this run's policy.md BEFORE deploying # Minimal probe ctx for the per-workload agent convergence gate: the inbound leg reaches the @@ -1131,9 +1076,6 @@ def _ready() -> bool: yield ctx finally: # Teardown — full-to-pristine, best-effort per step (each helper tolerates already-absent objects). - if default_effect_applied: - # Reset the shared stack to the shipped default so a later Policy-A run is unaffected. - _set_controller_default_effect(CONTROLLER_NAMESPACE, DEFAULT_EFFECT_DENY) for workload in reversed(workloads): # undeploy in reverse deploy order undeploy_workload(workload) # Explicit scrub — do not trust an unverified operator cascade. Same reset the pre-run slate runs. @@ -1152,3 +1094,322 @@ def _ready() -> bool: log.error("%s [suppressed — a prior error is propagating]", msg) else: raise RuntimeError(msg) + + +# ====================================================================================== +# Failure path (rung 5) — LLM-seam injection, re-fired trigger, quarantine readers, pristine stack +# ====================================================================================== +# +# A failed onboarding does not converge to a live allow, so ``onboarded_stack`` (which polls for the +# happy path) cannot drive it. These helpers let a failure-path flow compose the same building blocks: +# the pristine slate + teardown (``pristine_stack``), a Controller-side failure injection that is always +# undone (``controller_llm_unusable``), a re-fire of the onboarding trigger for an existing client +# (``publish_service_event``), and read-only views of the state a rollback + quarantine leaves. + +# The Controller pod selector (``app: aiac-agent`` in ``k8s/agent-deployment.yaml``) and the PRB's +# endpoint env (``aiac.agent.llm.load_llm_settings`` reads the bare ``LLM_BASE_URL``). +CONTROLLER_SELECTOR = os.environ.get("AIAC_CONTROLLER_SELECTOR", f"app={CONTROLLER_DEPLOYMENT}") +LLM_BASE_URL_ENV = "LLM_BASE_URL" + +# The PRB endpoint the failure injection points the Controller at: a path the Controller's own FastAPI +# app (port 7070, same pod) does not serve, so every chat-completions call gets an immediate HTTP 404. +# A 4xx is not transient (``aiac.shared.upstream.is_transient``), so the PRB does not retry it and +# raises ``UnparseableLLMResponseError`` — a PERMANENT consumer error (dead-lettered on the first +# delivery). See the rung-5 test's module docstring for why this is preferred over an unreachable host. +UNUSABLE_LLM_BASE_URL = os.environ.get("AIAC_UNUSABLE_LLM_BASE_URL", "http://127.0.0.1:7070/aiac-system-test-no-llm/v1") + +# The CR's two policy paths (``aiac.pdp.service.policy.opa.main._build_cr``) and the Rego bindings that +# carry every grant in each. A no-rules CR (``engine._fresh_apm`` — the quarantine shell) renders each +# of them empty (``[]`` / ``{}``); a real CR has a non-empty ``agent_scopes`` + subject allow map. +CR_INBOUND_PATH = "inbound/request.rego" +CR_OUTBOUND_PATH = "outbound/request.rego" +INBOUND_GRANT_BINDINGS = ("agent_scopes", "subject_role_allow_scopes", "source_role_allow_scopes") +OUTBOUND_GRANT_BINDINGS = ("subject_role_allow_scopes", "target_allow_scopes") + + +def resolve_controller_pod() -> str: + """The current live Controller pod (newest Ready, non-terminating — see ``resolve_pod``).""" + return resolve_pod(CONTROLLER_SELECTOR, namespace=CONTROLLER_NAMESPACE) + + +def _wait_controller_rolled() -> None: + """Wait for the Controller rollout AND for every old Controller pod to be gone. ``rollout status`` + returns while the old pod may still be ``Terminating`` with its NATS consumer bound; an event it + takes then runs on the OLD env (or is lost to the kill and redelivered only after ``ACK_WAIT``), so + the injection is in force only once no terminating pod remains.""" + kubectl_rollout_status(f"deployment/{CONTROLLER_DEPLOYMENT}", namespace=CONTROLLER_NAMESPACE) + + def _no_terminating() -> bool: + doc = json.loads(kubectl("get", "pods", "-n", CONTROLLER_NAMESPACE, "-l", CONTROLLER_SELECTOR, "-o", "json")) + items = doc.get("items", []) + return bool(items) and not any(p.get("metadata", {}).get("deletionTimestamp") for p in items) + + if not poll_until(_no_terminating, timeout=DEPLOY_TIMEOUT, interval=3): + raise RuntimeError(f"old {CONTROLLER_DEPLOYMENT} pod(s) still terminating after {DEPLOY_TIMEOUT:.0f}s") + + +@contextmanager +def controller_env(overrides: dict[str, str]) -> Iterator[None]: + """Set explicit env ``overrides`` on the Controller container for the duration of the block, then + restore the container's **exact** original ``env`` list (or its absence) in ``finally`` — so a later + test on the shared stack never sees the override, even when the block raises. + + An explicit container ``env`` entry wins over the ``envFrom`` ConfigMap/Secret, so the committed + ``aiac-agent-config`` is never edited. Both the set and the restore are one JSON patch of the pod + template's ``env`` list (a template change rolls the Controller), and both wait for the old pod to + be gone (``_wait_controller_rolled``). Like ``ensure_agent_policy``, a test-owned mutation of the + running Controller, never written into a committed manifest.""" + dep = json.loads( + kubectl("get", "deployment", CONTROLLER_DEPLOYMENT, "-n", CONTROLLER_NAMESPACE, "-o", "json", timeout=30) + ) + containers = dep["spec"]["template"]["spec"]["containers"] + index = next((i for i, c in enumerate(containers) if c.get("name") == CONTROLLER_DEPLOYMENT), None) + if index is None: + raise RuntimeError(f"no container {CONTROLLER_DEPLOYMENT!r} in deployment/{CONTROLLER_DEPLOYMENT}") + original = containers[index].get("env") # None when the container declares no explicit env + patched = [e for e in (original or []) if e.get("name") not in overrides] + patched += [{"name": name, "value": value} for name, value in overrides.items()] + path = f"/spec/template/spec/containers/{index}/env" + + def _patch(ops: list[dict]) -> None: + kubectl( + "patch", + "deployment", + CONTROLLER_DEPLOYMENT, + "-n", + CONTROLLER_NAMESPACE, + "--type", + "json", + "-p", + json.dumps(ops), + ) + + _patch([{"op": "add", "path": path, "value": patched}]) # JSON-patch "add" replaces an existing member + try: + _wait_controller_rolled() + yield + finally: + _patch( + [{"op": "add", "path": path, "value": original}] + if original is not None + else [{"op": "remove", "path": path}] + ) + _wait_controller_rolled() + + +def controller_llm_unusable(): + """The rung-5 failure injection: point the Controller's PRB at ``UNUSABLE_LLM_BASE_URL`` for the + block (``ServicePolicyBuilder.build`` then raises after Provision has run), restored on exit.""" + return controller_env({LLM_BASE_URL_ENV: UNUSABLE_LLM_BASE_URL}) + + +def controller_logs() -> str: + """The current Controller pod's full log (the app container). The consumer logs each PRB failure + (``log_by_type``) and each dead-letter move at ERROR, so both reach the pod's stderr.""" + return kubectl( + "logs", "-n", CONTROLLER_NAMESPACE, resolve_controller_pod(), "-c", CONTROLLER_DEPLOYMENT, timeout=60 + ) + + +def publish_service_event(service_uuid: str) -> None: + """Re-fire the onboarding trigger for an EXISTING Keycloak client: publish on + ``aiac.apply.service.`` (the subject + ``{"id": ...}`` payload the ``aiac-event-listener`` SPI + publishes on ``CLIENT_CREATED``), so the agent consumer runs the same ``onboard_service`` → + ``compute_and_apply(focus_service=client_id)`` → ``reenable_service`` path a deploy fires. + + A redeploy cannot do this — the SPI publishes only on client CREATE, and a re-created client has a + new UUID (a new service, not the quarantined one). The publish runs from INSIDE the Controller pod, + which already carries ``nats-py`` and resolves the in-cluster broker (``NATS_URL`` or the + ``aiac-event-broker-service`` default), so the pytest host needs no NATS client or port-forward. + Raises when the JetStream publish is not acknowledged.""" + subject = f"aiac.apply.service.{service_uuid}" + script = ( + "import asyncio, os, nats\n" + f"subject = {json.dumps(subject)}\n" + f"payload = {json.dumps(json.dumps({'id': service_uuid}))}.encode()\n" + "async def main():\n" + " nc = await nats.connect(os.environ.get('NATS_URL', 'nats://aiac-event-broker-service:4222'))\n" + " try:\n" + " ack = await nc.jetstream().publish(subject, payload)\n" + " print('AB_PUB:%d' % ack.seq)\n" + " finally:\n" + " await nc.close()\n" + "asyncio.run(main())\n" + ) + out = kubectl( + "exec", + "-i", + "-n", + CONTROLLER_NAMESPACE, + resolve_controller_pod(), + "-c", + CONTROLLER_DEPLOYMENT, + "--", + "python", + "-", + input_text=script, + timeout=60, + ) + if "AB_PUB:" not in out: + raise RuntimeError(f"publish on {subject!r} was not acknowledged by JetStream: {out.strip()[:300]!r}") + + +def workload_client(admin, workload: str) -> dict | None: + """The Keycloak client representation (``id`` UUID, ``clientId``, ``enabled``, ``attributes``) of + ``{ns}/{workload}``, keyed on the client ``name`` like ``wait_for_registration``; ``None`` if absent.""" + admin.change_current_realm(TEST_REALM) + return next((c for c in admin.get_clients() if c.get("name") == f"{NAMESPACE}/{workload}"), None) + + +def authpolicy_policies(name: str) -> dict[str, str] | None: + """The ``AuthorizationPolicy`` CR ``name``'s policies as ``{path: rego}`` (``spec.policies[]``), or + ``None`` when no such CR exists. ``--ignore-not-found`` makes an absent CR an empty output, so an + unreachable API still raises instead of reading as "no CR".""" + out = kubectl("get", "authorizationpolicy", name, "-n", NAMESPACE, "-o", "json", "--ignore-not-found", timeout=30) + if not out.strip(): + return None + spec = json.loads(out).get("spec", {}) + return {p.get("path", ""): p.get("content", "") for p in spec.get("policies", [])} + + +def rego_binding_empty(rego: str, var: str) -> bool | None: + """Whether the top-level Rego binding ``var := …`` is an empty list/map (``[]`` / ``{}``) — the + form ``rego._render_list`` / ``_render_map`` emit for no entries. ``None`` when ``var`` is not + bound at all (a CR format change, surfaced by the caller rather than read as empty).""" + if not re.search(rf"^{re.escape(var)}\s*:=", rego, re.M): + return None + return re.search(rf"^{re.escape(var)}\s*:=\s*(\[\s*\]|\{{\s*\}})", rego, re.M) is not None + + +def cr_has_no_grants(policies: dict[str, str] | None) -> bool: + """True when ``policies`` is a present no-rules CR (the quarantine shell): both packages carry + ``default allow := false`` and every grant binding (``INBOUND_GRANT_BINDINGS`` / + ``OUTBOUND_GRANT_BINDINGS``) is bound and empty — so every inbound and outbound request is denied.""" + if not policies: + return False + inbound, outbound = policies.get(CR_INBOUND_PATH, ""), policies.get(CR_OUTBOUND_PATH, "") + if "default allow := false" not in inbound or "default allow := false" not in outbound: + return False + return all(rego_binding_empty(inbound, v) is True for v in INBOUND_GRANT_BINDINGS) and all( + rego_binding_empty(outbound, v) is True for v in OUTBOUND_GRANT_BINDINGS + ) + + +def cr_has_grants(policies: dict[str, str] | None) -> bool: + """True when ``policies`` is a real agent CR: the inbound package names the agent's scopes and + grants some user role one of them (non-empty ``agent_scopes`` + ``subject_role_allow_scopes``).""" + if not policies: + return False + inbound = policies.get(CR_INBOUND_PATH, "") + return all(rego_binding_empty(inbound, v) is False for v in ("agent_scopes", "subject_role_allow_scopes")) + + +def spm_present(client_id: str) -> bool: + """Whether the Policy Store holds an SPM for the service keyed ``client_id`` (the SPM key is the + client's ``clientId``). ``GET /policy/services/{id}`` takes the id as unpadded base64url + (``aiac.policy.model_store.keying.encode_service_id``): 200 -> True, 404 -> False; any other + answer raises, so an unreachable store is never read as "no SPM".""" + encoded = base64.urlsafe_b64encode(client_id.encode("utf-8")).decode("ascii").rstrip("=") + with port_forward( + STORE_TARGET, + namespace=STORE_NAMESPACE, + local_port=STORE_LOCAL_PORT, + remote_port=STORE_REMOTE_PORT, + ready_url=f"http://127.0.0.1:{STORE_LOCAL_PORT}/health", + ) as base_url: + resp = requests.get(f"{base_url}/policy/services/{encoded}", timeout=30) + if resp.status_code == 200: + return True + if resp.status_code == 404: + return False + raise AssertionError(f"GET /policy/services/<{client_id}> returned HTTP {resp.status_code}: {resp.text[:300]}") + + +def mcp_session_frames(tool_bare: str) -> list[dict]: + """One MCP session against the tool, in protocol order: ``initialize``, the + ``notifications/initialized`` notification (no ``id``), ``tools/list``, then a ``tools/call`` of the + **bare** ``tool_bare``. The demo tool is a stateless JSON-response FastMCP server, so each frame is + answered on its own (no ``Mcp-Session-Id`` to carry).""" + return [ + { + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "aiac-system-test", "version": "0"}, + }, + }, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": "2", "method": "tools/list", "params": {}}, + {"jsonrpc": "2.0", "id": "3", "method": "tools/call", "params": {"name": tool_bare, "arguments": {}}}, + ] + + +def mcp_session_decisions(ctx: dict, user: str, tool_bare: str) -> dict[str, tuple[str, int | None, str]]: + """Mint a fresh ``user`` token, send one MCP session (``mcp_session_frames``) through the agent's + outbound (token-exchange → OPA), and return ``{method: (decision, http_code, body)}``. A request + frame is classified by its body (``outbound_outcome``: a ``result`` frame = allow, an OPA error frame + = deny); the notification by its status (``notification_outcome``: 202 = allow, 403 = deny).""" + token = mint_token(user, scn.USER_PASSWORD, keycloak_url=ctx["keycloak_url"], realm=ctx["realm"]) + frames = mcp_session_frames(tool_bare) + raw = outbound_session_probe(token, frames, namespace=ctx["namespace"], agent_pod=resolve_agent_pod()) + return { + frame["method"]: ( + (outbound_outcome(code, body) if "id" in frame else notification_outcome(code)), + code, + body, + ) + for frame, (code, body) in zip(frames, raw) + } + + +@contextmanager +def pristine_stack(workloads: Sequence[str], *, policy_md: str = scn.POLICY_ABSTRACT) -> Iterator[dict]: + """The slate + teardown half of ``onboarded_stack`` with NO deploy and NO convergence poll, for a + flow whose onboarding is meant to fail (``onboarded_stack`` would wait for a happy-path allow that + never comes). Yields ``ctx = {"admin", "namespace", "keycloak_url", "realm"}``; the caller deploys. + + Same skip gates (pipeline wiring, env, event path — before any mutation), same no-workloads slate + (undeploy + ``_scrub_to_pristine`` + ``reenable_provisioned_clients`` + clients-gone poll), same + realm/users provisioning + ``sub`` mapper gate, ``policy.md`` mount and image load for + ``workloads``; and the same full-to-pristine teardown, verified (clients gone, no CR left).""" + require_pipeline(namespace=NAMESPACE, workloads=[]) + creds = require_env_or_skip("KEYCLOAK_URL", "KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD") + keycloak_url = creds["KEYCLOAK_URL"] + admin = connect_admin() + require_event_path(admin=admin, realm=TEST_REALM) + + undeploy_workload(scn.AGENT_WORKLOAD) + undeploy_workload(scn.TOOL_WORKLOAD) + _scrub_to_pristine(admin) + reenable_provisioned_clients(admin, TEST_REALM) + if not poll_until(lambda: not workload_clients_present(admin), timeout=DEPLOY_TIMEOUT, interval=5): + raise RuntimeError( + f"pre-run cleanup left Keycloak client(s) {workload_clients_present(admin)} for {NAMESPACE!r} — the " + "fresh deploy would not re-fire CLIENT_CREATED, so event-driven onboarding would never trigger." + ) + provision_realm_and_users(admin, TEST_REALM) + verify_subject_mapper(keycloak_url=keycloak_url, realm=TEST_REALM, user="dev-user", password=scn.USER_PASSWORD) + + try: + ensure_agent_policy(CONTROLLER_NAMESPACE, policy_md=policy_md) + load_workload_images(list(workloads)) + yield {"admin": admin, "namespace": NAMESPACE, "keycloak_url": keycloak_url, "realm": TEST_REALM} + finally: + for workload in reversed(list(workloads)): + undeploy_workload(workload) + _scrub_to_pristine(admin) + if not poll_until( + lambda: not workload_clients_present(admin) and no_authpolicies_remain(), timeout=DEPLOY_TIMEOUT, interval=5 + ): + msg = ( + f"teardown did not restore pristine: Keycloak client(s) still present=" + f"{workload_clients_present(admin)}, AuthorizationPolicy CR(s) remain={not no_authpolicies_remain()} " + f"in {NAMESPACE!r}." + ) + if sys.exc_info()[0] is not None: + log.error("%s [suppressed — a prior error is propagating]", msg) + else: + raise RuntimeError(msg) diff --git a/test/unit/agent/controller/test_routes.py b/test/unit/agent/controller/test_routes.py index 5cd7ee3f..29927814 100644 --- a/test/unit/agent/controller/test_routes.py +++ b/test/unit/agent/controller/test_routes.py @@ -4,12 +4,12 @@ mocked at the routes module boundary — no live services, no real graphs. """ -import os from unittest.mock import patch from fastapi import HTTPException from fastapi.testclient import TestClient +from aiac.agent.controller import routes from aiac.agent.controller.routes import app from aiac.agent.policy_rules_builder.conflict_detection import ( PolicyConflictError, @@ -29,6 +29,10 @@ client = TestClient(app) +# What onboard_service returns for the onboarded service: the route gets the UUID in the path, the PCE +# takes the clientId. +CLIENT_ID = "spiffe://example.org/ns/team1/sa/svc" + def _rule(role_id: str = "r-1", scope_id: str = "s-1") -> PolicyRule: return PolicyRule( @@ -53,36 +57,26 @@ def test_health_returns_ok_without_touching_handlers_or_pce(): def test_apply_service_dispatches_to_orchestrator_and_calls_pce_once(): - # No AIAC_DEFAULT_EFFECT env → the on-ramp resolves DENY (today's least-privilege default), - # which the route passes to onboard_service and forwards to the PCE. with ( - patch( - "aiac.agent.controller.routes.onboard_service", - return_value=([], False, RuleEffect.DENY), - ) as orch, + patch("aiac.agent.controller.routes.onboard_service", return_value=([], False, CLIENT_ID)) as orch, patch("aiac.agent.controller.routes.compute_and_apply") as pce, patch("aiac.agent.controller.routes.reenable_service") as reenable, - patch.dict("os.environ", {}, clear=False) as _env, ): - os.environ.pop("AIAC_DEFAULT_EFFECT", None) resp = client.post("/apply/service/svc-123") assert resp.status_code == 200 - orch.assert_called_once_with("svc-123", RuleEffect.DENY) - # The onboard route forwards the orchestrator's default_effect to the PCE (least-privilege here). - pce.assert_called_once_with([], False, RuleEffect.DENY) - # The client is re-enabled only after the PCE apply succeeds. + orch.assert_called_once_with("svc-123") + # The focus service reaches the PCE routing guard as the clientId from onboard_service, not the + # path's Keycloak UUID. + pce.assert_called_once_with([], False, focus_service=CLIENT_ID) + # The client is re-enabled (an IdP call, by its UUID) only after the PCE apply succeeds. reenable.assert_called_once_with("svc-123") -def test_apply_service_default_effect_env_allow_reaches_orchestrator_and_pce(): - # The #149 harness patches AIAC_DEFAULT_EFFECT=Allow onto the Controller before onboarding; - # the on-ramp translates it to RuleEffect.ALLOW and threads it to onboard_service + the PCE. +def test_apply_service_ignores_a_default_effect_env(): + # The default effect is always DENY: a stale AIAC_DEFAULT_EFFECT on the Controller has no effect. with ( - patch( - "aiac.agent.controller.routes.onboard_service", - return_value=([], False, RuleEffect.ALLOW), - ) as orch, + patch("aiac.agent.controller.routes.onboard_service", return_value=([], False, CLIENT_ID)) as orch, patch("aiac.agent.controller.routes.compute_and_apply") as pce, patch("aiac.agent.controller.routes.reenable_service"), patch.dict("os.environ", {"AIAC_DEFAULT_EFFECT": "Allow"}, clear=False), @@ -90,26 +84,10 @@ def test_apply_service_default_effect_env_allow_reaches_orchestrator_and_pce(): resp = client.post("/apply/service/svc-123") assert resp.status_code == 200 - orch.assert_called_once_with("svc-123", RuleEffect.ALLOW) - pce.assert_called_once_with([], False, RuleEffect.ALLOW) - - -def test_apply_service_default_effect_env_unrecognised_falls_back_to_deny(): - # A garbage/empty env value must not crash onboarding — it degrades to the safe DENY default. - with ( - patch( - "aiac.agent.controller.routes.onboard_service", - return_value=([], False, RuleEffect.DENY), - ) as orch, - patch("aiac.agent.controller.routes.compute_and_apply") as pce, - patch("aiac.agent.controller.routes.reenable_service"), - patch.dict("os.environ", {"AIAC_DEFAULT_EFFECT": "banana"}, clear=False), - ): - resp = client.post("/apply/service/svc-123") - - assert resp.status_code == 200 - orch.assert_called_once_with("svc-123", RuleEffect.DENY) - pce.assert_called_once_with([], False, RuleEffect.DENY) + orch.assert_called_once_with("svc-123") + pce.assert_called_once_with([], False, focus_service=CLIENT_ID) + assert not hasattr(routes, "DEFAULT_EFFECT_ENV") + assert not hasattr(routes, "_default_effect_from_env") def test_apply_policy_build_dispatches_to_build_subagent(): @@ -182,7 +160,7 @@ def test_controller_forwards_handler_rules_and_override_verbatim(): with ( patch( "aiac.agent.controller.routes.onboard_service", - return_value=(rules, False, RuleEffect.DENY), + return_value=(rules, False, CLIENT_ID), ), patch("aiac.agent.controller.routes.compute_and_apply") as pce, patch("aiac.agent.controller.routes.reenable_service"), @@ -191,11 +169,10 @@ def test_controller_forwards_handler_rules_and_override_verbatim(): assert resp.status_code == 200 # Exactly one PCE call, with the handler's own rules object and flag — not a rebuilt/empty one. - pce.assert_called_once_with(rules, False, RuleEffect.DENY) - forwarded_rules, forwarded_override, forwarded_default_effect = pce.call_args.args + pce.assert_called_once_with(rules, False, focus_service=CLIENT_ID) + forwarded_rules, forwarded_override = pce.call_args.args assert forwarded_rules is rules assert forwarded_override is False - assert forwarded_default_effect is RuleEffect.DENY def test_handler_upstream_error_surfaces_status_and_skips_pce(): @@ -219,7 +196,7 @@ def test_apply_service_does_not_reenable_when_pce_apply_raises(): with ( patch( "aiac.agent.controller.routes.onboard_service", - return_value=([], False, RuleEffect.DENY), + return_value=([], False, CLIENT_ID), ), patch( "aiac.agent.controller.routes.compute_and_apply", diff --git a/test/unit/agent/eventbus/test_consumer.py b/test/unit/agent/eventbus/test_consumer.py index 51d24cfe..8ce23a41 100644 --- a/test/unit/agent/eventbus/test_consumer.py +++ b/test/unit/agent/eventbus/test_consumer.py @@ -28,7 +28,10 @@ PolicyRulesBuilderError, UnparseableLLMResponseError, ) -from aiac.policy.model.models import RuleEffect + +# What onboard_service returns for the onboarded service: the subject carries the Keycloak UUID, the +# PCE takes the clientId. +CLIENT_ID = "spiffe://example.org/ns/team1/sa/svc-1" def _fake_msg(subject: str, num_delivered: int = 1) -> MagicMock: @@ -50,26 +53,20 @@ def _fake_nc() -> MagicMock: def test_handle_routes_service_subject_to_onboard_service(): - # onboard_service is the one handler that returns its own (rules, override, default_effect), - # so _handle forwards its 3-tuple verbatim (only onboarding carries a caller-set default_effect). - with patch( - "aiac.agent.eventbus.consumer.onboard_service", - return_value=([], False, RuleEffect.ALLOW), - ) as onboard: + with patch("aiac.agent.eventbus.consumer.onboard_service", return_value=([], False, CLIENT_ID)) as onboard: result = _handle("aiac.apply.service.svc-1") + # The UUID from the subject goes to onboard_service; the focus service comes back as the clientId. onboard.assert_called_once_with("svc-1") - assert result == ([], False, RuleEffect.ALLOW) + assert result == ([], False, CLIENT_ID) def test_handle_routes_role_subject_to_update_role(): - # update_role returns (rules, override); _handle normalizes it to a 3-tuple with the - # least-privilege DENY default (role updates carry no caller-requestable default_effect). with patch("aiac.agent.eventbus.consumer.update_role", return_value=([], True)) as role: result = _handle("aiac.apply.role.role-1") role.assert_called_once_with("role-1") - assert result == ([], True, RuleEffect.DENY) + assert result == ([], True, None) # no focus service: only an onboarding has one def test_handle_decodes_percent_encoded_dotted_role_name(): @@ -79,17 +76,15 @@ def test_handle_decodes_percent_encoded_dotted_role_name(): result = _handle("aiac.apply.role.team%2Eadmin") role.assert_called_once_with("team.admin") - assert result == ([], True, RuleEffect.DENY) + assert result == ([], True, None) def test_handle_routes_policy_build_subject(): - # build_policy returns (rules, override); _handle normalizes it to a 3-tuple with the - # least-privilege DENY default (policy builds carry no caller-requestable default_effect). with patch("aiac.agent.eventbus.consumer.build_policy", return_value=([], False)) as build: result = _handle("aiac.apply.policy.build") build.assert_called_once_with() - assert result == ([], False, RuleEffect.DENY) + assert result == ([], False, None) def test_handle_raises_for_unknown_subject(): @@ -105,22 +100,40 @@ def test_dispatch_acks_on_success(): with ( patch( "aiac.agent.eventbus.consumer.onboard_service", - return_value=([], False, RuleEffect.DENY), + return_value=([], False, CLIENT_ID), ), patch("aiac.agent.eventbus.consumer.compute_and_apply") as pce, patch("aiac.agent.eventbus.consumer.reenable_service") as reenable, ): asyncio.run(consumer._dispatch(msg)) - # _dispatch forwards the normalized (rules, override, default_effect) triple to the PCE. - pce.assert_called_once_with([], False, RuleEffect.DENY) - # UC1 service-onboarding subject: the client is re-enabled with the derived service_id, only - # after the PCE apply succeeds. + # _dispatch forwards the handler's (rules, override) pair to the PCE, with the focus service's + # clientId (from onboard_service, not the subject's UUID) for the routing guard. + pce.assert_called_once_with([], False, focus_service=CLIENT_ID) + # UC1 service-onboarding subject: the client is re-enabled (an IdP call) by the subject's UUID, + # only after the PCE apply succeeds. reenable.assert_called_once_with("svc-1") msg.ack.assert_called_once() msg.term.assert_not_called() +def test_dispatch_passes_no_focus_service_for_a_role_subject(): + consumer = AiacEventConsumer() + consumer._nc = AsyncMock() + msg = _fake_msg("aiac.apply.role.role-1") + + with ( + patch("aiac.agent.eventbus.consumer.update_role", return_value=([], True)), + patch("aiac.agent.eventbus.consumer.compute_and_apply") as pce, + patch("aiac.agent.eventbus.consumer.reenable_service") as reenable, + ): + asyncio.run(consumer._dispatch(msg)) + + pce.assert_called_once_with([], True, focus_service=None) + reenable.assert_not_called() + msg.ack.assert_called_once() + + def test_dispatch_does_not_reenable_when_pce_apply_raises(): # The re-enable is strictly post-apply: if compute_and_apply raises, _dispatch never reaches # reenable_service, so the client stays disabled (the failed-service marker). The failure is @@ -132,7 +145,7 @@ def test_dispatch_does_not_reenable_when_pce_apply_raises(): with ( patch( "aiac.agent.eventbus.consumer.onboard_service", - return_value=([], False, RuleEffect.DENY), + return_value=([], False, CLIENT_ID), ), patch("aiac.agent.eventbus.consumer.compute_and_apply", side_effect=RuntimeError("pce boom")), patch("aiac.agent.eventbus.consumer.reenable_service") as reenable, diff --git a/test/unit/agent/shared/test_focal_entities.py b/test/unit/agent/shared/test_focal_entities.py index 05179848..adb0505a 100644 --- a/test/unit/agent/shared/test_focal_entities.py +++ b/test/unit/agent/shared/test_focal_entities.py @@ -51,11 +51,11 @@ def _scope(name, *, scope_id=None, service_id="", aiac_managed=True): ) -def _service(service_id, *, ref=None, roles=None, scopes=None, service_type=ServiceType.TOOL): +def _service(service_id, *, ref=None, roles=None, scopes=None, service_type=ServiceType.TOOL, enabled=True): return Service( id=service_id, serviceId=ref or service_id, - enabled=True, + enabled=enabled, type=service_type, roles=roles or [], scopes=scopes or [], @@ -190,3 +190,53 @@ def test_focus_resolved_by_uuid_not_clientid(self): with pytest.raises(HTTPException) as ei: _resolve(ServiceType.TOOL, services=[focus, other], subjects=[], service_id=client_id) assert ei.value.status_code == 404 + + +class TestDisabledServices: + """A disabled client is a failed (quarantined) service: its roles and scopes are not candidates, + so no build grants anything to or from it. The focus service still resolves while it is disabled + (a re-onboarding runs before ``reenable_service``).""" + + def test_disabled_service_roles_and_scopes_are_not_candidates(self): + focus = _service(FOCUS_ID, service_type=ServiceType.AGENT) + live = _service( + OTHER_ID, + roles=[_role("live.agent", kind=RoleKind.AGENT)], + scopes=[_scope("live.read", service_id=OTHER_ID)], + ) + failed = _service( + "svc-failed", + roles=[_role("failed.agent", kind=RoleKind.AGENT)], + scopes=[_scope("failed.read", service_id="svc-failed")], + enabled=False, + ) + + result = _resolve(ServiceType.AGENT, services=[focus, live, failed], subjects=[]) + + assert [r.name for r in result.candidate_roles] == ["live.agent"] + assert [s.name for s in result.other_scopes] == ["live.read"] + + def test_disabled_service_role_held_by_a_user_is_not_a_user_candidate(self): + # The disabled service still owns its roles: a user holding one does not turn it into a + # user-kind candidate. + failed_role = _role("failed.agent", kind=RoleKind.AGENT) + focus = _service(FOCUS_ID, service_type=ServiceType.AGENT) + failed = _service("svc-failed", roles=[failed_role], enabled=False) + + result = _resolve( + ServiceType.AGENT, services=[focus, failed], subjects=[_subject("alice", roles=[failed_role])] + ) + + assert result.candidate_roles == [] + + def test_disabled_focus_service_still_resolves_its_own_entities(self): + own_role = _role("focus.agent") + own_scope = _scope("focus.read", service_id=FOCUS_ID) + focus = _service(FOCUS_ID, roles=[own_role], scopes=[own_scope], service_type=ServiceType.AGENT, enabled=False) + other = _service(OTHER_ID, scopes=[_scope("other.read", service_id=OTHER_ID)]) + + result = _resolve(ServiceType.AGENT, services=[focus, other], subjects=[]) + + assert [r.name for r in result.own_roles] == ["focus.agent"] + assert [s.name for s in result.own_scopes] == ["focus.read"] + assert [s.name for s in result.other_scopes] == ["other.read"] diff --git a/test/unit/agent/uc/onboarding/provision/test_analyze_tool.py b/test/unit/agent/uc/onboarding/provision/test_analyze_tool.py index 0de1fd94..0fdae302 100644 --- a/test/unit/agent/uc/onboarding/provision/test_analyze_tool.py +++ b/test/unit/agent/uc/onboarding/provision/test_analyze_tool.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +import requests from fastapi import HTTPException from aiac.agent.uc.onboarding.provision import kube, nodes @@ -152,3 +153,68 @@ def test_returns_tools_from_result(self, monkeypatch): tools = [{"name": "t1", "description": "d"}] with patch("requests.post", return_value=self._resp(tools)): assert nodes._mcp_tools_list("http://x/mcp", token="abc") == tools + + +class TestMcpToolsListWaitsForEndpoint: + """The operator registers the tool's Keycloak client (which fires the onboarding event) while it + still rolls the tool pod onto the AuthBridge-injected template. The Service can then have no + ready endpoint for some seconds, so `tools/list` gets "connection refused". Discovery waits for + the endpoint (bounded by `AIAC_MCP_DISCOVERY_READY_TIMEOUT`) instead of failing into a NATS + redelivery that comes only after `ACK_WAIT` (600 s).""" + + @pytest.fixture(autouse=True) + def fast(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + monkeypatch.setenv("AIAC_MCP_DISCOVERY_READY_TIMEOUT", "5") + monkeypatch.setattr(nodes, "_MCP_READY_INTERVAL", 0) + + def _ok(self, tools): + resp = MagicMock() + resp.json.return_value = {"result": {"tools": tools}} + resp.raise_for_status = MagicMock() + return resp + + def _status(self, code): + resp = MagicMock() + resp.status_code = code + resp.raise_for_status.side_effect = requests.HTTPError(f"{code}", response=resp) + return resp + + def test_connection_refused_then_ready_returns_the_tools(self): + tools = [{"name": "t1", "description": "d"}] + refused = requests.ConnectionError("Connection refused") + with patch("requests.post", side_effect=[refused, refused, self._ok(tools)]) as post: + assert nodes._mcp_tools_list("http://x/mcp", token="abc") == tools + assert post.call_count == 3 + + @pytest.mark.parametrize("code", [502, 503, 504]) + def test_sidecar_gateway_error_then_ready_returns_the_tools(self, code): + tools = [{"name": "t1"}] + with patch("requests.post", side_effect=[self._status(code), self._ok(tools)]) as post: + assert nodes._mcp_tools_list("http://x/mcp") == tools + assert post.call_count == 2 + + def test_endpoint_never_ready_fails_after_the_ready_timeout(self, monkeypatch): + monkeypatch.setenv("AIAC_MCP_DISCOVERY_READY_TIMEOUT", "0.2") + with patch("requests.post", side_effect=requests.ConnectionError("Connection refused")) as post: + with pytest.raises(requests.ConnectionError): + nodes._mcp_tools_list("http://x/mcp") + assert post.call_count > 1 + + @pytest.mark.parametrize("code", [401, 404]) + def test_client_error_is_not_waited_for(self, code): + with patch("requests.post", return_value=self._status(code)) as post: + with pytest.raises(requests.HTTPError): + nodes._mcp_tools_list("http://x/mcp") + assert post.call_count == 1 + + def test_read_timeout_is_not_waited_for(self): + # A tool that accepts the connection but hangs is not a "not ready yet" endpoint. + with patch("requests.post", side_effect=requests.ReadTimeout("read timed out")) as post: + with pytest.raises(requests.ReadTimeout): + nodes._mcp_tools_list("http://x/mcp") + assert post.call_count == 1 + + def test_bad_ready_timeout_env_falls_back_to_the_default(self, monkeypatch): + monkeypatch.setenv("AIAC_MCP_DISCOVERY_READY_TIMEOUT", "banana") + assert nodes._mcp_ready_timeout() == nodes._MCP_READY_TIMEOUT_DEFAULT diff --git a/test/unit/agent/uc/onboarding/test_orchestrator.py b/test/unit/agent/uc/onboarding/test_orchestrator.py index 805c4d0e..526c895a 100644 --- a/test/unit/agent/uc/onboarding/test_orchestrator.py +++ b/test/unit/agent/uc/onboarding/test_orchestrator.py @@ -5,15 +5,18 @@ here via the module-level `build_provision_graph` / `ServicePolicyBuilder` seams, and the idp-library `Configuration` is mocked via the `_config` seam -- no live graph, IdP, Kubernetes, or LLM. The Orchestrator applies nothing (no PCE call); it returns -`(list[PolicyRule], override=False, default_effect)` to the Controller, which makes the +`(list[PolicyRule], override=False)` to the Controller, which makes the single `compute_and_apply` call afterwards. Issue 171 adds a compensating rollback (UC1-only): on any of the four typed build failures the Orchestrator tears down exactly what Provision *created this run* (the -created-manifest), unsets the client type, disables the client (failed-service marker), -and re-raises. On success it re-enables the client (idempotent). +created-manifest) and disables the client (failed-service marker). The client type is +kept (handoff 11 B2). Then it calls the PCE's ``quarantine`` (the policy teardown) and +re-raises. The caller re-enables the client after a successful apply (idempotent). """ +import ast +import inspect import threading from unittest.mock import MagicMock, patch @@ -28,10 +31,20 @@ UnparseableLLMResponseError, ) from aiac.agent.uc.onboarding import orchestrator -from aiac.idp.configuration.models import Role, Scope, ServiceType -from aiac.policy.model.models import RuleEffect +from aiac.idp.configuration.models import Role, Scope, Service, ServiceType +# The onboarding trigger carries the Keycloak UUID; the PCE takes the clientId. The two differ, so a +# test fails if the UUID leaks to the PCE. SERVICE_ID = "svc-1" +CLIENT_ID = "spiffe://example.org/ns/team1/sa/svc-1" + + +@pytest.fixture(autouse=True) +def quarantine(): + """The PCE ``quarantine`` seam — patched for every test, so the failure path never reaches a + live PCE. Tests that check it request this fixture by name.""" + with patch.object(orchestrator, "quarantine") as mock: + yield mock def _graph(*, created_roles=(), created_scopes=(), service_type=ServiceType.AGENT): @@ -46,6 +59,11 @@ def _graph(*, created_roles=(), created_scopes=(), service_type=ServiceType.AGEN return graph +def _service(): + """The onboarded service as the IdP returns it: UUID ``SERVICE_ID``, clientId ``CLIENT_ID``.""" + return Service(id=SERVICE_ID, serviceId=CLIENT_ID, enabled=True) + + def _config_returning(service): """A mocked Configuration whose get_service() yields `service`.""" config = MagicMock() @@ -77,34 +95,22 @@ def test_provision_result_fed_to_builder_and_rules_returned_with_override_false( with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.return_value = rules result = orchestrator.onboard_service(SERVICE_ID) # service_type produced by Provision is fed into the Service Policy Builder spb.build.assert_called_once_with(SERVICE_ID, ServiceType.AGENT) - # Orchestrator returns the builder's rules paired with the append flag and the - # default_effect (least-privilege DENY when the caller does not request otherwise). - assert result == (rules, False, RuleEffect.DENY) + # Orchestrator returns the builder's rules, the append flag, and the service's clientId (the + # PCE focus service). There is no default effect to forward: the deployed Rego always denies + # an unmentioned pair. + assert result == (rules, False, CLIENT_ID) + def test_onboard_service_takes_no_default_effect(self): + import inspect -class TestDefaultEffectForwarding: - def test_caller_requested_default_effect_is_returned_for_forwarding(self): - # A caller onboarding a service that should default to ALLOW passes default_effect through; - # the orchestrator returns it verbatim so the Controller forwards it to compute_and_apply. - graph = _graph(service_type=ServiceType.AGENT) - - with ( - patch.object(orchestrator, "build_provision_graph", return_value=graph), - patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), - ): - spb.build.return_value = [object()] - rules, override, default_effect = orchestrator.onboard_service(SERVICE_ID, default_effect=RuleEffect.ALLOW) - - assert override is False - assert default_effect is RuleEffect.ALLOW + assert "default_effect" not in inspect.signature(orchestrator.onboard_service).parameters def test_provision_graph_invoked_with_service_id_in_trigger(self): # The service_id must reach Provision as the trigger's entity_id (Keycloak @@ -115,7 +121,7 @@ def test_provision_graph_invoked_with_service_id_in_trigger(self): with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.return_value = [object()] orchestrator.onboard_service(SERVICE_ID) @@ -132,12 +138,39 @@ def test_builder_not_called_and_provision_error_propagates(self): with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), patch.object(orchestrator, "ServicePolicyBuilder") as spb, + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), + ): + with pytest.raises(HTTPException) as exc: + orchestrator.onboard_service(SERVICE_ID) + + assert exc.value.status_code == 502 + spb.build.assert_not_called() + + +class TestServiceReadFails: + def test_nothing_is_provisioned_or_quarantined_when_the_service_read_fails(self, quarantine): + # The clientId is resolved before Provision, so a failed IdP read happens before anything + # exists that needs compensation: no Provision, no build, no rollback, no quarantine. + # A raw IdP error (the Configuration library raises RuntimeError) becomes the same + # HTTPException(502) that Provision raises, not a generic 500. + config = MagicMock() + config.get_service.side_effect = RuntimeError("IdP config unavailable") + graph = _graph() + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=graph), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + patch.object(orchestrator, "_config", return_value=config), ): with pytest.raises(HTTPException) as exc: orchestrator.onboard_service(SERVICE_ID) assert exc.value.status_code == 502 + graph.invoke.assert_not_called() spb.build.assert_not_called() + config.set_service_enabled.assert_not_called() + quarantine.assert_not_called() + assert SERVICE_ID not in orchestrator._service_locks class TestSuccessDoesNotReEnableClient: @@ -146,7 +179,7 @@ def test_success_does_not_touch_enabled_and_does_not_tear_down(self): # the caller does that via reenable_service(), but only AFTER compute_and_apply succeeds # (so a PCE failure leaves the client disabled). onboard_service tears nothing down and # never sets enabled here. - service = object() + service = _service() config = _config_returning(service) graph = _graph( created_roles=[Role(id="r1", name="weather.forecast", composite=False)], @@ -164,14 +197,13 @@ def test_success_does_not_touch_enabled_and_does_not_tear_down(self): config.set_service_enabled.assert_not_called() config.delete_service_role.assert_not_called() config.delete_service_scope.assert_not_called() - config.unset_service_type.assert_not_called() class TestReenableService: def test_reenable_sets_enabled_true_and_touches_nothing_else(self): # reenable_service is the UC1-only, idempotent post-apply hook the caller runs after a # successful compute_and_apply. It resolves the service and sets enabled=true — nothing else. - service = object() + service = _service() config = _config_returning(service) with patch.object(orchestrator, "_config", return_value=config): @@ -181,17 +213,20 @@ def test_reenable_sets_enabled_true_and_touches_nothing_else(self): config.set_service_enabled.assert_called_once_with(service, True) config.delete_service_role.assert_not_called() config.delete_service_scope.assert_not_called() - config.unset_service_type.assert_not_called() class TestRollbackOnBuildFailure: + @pytest.mark.parametrize("service_type", [ServiceType.AGENT, ServiceType.TOOL]) @pytest.mark.parametrize("error", _rollback_errors(), ids=lambda e: type(e).__name__) - def test_rollback_tears_down_created_disables_and_reraises(self, error): + def test_rollback_then_quarantine_then_reraise(self, error, service_type, quarantine): role = Role(id="r1", name="weather.forecast", composite=False) scope = Scope(id="s1", name="weather.history") - service = object() + service = _service() config = _config_returning(service) - graph = _graph(created_roles=[role], created_scopes=[scope]) + graph = _graph(created_roles=[role], created_scopes=[scope], service_type=service_type) + order = MagicMock() + order.attach_mock(config, "config") + order.attach_mock(quarantine, "quarantine") with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), @@ -205,18 +240,31 @@ def test_rollback_tears_down_created_disables_and_reraises(self, error): # The ORIGINAL error instance is re-raised, not swallowed or re-wrapped. assert ei.value is error # Teardown of exactly what this run created (unmap-then-delete is done inside the - # Configuration primitives), then unset type, then disable (failed-service marker). + # Configuration primitives), then disable (failed-service marker), then the PCE quarantine + # keyed by the clientId. The Service is read from the IdP once, by its UUID. + config.get_service.assert_called_once_with(SERVICE_ID) config.delete_service_role.assert_called_once_with(service, role) config.delete_service_scope.assert_called_once_with(service, scope) - config.unset_service_type.assert_called_once_with(service) config.set_service_enabled.assert_called_once_with(service, False) + # The created roles go to the quarantine: the rollback deleted them from the IdP, so the + # quarantine cannot find them in the catalog, but their grants can be on other SPMs. + quarantine.assert_called_once_with(CLIENT_ID, [role]) + calls = [c[0] for c in order.method_calls if c[0] != "config.get_service"] + assert calls == [ + "config.delete_service_role", + "config.delete_service_scope", + "config.set_service_enabled", + "quarantine", + ] def test_disable_is_the_last_rollback_action(self): # The failed-service marker (enabled=false) must land AFTER the teardown, so a - # crash mid-teardown never leaves a disabled-but-still-provisioned client. + # crash mid-teardown never leaves a disabled-but-still-provisioned client. It lands + # BEFORE the quarantine (asserted above), so no run after the teardown sees the service + # as enabled. role = Role(id="r1", name="weather.forecast", composite=False) scope = Scope(id="s1", name="weather.history") - service = object() + service = _service() config = _config_returning(service) graph = _graph(created_roles=[role], created_scopes=[scope]) @@ -232,7 +280,50 @@ def test_disable_is_the_last_rollback_action(self): names = [c[0] for c in config.method_calls] assert names.index("set_service_enabled") > names.index("delete_service_role") assert names.index("set_service_enabled") > names.index("delete_service_scope") - assert names.index("set_service_enabled") > names.index("unset_service_type") + + def test_quarantine_failure_propagates_after_the_rollback(self, quarantine): + # A failed quarantine (e.g. the PDP is down) surfaces loudly — it is not swallowed. The + # rollback already ran, so the client is disabled. The quarantine error replaces even a + # permanent build error, so the consumer retries (and quarantines again) instead of + # term()ing a fail-open service; the build error stays on __context__. + service = _service() + config = _config_returning(service) + quarantine.side_effect = RuntimeError("PDP unreachable") + build_error = PolicyRulesBuilderError("auditor rejected after retries") + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=_graph()), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + patch.object(orchestrator, "_config", return_value=config), + ): + spb.build.side_effect = build_error + with pytest.raises(RuntimeError, match="PDP unreachable") as ei: + orchestrator.onboard_service(SERVICE_ID) + + config.set_service_enabled.assert_called_once_with(service, False) + assert ei.value.__context__ is build_error + + def test_rollback_failure_still_quarantines(self, quarantine): + # A failed rollback (e.g. Keycloak is down) must not skip the quarantine: else a failed + # first onboarding stays fail-open. The rollback error propagates (a retryable error), and + # the build error stays on its __context__. + config = _config_returning(_service()) + config.delete_service_role.side_effect = RuntimeError("Keycloak unreachable") + build_error = PolicyRulesBuilderError("auditor rejected after retries") + created_role = Role(id="r1", name="weather.forecast", composite=False) + graph = _graph(created_roles=[created_role]) + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=graph), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + patch.object(orchestrator, "_config", return_value=config), + ): + spb.build.side_effect = build_error + with pytest.raises(RuntimeError, match="Keycloak unreachable") as ei: + orchestrator.onboard_service(SERVICE_ID) + + quarantine.assert_called_once_with(CLIENT_ID, [created_role]) + assert ei.value.__context__ is build_error class TestRollbackLogInjectionSanitized: @@ -243,7 +334,7 @@ def test_crlf_in_service_id_and_names_cannot_forge_log_lines(self, caplog): evil_id = "svc-1\r\nINFO forged: attacker-controlled entry" role = Role(id="r1", name="role\r\ninjected", composite=False) scope = Scope(id="s1", name="scope\ninjected") - service = object() + service = Service(id=evil_id, serviceId=CLIENT_ID, enabled=True) config = _config_returning(service) graph = _graph(created_roles=[role], created_scopes=[scope]) @@ -271,7 +362,7 @@ def test_reused_by_name_entities_are_never_torn_down(self): reused_role = Role(id="r-shared", name="shared.role", composite=False) created_scope = Scope(id="s-new", name="weather.new") reused_scope = Scope(id="s-shared", name="shared.scope") - service = object() + service = _service() config = _config_returning(service) graph = _graph(created_roles=[created_role], created_scopes=[created_scope]) @@ -293,10 +384,10 @@ def test_reused_by_name_entities_are_never_torn_down(self): class TestRollbackScopedToFourErrors: - def test_non_rollback_builder_error_propagates_without_teardown(self): + def test_non_rollback_builder_error_propagates_without_teardown(self, quarantine): # A builder error that is NOT one of the four typed failures (e.g. an HTTPException # from IdP focus resolution) propagates untouched -- no teardown, no disable. - service = object() + service = _service() config = _config_returning(service) graph = _graph( created_roles=[Role(id="r1", name="weather.forecast", composite=False)], @@ -317,8 +408,8 @@ def test_non_rollback_builder_error_propagates_without_teardown(self): graph.invoke.assert_called_once() config.delete_service_role.assert_not_called() config.delete_service_scope.assert_not_called() - config.unset_service_type.assert_not_called() config.set_service_enabled.assert_not_called() + quarantine.assert_not_called() class TestRetryableReRunRollsBackIdempotently: @@ -327,7 +418,7 @@ def test_second_attempt_rolls_back_only_its_own_created_objects(self): # The first attempt already deleted its objects; the second re-creates fresh ones # (its own created-manifest) and tears down ONLY those -- no crash on already-gone # objects (the Configuration deletes are idempotent; the mock never raises). - service = object() + service = _service() config = _config_returning(service) def _attempt(role, scope): @@ -370,7 +461,7 @@ def test_success_path_evicts_the_entry(self): with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.return_value = [object()] orchestrator.onboard_service(SERVICE_ID) @@ -387,7 +478,7 @@ def test_rollback_path_evicts_the_entry(self, error): with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.side_effect = error with pytest.raises(type(error)): @@ -402,7 +493,7 @@ def test_passthrough_fault_evicts_the_entry(self): with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.side_effect = HTTPException(502, "IdP config unavailable") with pytest.raises(HTTPException): @@ -418,6 +509,7 @@ def test_provision_stage_fault_evicts_the_entry(self): with ( patch.object(orchestrator, "build_provision_graph", return_value=graph), patch.object(orchestrator, "ServicePolicyBuilder"), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): with pytest.raises(HTTPException): orchestrator.onboard_service(SERVICE_ID) @@ -446,7 +538,7 @@ def _build(_service_id, _service_type): with ( patch.object(orchestrator, "build_provision_graph", return_value=_graph()), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.side_effect = _build @@ -490,7 +582,7 @@ def _build(_service_id, _service_type): with ( patch.object(orchestrator, "build_provision_graph", return_value=_graph()), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.side_effect = _build @@ -524,7 +616,7 @@ def _build(_service_id, _service_type): with ( patch.object(orchestrator, "build_provision_graph", return_value=_graph()), patch.object(orchestrator, "ServicePolicyBuilder") as spb, - patch.object(orchestrator, "_config", return_value=_config_returning(object())), + patch.object(orchestrator, "_config", return_value=_config_returning(_service())), ): spb.build.side_effect = _build @@ -542,3 +634,14 @@ def _run(service_id): assert not t.is_alive(), "onboard_service thread hung" assert errors == [], "different service_ids did not run concurrently" + + +class TestPceOwnsThePdp: + def test_orchestrator_does_not_import_the_pdp_library(self): + # The PCE owns the PDP: the orchestrator reaches it only through the PCE (quarantine). + tree = ast.parse(inspect.getsource(orchestrator)) + imported = {node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)} | { + alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names + } + assert not any(name and name.startswith("aiac.pdp") for name in imported) + assert "aiac.policy.computation" in imported diff --git a/test/unit/idp/configuration/test_configuration.py b/test/unit/idp/configuration/test_configuration.py index a481ec1f..a328733b 100644 --- a/test/unit/idp/configuration/test_configuration.py +++ b/test/unit/idp/configuration/test_configuration.py @@ -1310,66 +1310,6 @@ def test_idempotent_already_gone_does_not_raise(self, monkeypatch): assert result is None -# --------------------------------------------------------------------------- -# unset_service_type — clears the client.type attribute -# -# Reuses the set_service_type POST /services/{id}/type pattern with an empty/clear -# type; the service clears client.type via the same read-merge-update_client path. -# Returns the updated Service (type now None). Idempotent — clearing an -# already-clear type is not an error. -# --------------------------------------------------------------------------- - - -class TestUnsetServiceType: - def _make_service(self, **kwargs): - defaults = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} - return Service.model_validate({**defaults, **kwargs}) - - def test_returns_service_with_type_cleared(self, monkeypatch): - monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) - service = self._make_service(attributes={"client.type": "Agent"}) - # The service returns the updated client with client.type gone → type resolves to None. - updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} - with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)): - result = Configuration.for_realm(REALM).unset_service_type(service) - assert isinstance(result, Service) - assert result.type is None - - def test_posts_to_type_endpoint_with_empty_type_body(self, monkeypatch): - monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) - service = self._make_service() - updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} - with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)) as m: - Configuration.for_realm(REALM).unset_service_type(service) - assert m.call_args[0][0] == f"{BASE}/services/svc-uuid/type" - assert m.call_args[1].get("json") == {"type": ""} - - def test_forwards_realm_as_query_param(self, monkeypatch): - monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) - service = self._make_service() - updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} - with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)) as m: - Configuration.for_realm(REALM).unset_service_type(service) - assert m.call_args[1].get("params") == {"realm": REALM} - - def test_raises_on_non_2xx(self, monkeypatch): - monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) - service = self._make_service() - with patch("aiac.idp.configuration.api.requests.post", return_value=_err(502)): - with pytest.raises(RuntimeError): - Configuration.for_realm(REALM).unset_service_type(service) - - def test_idempotent_clearing_already_clear_type_does_not_raise(self, monkeypatch): - # A service with no client.type: clearing is a no-op the service reports as - # success; the returned Service still has type None and no error is raised. - monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) - service = self._make_service() # type already None - updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} - with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)): - result = Configuration.for_realm(REALM).unset_service_type(service) - assert result.type is None - - # --------------------------------------------------------------------------- # set_service_enabled — the writer for Service.enabled # diff --git a/test/unit/idp/service/configuration/keycloak/test_main.py b/test/unit/idp/service/configuration/keycloak/test_main.py index 4eef0cef..80ef1e48 100644 --- a/test/unit/idp/service/configuration/keycloak/test_main.py +++ b/test/unit/idp/service/configuration/keycloak/test_main.py @@ -996,7 +996,7 @@ def test_returns_502_on_keycloak_error(self): def test_already_gone_role_404_is_idempotent_success(self): # Idempotent teardown: on a UC1 rollback retry the realm role is already gone, so # get_realm_role_by_id raises KeycloakGetError(404). This must be treated as success - # (200), not 502 — otherwise the rollback aborts before unset-type + disable. + # (200), not 502 — otherwise the rollback aborts before the disable. admin = MagicMock() admin.get_client_service_account_user.return_value = {"id": "sa-user-id"} admin.get_realm_role_by_id.side_effect = KeycloakError(error_message="Could not find role", response_code=404) @@ -1056,7 +1056,7 @@ def test_returns_502_on_keycloak_error(self): def test_already_gone_scope_404_is_idempotent_success(self): # Idempotent teardown: on a UC1 rollback retry the client scope (or its mapping) is # already gone, so the delete raises KeycloakDeleteError(404). This must be treated as - # success (200), not 502 — otherwise the rollback aborts before unset-type + disable. + # success (200), not 502 — otherwise the rollback aborts before the disable. admin = MagicMock() admin.delete_client_default_client_scope.side_effect = KeycloakError( error_message="Could not find client scope", response_code=404 diff --git a/test/unit/pdp/service/policy/opa/test_main.py b/test/unit/pdp/service/policy/opa/test_main.py index 82bdf507..764f6c7b 100644 --- a/test/unit/pdp/service/policy/opa/test_main.py +++ b/test/unit/pdp/service/policy/opa/test_main.py @@ -6,14 +6,13 @@ no real Kubernetes API is contacted. The additive ``POLICY_WRITER_DUMP_REGO`` local-dump toggle is covered here too (it never gates or replaces the CR write). -Note on the delete-by-id endpoint: its route param ``{agent_id}`` is a single -path segment, and a valid namespaced id (``/`` or a SPIFFE URI) carries -slashes. The library client percent-encodes them and the ASGI server decodes the -segment back, but the ``TestClient``/httpx transport collapses ``%2F`` -> ``/`` -before the request is sent, so a namespaced id cannot reach the param through -``TestClient``. Those cases therefore call the route handler function directly -(the FastAPI decorators leave the functions callable), which still exercises the -full write + error-mapping path through the mocked ``_api``. +Note on the by-id endpoints: a valid namespaced id (``/`` or a SPIFFE +URI) carries slashes. The library client percent-encodes them as one segment +(``%2F``), but the server decodes ``%2F`` back to ``/`` before routing — the live +uvicorn server and the ``TestClient`` alike — so both routes take the id with the +``{agent_id:path}`` converter. ``TestLibraryEncodedAgentId`` drives each route with +the exact URL the library builds. The other delete cases call the route handler +function directly (the FastAPI decorators leave the functions callable). """ import json @@ -23,6 +22,7 @@ from fastapi.testclient import TestClient from kubernetes.client import ApiException +from aiac.pdp.policy.library.api import _agent_id_segment from aiac.pdp.service.policy.opa import main from aiac.pdp.service.policy.opa.main import app from aiac.policy.model.models import AgentPolicyModel @@ -176,6 +176,32 @@ def test_other_api_exception_502(self, api): assert "error" in _body(resp) +# --------------------------------------------------------------------------- +# The by-id routes, driven with the exact URL the library client builds +# --------------------------------------------------------------------------- + + +class TestLibraryEncodedAgentId: + """The library sends ``/policy/agents/``. A SPIFFE id has slashes, + which the server decodes back before routing, so a single-segment route returns 404 — the + live failure of the PCE ``quarantine`` (``apply_agent_policy``) and ``decommission`` + (``delete_agent_policy``).""" + + @pytest.mark.parametrize("agent_id", [SPIFFE, "team1/github-agent"]) + def test_upsert_by_id_reaches_the_route(self, api, agent_id): + resp = TestClient(app).post(f"/policy/agents/{_agent_id_segment(agent_id)}", json=_agent(agent_id)) + assert resp.status_code == 204 + kwargs = api.patch_namespaced_custom_object.call_args.kwargs + assert (kwargs["namespace"], kwargs["name"]) == ("team1", "github-agent") + + @pytest.mark.parametrize("agent_id", [SPIFFE, "team1/github-agent"]) + def test_delete_by_id_reaches_the_route(self, api, agent_id): + resp = TestClient(app).delete(f"/policy/agents/{_agent_id_segment(agent_id)}") + assert resp.status_code == 204 + kwargs = api.delete_namespaced_custom_object.call_args.kwargs + assert (kwargs["namespace"], kwargs["name"]) == ("team1", "github-agent") + + # --------------------------------------------------------------------------- # DELETE /policy -> list-by-label then delete each # --------------------------------------------------------------------------- diff --git a/test/unit/pdp/service/policy/opa/test_rego.py b/test/unit/pdp/service/policy/opa/test_rego.py index 06c257e3..e36ee58e 100644 --- a/test/unit/pdp/service/policy/opa/test_rego.py +++ b/test/unit/pdp/service/policy/opa/test_rego.py @@ -27,6 +27,7 @@ generate_outbound_rego, identity_ref, ) +from aiac.policy.computation.engine import _fresh_apm from aiac.policy.model.models import AgentPolicyModel, PolicyRule, RuleEffect # Full SPIFFE id of the github-tool workload that owns the outbound scopes. @@ -230,8 +231,6 @@ def test_inbound_split_scope_maps_from_split_rule_lists(): def test_inbound_subject_gates_use_identity_fields(): rego = generate_inbound_rego(_github_agent()) - assert "subject_allow_ok if {" in rego - assert "subject_deny_ok if {" in rego assert "some role in subject_roles[input.identity.subject]" in rego assert "some scope in subject_role_allow_scopes[role]" in rego assert "some scope in subject_role_deny_scopes[role]" in rego @@ -352,17 +351,20 @@ def test_outbound_no_prefixed_scope_leaks(): def test_outbound_gates_use_nested_identity_and_mcp_input(): rego = generate_outbound_rego(_github_agent()) - assert "subject_allow_ok if {" in rego - assert "subject_deny_ok if {" in rego assert "some role in subject_roles[input.identity.subject]" in rego - assert "input.mcp.params.name in subject_role_allow_scopes[role]" in rego - assert "input.mcp.params.name in subject_role_deny_scopes[role]" in rego - assert "target_allow_ok if {" in rego - assert "input.mcp.params.name in target_allow_scopes[input.identity.service_id]" in rego - assert "target_deny_ok if {" in rego - assert "input.mcp.params.name in target_deny_scopes[input.identity.service_id]" in rego + assert "tool in subject_role_allow_scopes[role]" in rego + assert "tool in subject_role_deny_scopes[role]" in rego + assert "subject_allow_ok if { subject_allows(input.mcp.params.name) }" in rego + assert "subject_deny_ok if { subject_denies(input.mcp.params.name) }" in rego + assert "target_allow_ok if { target_allows(input.mcp.params.name) }" in rego + assert "tool in target_allow_scopes[input.identity.service_id]" in rego + assert "target_deny_ok if { target_denies(input.mcp.params.name) }" in rego + assert "tool in target_deny_scopes[input.identity.service_id]" in rego assert "default allow := false" in rego - assert "allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }" in rego + assert ( + 'allow if { input.mcp.method == "tools/call"; subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }' + in rego + ) # The inbound-flavoured subject gate must NOT appear in the outbound package. assert "scope in agent_scopes" not in rego @@ -398,7 +400,10 @@ def test_outbound_empty_model_renders_valid_empty_literals(): assert "target_allow_scopes := {}" in rego assert "target_deny_scopes := {}" in rego assert "default allow := false" in rego - assert "allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }" in rego + assert ( + 'allow if { input.mcp.method == "tools/call"; subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }' + in rego + ) # --- per-scope AND intersection + deny-overrides semantics --- @@ -451,10 +456,13 @@ def test_outbound_per_scope_and_structural(): assert ( '"spiffe://localtest.me/ns/team1/sa/github-tool": ["scope-b", "scope-c", "scope-d"]' in rego ) # capability allow gate - assert "input.mcp.params.name in subject_role_allow_scopes[role]" in rego - assert "input.mcp.params.name in subject_role_deny_scopes[role]" in rego - assert "input.mcp.params.name in target_allow_scopes[input.identity.service_id]" in rego - assert "allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }" in rego + assert "subject_allow_ok if { subject_allows(input.mcp.params.name) }" in rego + assert "subject_deny_ok if { subject_denies(input.mcp.params.name) }" in rego + assert "target_allow_ok if { target_allows(input.mcp.params.name) }" in rego + assert ( + 'allow if { input.mcp.method == "tools/call"; subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }' + in rego + ) @pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") @@ -479,7 +487,7 @@ def test_outbound_per_scope_and_denies_mismatch(tool_name: str, allowed: bool): "data.authbridge.client.outbound.request.allow", { "identity": {"subject": "user1", "service_id": GH_TOOL}, - "mcp": {"params": {"name": tool_name}}, + "mcp": {"method": "tools/call", "params": {"name": tool_name}}, }, allowed, ) @@ -557,91 +565,51 @@ def test_inbound_source_deny_overrides_behavioural(source: str, allowed: bool): ) -# --- per-policy default_effect (issue #145) --------------------------------- +# --- always DENY: no configurable default effect --------------------------- # -# default_effect decides how a (role, scope) pair that NO rule mentions -# resolves: DENY (the default, least-privilege) reproduces today's -# `default allow := false` byte-for-byte; ALLOW opens the default while explicit -# denies still override. Only the trailing decision block changes — every -# declaration map and every *_allow_ok / *_deny_ok gate is emitted identically. - - -def test_inbound_default_effect_omitted_is_deny_byte_for_byte(): - """Omitting default_effect (→ DENY) reproduces today's inbound decision block - verbatim, and is byte-for-byte identical to an explicit DENY.""" - omitted = generate_inbound_rego(_github_agent()) - assert "default allow := false" in omitted - assert "allow if { subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok }" in omitted - explicit_deny = generate_inbound_rego(_github_agent_with_effect(RuleEffect.DENY)) - assert omitted == explicit_deny - - -def test_outbound_default_effect_omitted_is_deny_byte_for_byte(): - """Omitting default_effect (→ DENY) reproduces today's outbound decision block - verbatim, and is byte-for-byte identical to an explicit DENY.""" - omitted = generate_outbound_rego(_github_agent()) - assert "default allow := false" in omitted - assert "allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }" in omitted - explicit_deny = generate_outbound_rego(_github_agent_with_effect(RuleEffect.DENY)) - assert omitted == explicit_deny - - -def _github_agent_with_effect(effect: RuleEffect) -> AgentPolicyModel: - model = _github_agent() - return model.model_copy(update={"default_effect": effect}) - - -def test_inbound_allow_default_shape(): - """ALLOW mode: default flips to true, deny gates become separate - `allow := false if` rules, and the DENY-mode allow-conjunction is gone.""" - rego = generate_inbound_rego(_github_agent_with_effect(RuleEffect.ALLOW)) - assert "default allow := true" in rego - assert "allow := false if { subject_deny_ok }" in rego - assert "allow := false if { source_deny_ok }" in rego - # The DENY-mode allow-conjunction must not appear. - assert "default allow := false" not in rego - assert "allow if { subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok }" not in rego - - -def test_outbound_allow_default_shape_is_deny_if_either_side(): - """ALLOW mode outbound: deny-if-either-side, NOT a negated allow-gate AND. - - Guards §3d — a `not subject_allow_ok` / `not target_allow_ok` flip would - wrongly DENY every unmentioned (role, tool) pair.""" - rego = generate_outbound_rego(_github_agent_with_effect(RuleEffect.ALLOW)) - assert "default allow := true" in rego - assert "allow := false if { subject_deny_ok }" in rego - assert "allow := false if { target_deny_ok }" in rego - # The DENY-mode allow-conjunction must not appear. - assert "default allow := false" not in rego - assert "allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok }" not in rego - # The wrong "unmentioned → deny" flip must NOT be emitted. - assert "not subject_allow_ok" not in rego - assert "not target_allow_ok" not in rego - - -def test_allow_mode_still_emits_inert_allow_maps_and_gates(): - """Under ALLOW the allow-side machinery is still emitted (inert but - structurally symmetric, expected by downstream tooling).""" - inbound = generate_inbound_rego(_github_agent_with_effect(RuleEffect.ALLOW)) - assert "subject_role_allow_scopes := {" in inbound - assert "source_role_allow_scopes := {" in inbound - assert "subject_allow_ok if {" in inbound - assert "source_allow_ok if {" in inbound - outbound = generate_outbound_rego(_github_agent_with_effect(RuleEffect.ALLOW)) - assert "subject_role_allow_scopes := {" in outbound - assert "target_allow_scopes := {" in outbound - assert "agent_role_scopes := {" in outbound - assert "subject_allow_ok if {" in outbound - assert "target_allow_ok if {" in outbound - - -# --- behavioural: default_effect decides the unmentioned pair ---------------- +# A (role, scope) pair that NO rule mentions is always denied. Both packages emit +# exactly one decision shape: `default allow := false` plus one allow +# conjunction with inline `not …_deny_ok` guards. There is no permissive +# (`default allow := true`) branch. + + +def test_inbound_decision_block_is_the_single_deny_default(): + rego = generate_inbound_rego(_github_agent()) + assert "default allow := false" in rego + assert "allow if { subject_allow_ok; source_allow_ok; not subject_deny_ok; not source_deny_ok }" in rego + assert "default allow := true" not in rego + assert "allow := false if" not in rego + + +def test_outbound_decision_block_is_the_single_deny_default(): + rego = generate_outbound_rego(_github_agent()) + assert rego.count("default allow := false") == 1 + assert "default allow := true" not in rego + assert "allow := false if" not in rego + # Two allow rules, both under the DENY default: the per-tool tools/call check and the session. + assert rego.count("\nallow if {") == 2 + + +def test_agent_policy_model_has_no_default_effect(): + assert "default_effect" not in AgentPolicyModel.model_fields + + +def test_legacy_default_effect_allow_in_payload_is_ignored(): + """A stored or posted APM that still carries ``default_effect: Allow`` renders + the DENY default (the field is gone and ``extra="ignore"`` drops it).""" + payload = _github_agent().model_dump() + payload["default_effect"] = RuleEffect.ALLOW.value + model = AgentPolicyModel.model_validate(payload) + for rego in (generate_inbound_rego(model), generate_outbound_rego(model)): + assert "default allow := false" in rego + assert "default allow := true" not in rego + + +# --- behavioural: an unmentioned pair is denied ------------------------------ def _inbound_unmentioned_model() -> AgentPolicyModel: - """A subject holding a role that NO allow/deny rule mentions — the unmentioned - (role, scope) case that resolves to default_effect.""" + """A subject holding a role that NO allow/deny rule mentions.""" lonely = _role("lonely") access = _scope("github-agent.access") return _model( @@ -652,27 +620,17 @@ def _inbound_unmentioned_model() -> AgentPolicyModel: @pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") -@pytest.mark.parametrize( - "effect, allowed", - [ - (RuleEffect.DENY, False), # unmentioned → least-privilege deny - (RuleEffect.ALLOW, True), # unmentioned → permissive default - ], -) -def test_inbound_unmentioned_resolves_to_default_effect(effect: RuleEffect, allowed: bool): - model = _inbound_unmentioned_model().model_copy(update={"default_effect": effect}) - rego = generate_inbound_rego(model) +def test_inbound_unmentioned_pair_is_denied(): _assert_opa_allow( - rego, + generate_inbound_rego(_inbound_unmentioned_model()), "data.authbridge.client.inbound.request.allow", {"identity": {"subject": "some-user"}}, - allowed, + False, ) def _outbound_unmentioned_model() -> AgentPolicyModel: - """A (subject role, tool) that NO outbound rule mentions and no target scope - grants — the unmentioned outbound case resolving to default_effect.""" + """A (subject role, tool) that NO outbound rule mentions and no target scope grants.""" lonely = _role("lonely") return _model( agent_id=GH_AGENT, @@ -681,77 +639,18 @@ def _outbound_unmentioned_model() -> AgentPolicyModel: @pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") -@pytest.mark.parametrize( - "effect, allowed", - [ - (RuleEffect.DENY, False), - (RuleEffect.ALLOW, True), - ], -) -def test_outbound_unmentioned_resolves_to_default_effect(effect: RuleEffect, allowed: bool): - model = _outbound_unmentioned_model().model_copy(update={"default_effect": effect}) - rego = generate_outbound_rego(model) +def test_outbound_unmentioned_pair_is_denied(): _assert_opa_allow( - rego, + generate_outbound_rego(_outbound_unmentioned_model()), "data.authbridge.client.outbound.request.allow", { "identity": {"subject": "some-user", "service_id": GH_TOOL}, - "mcp": {"params": {"name": "anything"}}, + "mcp": {"method": "tools/call", "params": {"name": "anything"}}, }, - allowed, - ) - - -@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") -@pytest.mark.parametrize("effect", [RuleEffect.DENY, RuleEffect.ALLOW]) -def test_inbound_deny_overrides_holds_under_both_defaults(effect: RuleEffect): - """A subject holding both an allow and a deny role on the same audience scope - is barred regardless of default_effect (deny-overrides).""" - model = _inbound_deny_model().model_copy(update={"default_effect": effect}) - rego = generate_inbound_rego(model) - _assert_opa_allow( - rego, - "data.authbridge.client.inbound.request.allow", - {"identity": {"subject": "bad-user"}}, False, ) -@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") -@pytest.mark.parametrize( - "tool_name, deny_allowed, allow_allowed", - [ - # tool_name DENY ALLOW - ("scope-c", True, True), # both allow gates, not denied → allowed either way - ("scope-a", False, True), # user-only: AND fails under DENY, unmentioned→allow under ALLOW - ("scope-b", False, True), # agent-only: AND fails under DENY, unmentioned→allow under ALLOW - ("scope-d", False, False), # subject-denied: deny-overrides under BOTH - ], -) -def test_outbound_gate_flip_under_allow_keeps_deny_override(tool_name: str, deny_allowed: bool, allow_allowed: bool): - """The outbound gate-shape flip: under DENY the two allow gates AND (A user-only - and B agent-only both denied); under ALLOW that AND drops so A and B become - allowed (unmentioned by any deny), while the subject-denied D stays denied.""" - input_doc = { - "identity": {"subject": "user1", "service_id": GH_TOOL}, - "mcp": {"params": {"name": tool_name}}, - } - deny_model = _outbound_and_model() # default_effect defaults to DENY - _assert_opa_allow( - generate_outbound_rego(deny_model), - "data.authbridge.client.outbound.request.allow", - input_doc, - deny_allowed, - ) - allow_model = _outbound_and_model().model_copy(update={"default_effect": RuleEffect.ALLOW}) - _assert_opa_allow( - generate_outbound_rego(allow_model), - "data.authbridge.client.outbound.request.allow", - input_doc, - allow_allowed, - ) - - def _assert_opa_allow(rego: str, query: str, input_doc: dict, expected: bool) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "policy.rego" @@ -780,9 +679,8 @@ def _assert_opa_allow(rego: str, query: str, input_doc: dict, expected: bool) -> def _opa_verdict(rego: str, query: str, input_doc: dict) -> bool: """Evaluate ``query`` against ``rego`` for ``input_doc`` and return the bool. - The value-returning sibling of ``_assert_opa_allow`` — used by the toggle - differential below, which must *compare* the two defaults' verdicts cell by - cell rather than pin each to a literal.""" + The value-returning sibling of ``_assert_opa_allow`` — used by the matrix + tests below, which compare each verdict against an oracle table.""" with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "policy.rego" path.write_text(rego) @@ -806,40 +704,24 @@ def _opa_verdict(rego: str, query: str, input_doc: dict) -> bool: return json.loads(out)["result"][0]["expressions"][0]["value"] -# --- default_effect toggle isolation (issue #150) --------------------------- +# --- Policy B matrix under the DENY default --------------------------------- # -# The clean controlled experiment the cross-policy live full-deployment test -# (test/system/disabled/test_policy_pipeline_denyworld.py) cannot give on its own: -# it can only run default=ALLOW, so it never *directly* compares the two defaults -# on one policy. Here we build ONE Policy-B outbound APM by hand (the hand-built -# analogue of what the PRB emits — NOT a PRB re-run), generate its Rego twice -# from the SAME model — once default_effect=DENY, once ALLOW — and opa-eval the -# full {developer,tester,devops} × {source,issues}-{read,write} matrix against -# each bundle. With the APM, the generator, and the inputs all pinned, the toggle -# is the only variable: it must flip ONLY the unmentioned (devops, issues-*) cell -# and leave every explicit-rule cell (the 4 allow, the 6 deny) invariant. That -# isolates "the toggle changed the base" from "an explicit rule fired". +# One hand-built Policy-B outbound APM (the hand-built analogue of what the PRB +# emits), opa-evaluated over the full {developer,tester,devops} x +# {source,issues}-{read,write} matrix. The capability gate (target_allow_scopes) +# provisions all four tool scopes wide-open, as a real github-tool deployment +# does, so the two-gate AND reduces to the subject side. # -# Policy B, subject side only (mirrors handoff 02 §5's corrected PRB emission): # ALLOW developer -> source-read, source-write ; tester -> issues-read, issues-write # DENY developer -> issues-* ; tester -> source-* ; devops -> source-* -# devops -> issues-* is mentioned by NO rule, so it resolves to default_effect: -# deny under DENY (least-privilege), allow under ALLOW (permissive default). -# -# The prose emits no target-side DENY, but the capability gate (target_allow_scopes) -# still provisions all four tool scopes wide-open — exactly as a real github-tool -# deployment does. That open capability gate is what makes the two-gate AND under -# default=DENY reduce to the subject side, so the 4 allow cells read allow under -# BOTH defaults; without it default=DENY would deny every cell (target gate never -# passing) and the explicit allow cells would not be invariant across the toggle. +# devops -> issues-* is mentioned by NO rule, so it is denied (no permissive default). _POLICY_B_ROLES = ("developer", "tester", "devops") _POLICY_B_TOOLS = ("source-read", "source-write", "issues-read", "issues-write") -_POLICY_B_FLIP_CELLS = {("devops", "issues-read"), ("devops", "issues-write")} -# Oracle verdicts (allow=True / deny=False) under default=DENY, computed from the -# rule lists by hand — NEVER read back from the Rego under test. -_POLICY_B_DENY_MATRIX: dict[tuple[str, str], bool] = { +# Oracle verdicts (allow=True / deny=False), computed from the rule lists by +# hand — NEVER read back from the Rego under test. +_POLICY_B_MATRIX: dict[tuple[str, str], bool] = { ("developer", "source-read"): True, # explicit ALLOW + capability gate open ("developer", "source-write"): True, # explicit ALLOW ("developer", "issues-read"): False, # explicit DENY @@ -850,14 +732,8 @@ def _opa_verdict(rego: str, query: str, input_doc: dict) -> bool: ("tester", "issues-write"): True, # explicit ALLOW ("devops", "source-read"): False, # explicit DENY ("devops", "source-write"): False, # explicit DENY - ("devops", "issues-read"): False, # UNMENTIONED -> least-privilege deny - ("devops", "issues-write"): False, # UNMENTIONED -> least-privilege deny -} -# Under default=ALLOW only the two unmentioned cells flip to allow. -_POLICY_B_ALLOW_MATRIX: dict[tuple[str, str], bool] = { - **_POLICY_B_DENY_MATRIX, - ("devops", "issues-read"): True, # UNMENTIONED -> permissive default (flip) - ("devops", "issues-write"): True, # UNMENTIONED -> permissive default (flip) + ("devops", "issues-read"): False, # UNMENTIONED -> deny + ("devops", "issues-write"): False, # UNMENTIONED -> deny } @@ -877,9 +753,6 @@ def _policy_b_outbound_model() -> AgentPolicyModel: "tester": [tester], "devops": [devops], }, - # Capability gate provisioned wide-open (the tool exposes all four scopes); - # NO target-side DENY. This makes the outbound two-gate AND reduce to the - # subject side, so the matrix is driven purely by the subject allow/deny. target_allow_scopes={GH_TOOL: [source_read, source_write, issues_read, issues_write]}, outbound_subject_allow_rules=[ _rule(developer, source_read), @@ -898,53 +771,152 @@ def _policy_b_outbound_model() -> AgentPolicyModel: ) -def test_policy_b_only_decision_block_differs_between_defaults(): - """Cluster-free: flipping default_effect on the SAME model changes ONLY the - trailing decision block — every declaration map and every ``*_allow_ok`` / - ``*_deny_ok`` gate is emitted identically. Split on the ``default allow`` line; - the whole prefix (declarations + gates) must be byte-equal across both.""" - model = _policy_b_outbound_model() - deny_rego = generate_outbound_rego(model) # default_effect defaults to DENY - allow_rego = generate_outbound_rego(model.model_copy(update={"default_effect": RuleEffect.ALLOW})) - assert deny_rego != allow_rego - assert "default allow := false" in deny_rego - assert "default allow := true" in allow_rego - marker = "default allow" - assert deny_rego.split(marker)[0] == allow_rego.split(marker)[0] - - @pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") -def test_policy_b_default_effect_toggle_flips_only_unmentioned_cell(): - """Behavioural differential: generate Policy B twice from the SAME model - (default=DENY vs default=ALLOW) and opa-eval the full 3x4 matrix against each. - - Asserts, cell by cell: each verdict matches the hand-computed oracle for its - default, every explicit-rule cell (the 4 allow, the 6 deny) is IDENTICAL under - both defaults, and ONLY the unmentioned devops -> issues-read / issues-write - cell flips (deny under DENY, allow under ALLOW). This is the toggle-isolation - the ALLOW-only live test cannot give.""" - model = _policy_b_outbound_model() - deny_rego = generate_outbound_rego(model) # default_effect defaults to DENY - allow_rego = generate_outbound_rego(model.model_copy(update={"default_effect": RuleEffect.ALLOW})) +def test_policy_b_matrix_under_deny_default(): + rego = generate_outbound_rego(_policy_b_outbound_model()) query = "data.authbridge.client.outbound.request.allow" - flipped: set[tuple[str, str]] = set() for role in _POLICY_B_ROLES: for tool in _POLICY_B_TOOLS: - cell = (role, tool) input_doc = { "identity": {"subject": role, "service_id": GH_TOOL}, - "mcp": {"params": {"name": tool}}, + "mcp": {"method": "tools/call", "params": {"name": tool}}, } - deny_v = _opa_verdict(deny_rego, query, input_doc) - allow_v = _opa_verdict(allow_rego, query, input_doc) - # 1. Each verdict matches the hand-computed oracle for its default. - assert deny_v is _POLICY_B_DENY_MATRIX[cell], f"DENY mode {cell}" - assert allow_v is _POLICY_B_ALLOW_MATRIX[cell], f"ALLOW mode {cell}" - # 2. Invariance vs flip: explicit cells hold, only the unmentioned flips. - if cell in _POLICY_B_FLIP_CELLS: - assert deny_v is False and allow_v is True, f"flip cell {cell}" - flipped.add(cell) - else: - assert deny_v is allow_v, f"explicit cell not invariant: {cell}" - # 3. Exactly the two unmentioned cells flipped — no explicit cell moved. - assert flipped == _POLICY_B_FLIP_CELLS + assert _opa_verdict(rego, query, input_doc) is _POLICY_B_MATRIX[(role, tool)], (role, tool) + + +# --- the no-rules CR of a quarantined agent ---------------------------------- +# +# The PCE's ``quarantine`` replaces a failed agent's CR with the ``_fresh_apm`` +# shell (no rules, no scopes). Under the DENY default it denies every inbound and +# every outbound request — also end-user traffic that carries the ``rossoctl`` +# platform client, and also the MCP session messages. + +_OUTBOUND = "data.authbridge.client.outbound.request.allow" +_INBOUND = "data.authbridge.client.inbound.request.allow" +_SESSION_METHODS = ("initialize", "notifications/initialized", "ping", "tools/list") + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize( + "identity", + [ + {"subject": "dev-user"}, + {"subject": "dev-user", "client_id": "rossoctl"}, + {"subject": "dev-user", "client_id": "spiffe://localtest.me/ns/team1/sa/other-agent"}, + {}, + ], + ids=["end-user", "platform-client", "agent-client", "anonymous"], +) +def test_no_rules_cr_denies_every_inbound_request(identity): + _assert_opa_allow(generate_inbound_rego(_fresh_apm(GH_AGENT)), _INBOUND, {"identity": identity}, False) + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize( + "mcp", + [{"method": "tools/call", "params": {"name": "source-read"}}] + [{"method": m} for m in _SESSION_METHODS], + ids=lambda mcp: mcp["method"], +) +def test_no_rules_cr_denies_every_outbound_request(mcp): + _assert_opa_allow( + generate_outbound_rego(_fresh_apm(GH_AGENT)), + _OUTBOUND, + {"identity": {"subject": "dev-user", "service_id": GH_TOOL}, "mcp": mcp}, + False, + ) + + +# --- B4: MCP session messages on the outbound ------------------------------- +# +# ``initialize``, ``notifications/initialized``, ``ping`` and ``tools/list`` +# carry no tool name. They are allowed to a target (``input.identity.service_id``) +# iff at least ONE tool of that target passes the full per-tool check for this +# request: the user gate allows it, the target gate allows it, and no deny vetoes +# it. ``tools/call`` stays a per-tool check on ``input.mcp.params.name`` (and +# requires the ``tools/call`` method). Every other MCP method is denied. + +OTHER_TOOL = "spiffe://localtest.me/ns/team1/sa/other-tool" + + +def _session_model() -> AgentPolicyModel: + """dev-user may use source-read on github-tool; ops-user holds a role that is allowed + issues-read but also denied it (a vetoed allow); guest holds a role that no rule grants. + The target gate admits source-read and issues-read on github-tool, and nothing on other-tool + except a target-denied tool.""" + dev = _role("developer") + ops = _role("ops") + lonely = _role("lonely") + source_read = _scope("github-tool.source-read", GH_TOOL) + issues_read = _scope("github-tool.issues-read", GH_TOOL) + blocked = _scope("other-tool.blocked", OTHER_TOOL) + return _model( + agent_id=GH_AGENT, + subject_roles={"dev-user": [dev], "ops-user": [ops], "guest": [lonely]}, + target_allow_scopes={GH_TOOL: [source_read, issues_read], OTHER_TOOL: [blocked]}, + target_deny_scopes={OTHER_TOOL: [blocked]}, + outbound_subject_allow_rules=[_rule(dev, source_read), _rule(ops, issues_read), _rule(dev, blocked)], + outbound_subject_deny_rules=[_rule(ops, issues_read, RuleEffect.DENY)], + ) + + +def _outbound_input(subject, mcp, target=GH_TOOL): + return {"identity": {"subject": subject, "service_id": target}, "mcp": mcp} + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize("method", _SESSION_METHODS) +def test_session_message_allowed_when_a_tool_of_the_target_passes(method): + rego = generate_outbound_rego(_session_model()) + _assert_opa_allow(rego, _OUTBOUND, _outbound_input("dev-user", {"method": method}), True) + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize("method", _SESSION_METHODS) +@pytest.mark.parametrize( + "subject, target", + [ + ("guest", GH_TOOL), # no role of the user grants any tool of the target + ("ops-user", GH_TOOL), # the only allow is vetoed by a subject deny + ("dev-user", OTHER_TOOL), # the only allow is vetoed by a target deny + ("dev-user", "spiffe://localtest.me/ns/team1/sa/unknown"), # a target with no grant + ("nobody", GH_TOOL), # a user with no roles + ], + ids=["no-grant", "subject-veto", "target-veto", "unknown-target", "no-roles"], +) +def test_session_message_denied_when_no_tool_of_the_target_passes(method, subject, target): + rego = generate_outbound_rego(_session_model()) + _assert_opa_allow(rego, _OUTBOUND, _outbound_input(subject, {"method": method}, target), False) + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize( + "subject, tool, allowed", + [ + ("dev-user", "source-read", True), # granted + ("dev-user", "issues-read", False), # the session is open, but this tool is not granted + ("ops-user", "issues-read", False), # vetoed + ], +) +def test_tools_call_stays_a_per_tool_check(subject, tool, allowed): + rego = generate_outbound_rego(_session_model()) + mcp = {"method": "tools/call", "params": {"name": tool}} + _assert_opa_allow(rego, _OUTBOUND, _outbound_input(subject, mcp), allowed) + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize( + "mcp", + [ + {"method": "resources/list"}, + {"method": "prompts/list"}, + {"method": "resources/read", "params": {"uri": "file:///etc/passwd"}}, + # A granted tool name under a method that is not tools/call: the per-tool branch + # requires the tools/call method. + {"method": "prompts/get", "params": {"name": "source-read"}}, + {"params": {"name": "source-read"}}, # no method at all + ], + ids=["resources-list", "prompts-list", "resources-read", "prompts-get-granted-name", "no-method"], +) +def test_every_other_mcp_method_is_denied(mcp): + rego = generate_outbound_rego(_session_model()) + _assert_opa_allow(rego, _OUTBOUND, _outbound_input("dev-user", mcp), False) diff --git a/test/unit/policy/computation/test_engine.py b/test/unit/policy/computation/test_engine.py index 8f8c131a..724022ae 100644 --- a/test/unit/policy/computation/test_engine.py +++ b/test/unit/policy/computation/test_engine.py @@ -15,6 +15,7 @@ """ import os +import threading from contextlib import ExitStack, contextmanager from unittest.mock import patch @@ -56,23 +57,23 @@ def _scope(id, name=None, *, service_id="", aiac_managed=True) -> Scope: return Scope(id=id, name=name or id, attributes=attributes, serviceId=service_id) -def _service(service_id, *, type=None, roles=None, scopes=None) -> Service: +def _service(service_id, *, type=None, roles=None, scopes=None, enabled=True) -> Service: return Service( id=f"uuid-{service_id}", serviceId=service_id, - enabled=True, + enabled=enabled, type=type, roles=roles or [], scopes=scopes or [], ) -def _agent(service_id, *, roles=None, scopes=None) -> Service: - return _service(service_id, type=ServiceType.AGENT, roles=roles, scopes=scopes) +def _agent(service_id, *, roles=None, scopes=None, enabled=True) -> Service: + return _service(service_id, type=ServiceType.AGENT, roles=roles, scopes=scopes, enabled=enabled) -def _tool(service_id, *, roles=None, scopes=None) -> Service: - return _service(service_id, type=ServiceType.TOOL, roles=roles, scopes=scopes) +def _tool(service_id, *, roles=None, scopes=None, enabled=True) -> Service: + return _service(service_id, type=ServiceType.TOOL, roles=roles, scopes=scopes, enabled=enabled) def _rule(role, scope, effect=RuleEffect.ALLOW) -> PolicyRule: @@ -115,6 +116,7 @@ def __init__(self, initial=None): self.policy_pushes = [] # [PolicyModel] captured from apply_policy self.service_deletes = [] # [service_id] captured from delete_service_policy self.agent_deletes = [] # [agent_id] captured from delete_agent_policy + self.agent_applies = [] # [(agent_id, APM)] captured from apply_agent_policy def get_service_policy(self, service_id): if service_id in self.data: @@ -143,6 +145,9 @@ def delete_service_policy(self, service_id): def delete_agent_policy(self, agent_id): self.agent_deletes.append(agent_id) + def apply_agent_policy(self, agent_id, model): + self.agent_applies.append((agent_id, model.model_copy(deep=True))) + # ---- assertion helpers ------------------------------------------------ # @property def apply_policy_count(self): @@ -190,15 +195,21 @@ def engine_env(catalog, store): stack.enter_context( patch("aiac.policy.computation.engine.delete_agent_policy", side_effect=store.delete_agent_policy) ) + stack.enter_context( + patch("aiac.policy.computation.engine.apply_agent_policy", side_effect=store.apply_agent_policy) + ) from aiac.policy.computation.engine import compute_and_apply yield compute_and_apply -def run_engine(rules, *, catalog=None, store_initial=None, override=False, default_effect=RuleEffect.DENY) -> FakeStore: +def run_engine(rules, *, catalog=None, store_initial=None, override=False, focus_service=None) -> FakeStore: store = FakeStore(store_initial) with engine_env(catalog or [], store) as compute_and_apply: - compute_and_apply(rules, override=override, default_effect=default_effect) + if focus_service is None: + compute_and_apply(rules, override=override) + else: + compute_and_apply(rules, override=override, focus_service=focus_service) return store @@ -718,13 +729,16 @@ def test_reconcile_preserves_live_edges_and_is_idempotent(): def test_reconcile_skips_when_service_absent_from_catalog(): # A transient catalog miss (owning service not returned by get_services()) must never wipe an - # SPM — reconcile is skipped and the stale edge is left intact rather than dropped. + # SPM — reconcile is skipped and the stale edge is left intact rather than dropped. Called + # directly: compute_and_apply's routing guard already drops a rule for an absent owner. + from aiac.policy.computation.engine import _reconcile + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) orphan_scope = _scope("s-orphan", "orphan", service_id="orphan") - initial = {"orphan": _spm("orphan", owned_scopes=[orphan_scope], inbound=[_rule(UR, orphan_scope)])} - store = run_engine([_rule(UR, orphan_scope)], catalog=[], store_initial=initial) + model = _spm("orphan", owned_scopes=[], inbound=[_rule(UR, orphan_scope)]) - assert _pairs(_inbound(store.data["orphan"])) == [("r-user-dev", "s-orphan")] + assert _reconcile(model, {}, set(), {UR.id}) is False + assert _pairs(_inbound(model)) == [("r-user-dev", "s-orphan")] # --------------------------------------------------------------------------- # @@ -1042,42 +1056,504 @@ def test_derive_agent_deny_target_scope_and_outbound_subject_deny_gate(): # --------------------------------------------------------------------------- # -# default_effect threading — PCE stamps default_effect onto every derived APM. # -# The value is produced at derive time (the APM is a pure projection), defaults # -# to least-privilege DENY, and an explicitly requested ALLOW propagates. # +# always DENY — the PCE carries no default effect. # # --------------------------------------------------------------------------- # -def test_default_effect_defaults_to_deny_on_derived_apm(): - # When no default_effect is passed, every emitted APM carries the least-privilege DENY default, - # reproducing today's `default allow := false` Rego (byte-for-byte-compatible default). - AR, UR, AS, TS, catalog = _repro() - store = run_engine([_rule(UR, AS)], catalog=catalog) +def test_compute_and_apply_takes_no_default_effect(): + import inspect - apm = store.pushed_agent("github-agent") - assert apm.default_effect == RuleEffect.DENY + from aiac.policy.computation import engine + for fn in (engine.compute_and_apply, engine._run, engine._derive, engine._fresh_apm): + assert "default_effect" not in inspect.signature(fn).parameters, fn.__name__ -def test_default_effect_allow_propagates_to_derived_apm(): - # A caller-requested ALLOW threads compute_and_apply -> _run -> _derive and lands on the APM. + +def test_derived_apm_carries_no_default_effect(): AR, UR, AS, TS, catalog = _repro() - store = run_engine([_rule(UR, AS)], catalog=catalog, default_effect=RuleEffect.ALLOW) + store = run_engine([_rule(UR, AS)], catalog=catalog) apm = store.pushed_agent("github-agent") - assert apm.default_effect == RuleEffect.ALLOW + assert "default_effect" not in apm.model_dump() + +# --------------------------------------------------------------------------- # +# PCE lock — one module-level lock serializes every read-modify-write of the # +# store (compute_and_apply, decommission, quarantine). Without it two runs # +# that route rules into one shared SPM both read the old SPM, and the second # +# write removes the first run's rules (a lost update, no error). # +# --------------------------------------------------------------------------- # +class PausingStore(FakeStore): + """A ``FakeStore`` whose first read of ``pause_on`` waits (bounded) for a second reader. + + Without a lock the second run reads the same, not-yet-written SPM while the first waits, so the + two runs interleave read → read → write → write. With the lock the second run cannot read until + the first has written; the first waits only until ``timeout`` and then continues.""" + + def __init__(self, pause_on, timeout=0.5, initial=None): + super().__init__(initial) + self.pause_on = pause_on + self.timeout = timeout + self.first_read = threading.Event() + self.second_read = threading.Event() + self._reads = 0 + self._reads_lock = threading.Lock() -def test_default_effect_stamped_on_every_emitted_apm(): - # The default_effect rides onto EVERY APM a single recompute emits, not just the first. + def get_service_policy(self, service_id): + model = super().get_service_policy(service_id) + if service_id == self.pause_on: + with self._reads_lock: + self._reads += 1 + reads = self._reads + if reads == 1: + self.first_read.set() + self.second_read.wait(self.timeout) + else: + self.second_read.set() + return model + + +def _two_agents_one_tool(): A1 = _agent_role("r-a1", "a1-src", owner="agent-1") A2 = _agent_role("r-a2", "a2-src", owner="agent-2") - U = _user_role("r-user", "dev", users=["dev-user"]) - S1 = _scope("s-a1-in", "a1-inbound", service_id="agent-1") - S2 = _scope("s-a2-in", "a2-inbound", service_id="agent-2") + TS = _scope("s-tool-read", "tool-read", service_id="github-tool") catalog = [ - _agent("agent-1", roles=[A1], scopes=[S1]), - _agent("agent-2", roles=[A2], scopes=[S2]), + _agent("agent-1", roles=[A1]), + _agent("agent-2", roles=[A2]), + _tool("github-tool", scopes=[TS]), + ] + return A1, A2, TS, catalog + + +def _run_in_threads(*calls): + errors = [] + + def wrap(fn): + def target(): + try: + fn() + except Exception as exc: # surfaced to the test thread below + errors.append(exc) + + return target + + threads = [threading.Thread(target=wrap(fn)) for fn in calls] + threads[0].start() + return threads, errors + + +def test_concurrent_runs_into_one_shared_spm_keep_both_rules(): + A1, A2, TS, catalog = _two_agents_one_tool() + store = PausingStore(pause_on="github-tool") + with engine_env(catalog, store) as compute_and_apply: + threads, errors = _run_in_threads( + lambda: compute_and_apply([_rule(A1, TS)]), + lambda: compute_and_apply([_rule(A2, TS)]), + ) + assert store.first_read.wait(2) # run 1 is between its read and its write of SPM(tool) + threads[1].start() + for t in threads: + t.join(5) + assert not errors + + assert _pairs(_inbound(store.data["github-tool"])) == [("r-a1", "s-tool-read"), ("r-a2", "s-tool-read")] + + +def _blocks_while_pce_lock_held(fn) -> None: + """Hold the PCE lock in the test thread; ``fn`` in a worker must not finish until it is released.""" + from aiac.policy.computation import engine + + done = threading.Event() + + def target(): + fn() + done.set() + + with engine._pce_lock: + worker = threading.Thread(target=target) + worker.start() + assert not done.wait(0.3), "ran while another holder had the PCE lock" + worker.join(5) + assert done.is_set() + + +def test_compute_and_apply_holds_the_pce_lock(): + AR, UR, AS, TS, catalog = _repro() + store = FakeStore() + with engine_env(catalog, store) as compute_and_apply: + _blocks_while_pce_lock_held(lambda: compute_and_apply([_rule(UR, AS)])) + + +def test_decommission_holds_the_pce_lock(): + store = FakeStore() + _onboard_repro(store) + with engine_env([_agent("github-agent")], store): + from aiac.policy.computation.engine import decommission + + _blocks_while_pce_lock_held(lambda: decommission("github-tool")) + + +# --------------------------------------------------------------------------- # +# Routing guard — under the PCE lock, after the catalog read, drop each rule # +# that touches a disabled (quarantined) service: its scope owner is disabled, # +# or its agent role belongs to a disabled service. The focus service (the one # +# this onboarding builds, passed as its clientId) is exempt: its client is # +# still disabled while a re-onboarding applies. # +# --------------------------------------------------------------------------- # +def _guard_catalog(*, agent_enabled=True, tool_enabled=True): + AR = _agent_role("r-agent-src", "agent-source", owner="github-agent") + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + AS = _scope("s-agent-inbound", "agent-inbound", service_id="github-agent") + TS = _scope("s-tool-read", "tool-read", service_id="github-tool") + catalog = [ + _agent("github-agent", roles=[AR], scopes=[AS], enabled=agent_enabled), + _tool("github-tool", scopes=[TS], enabled=tool_enabled), + ] + return AR, UR, AS, TS, catalog + + +def test_guard_drops_rule_whose_scope_owner_is_disabled(): + AR, UR, AS, TS, catalog = _guard_catalog(tool_enabled=False) + store = run_engine([_rule(AR, TS), _rule(UR, TS), _rule(UR, AS)], catalog=catalog, focus_service="github-agent") + + assert "github-tool" not in store.data + assert _pairs(_inbound(store.data["github-agent"])) == [("r-user-dev", "s-agent-inbound")] + assert store.pushed_agent("github-agent").target_allow_scopes == {} + + +def test_guard_drops_rule_whose_agent_role_belongs_to_a_disabled_service(): + AR, UR, AS, TS, catalog = _guard_catalog(agent_enabled=False) + store = run_engine([_rule(AR, TS), _rule(UR, TS)], catalog=catalog, focus_service="github-tool") + + assert _pairs(_inbound(store.data["github-tool"])) == [("r-user-dev", "s-tool-read")] + assert "github-agent" not in store.pushed_agent_ids + + +def test_guard_keeps_the_focus_service_rules_while_it_is_disabled(): + AR, UR, AS, TS, catalog = _guard_catalog(agent_enabled=False) + store = run_engine([_rule(AR, TS), _rule(UR, AS)], catalog=catalog, focus_service="github-agent") + + assert _pairs(_inbound(store.data["github-tool"])) == [("r-agent-src", "s-tool-read")] + assert _pairs(_inbound(store.data["github-agent"])) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(store.pushed_agent("github-agent").outbound_target_allow_rules) == [("r-agent-src", "s-tool-read")] + + +def test_guard_does_not_exempt_a_focus_service_given_by_its_keycloak_uuid(): + # focus_service is the clientId (the catalog key). A Keycloak UUID matches no catalog key, so + # the disabled agent is not exempt and its rules are dropped. + AR, UR, AS, TS, catalog = _guard_catalog(agent_enabled=False) + store = run_engine([_rule(AR, TS), _rule(UR, AS)], catalog=catalog, focus_service="uuid-github-agent") + + assert "github-agent" not in store.data + assert "github-tool" not in store.data + assert store.apply_policy_count == 0 + + +def test_guard_without_focus_drops_every_rule_that_touches_a_disabled_service(): + AR, UR, AS, TS, catalog = _guard_catalog(agent_enabled=False) + store = run_engine([_rule(AR, TS), _rule(UR, AS), _rule(UR, TS)], catalog=catalog) + + assert "github-agent" not in store.data + assert _pairs(_inbound(store.data["github-tool"])) == [("r-user-dev", "s-tool-read")] + assert store.apply_policy_count == 0 + + +def test_guard_keeps_every_rule_when_all_services_are_enabled(): + AR, UR, AS, TS, catalog = _guard_catalog() + store = run_engine([_rule(AR, TS), _rule(UR, AS)], catalog=catalog) + + assert _pairs(_inbound(store.data["github-tool"])) == [("r-agent-src", "s-tool-read")] + assert _pairs(_inbound(store.data["github-agent"])) == [("r-user-dev", "s-agent-inbound")] + + +# --------------------------------------------------------------------------- # +# quarantine — the failure-path teardown of a failed onboarding. Keyed by the # +# clientId (the SPM key), as decommission is; the service is still in the # +# catalog (disabled). Deletes SPM(X), removes X's roles from the other SPMs, # +# replaces an agent's CR with a no-rules CR (not a delete: today's combiner # +# allows a missing CR), and re-derives the affected agents in one call. # +# --------------------------------------------------------------------------- # +def _quarantine_fixture(): + """X = github-agent (failed, disabled). It targets the tool and other-agent; other-agent targets X.""" + AR = _agent_role("r-x-src", "x-source", owner="github-agent") + BR = _agent_role("r-b-src", "b-source", owner="other-agent") + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + AS = _scope("s-x-in", "x-inbound", service_id="github-agent") + BS = _scope("s-b-in", "b-inbound", service_id="other-agent") + TS = _scope("s-tool-read", "tool-read", service_id="github-tool") + initial = { + "github-agent": _spm( + "github-agent", owned_roles=[AR], owned_scopes=[AS], inbound=[_rule(UR, AS), _rule(BR, AS)] + ), + "github-tool": _spm( + "github-tool", + type=ServiceType.TOOL, + owned_scopes=[TS], + inbound=[_rule(AR, TS), _rule(BR, TS), _rule(UR, TS)], + ), + "other-agent": _spm("other-agent", owned_roles=[BR], owned_scopes=[BS], inbound=[_rule(AR, BS), _rule(UR, BS)]), + } + catalog = [ + _agent("github-agent", roles=[AR], scopes=[AS], enabled=False), + _agent("other-agent", roles=[BR], scopes=[BS]), + _tool("github-tool", scopes=[TS]), ] - store = run_engine([_rule(U, S1), _rule(U, S2)], catalog=catalog, default_effect=RuleEffect.ALLOW) + return catalog, initial + + +def run_quarantine(service_id, *, catalog, store) -> FakeStore: + with engine_env(catalog, store): + from aiac.policy.computation import quarantine + + quarantine(service_id) + return store + + +def test_quarantine_agent_deletes_its_spm_and_removes_its_roles_from_other_spms(): + catalog, initial = _quarantine_fixture() + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + + assert store.service_deletes == ["github-agent"] + assert "github-agent" not in store.data + assert _pairs(_inbound(store.data["github-tool"])) == [("r-b-src", "s-tool-read"), ("r-user-dev", "s-tool-read")] + assert _pairs(_inbound(store.data["other-agent"])) == [("r-user-dev", "s-b-in")] + + +def test_quarantine_agent_replaces_its_cr_with_the_no_rules_cr(): + from aiac.policy.computation.engine import _fresh_apm + + catalog, initial = _quarantine_fixture() + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + + assert store.agent_applies == [("github-agent", _fresh_apm("github-agent"))] + assert store.agent_deletes == [] # not a delete: the combiner allows a missing CR + + +def test_quarantine_rederives_the_affected_agents_in_one_call(): + catalog, initial = _quarantine_fixture() + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + + assert store.apply_policy_count == 1 + assert store.pushed_agent_ids == {"other-agent"} + other = store.pushed_agent("other-agent") + assert "github-agent" not in other.target_allow_scopes # targeted X: outbound to X dropped + assert "github-agent" not in other.source_roles # X's role lost on SPM(other-agent) + assert _pairs(other.inbound_subject_allow_rules) == [("r-user-dev", "s-b-in")] + + +def test_quarantine_tool_writes_no_cr_and_rederives_its_callers(): + AR = _agent_role("r-agent-src", "agent-source", owner="github-agent") + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + TS = _scope("s-tool-read", "tool-read", service_id="github-tool") + initial = { + "github-tool": _spm( + "github-tool", type=ServiceType.TOOL, owned_scopes=[TS], inbound=[_rule(AR, TS), _rule(UR, TS)] + ) + } + catalog = [_agent("github-agent", roles=[AR]), _tool("github-tool", scopes=[TS], enabled=False)] + + store = run_quarantine("github-tool", catalog=catalog, store=FakeStore(initial)) + + assert "github-tool" not in store.data + assert store.agent_applies == [] + assert store.agent_deletes == [] + assert store.apply_policy_count == 1 + assert store.pushed_agent("github-agent").target_allow_scopes == {} + + +def test_quarantine_twice_gives_the_same_result(): + catalog, initial = _quarantine_fixture() + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + after_first = {sid: m.model_dump() for sid, m in store.data.items()} + cr_first = store.agent_applies[-1] + + run_quarantine("github-agent", catalog=catalog, store=store) + + assert {sid: m.model_dump() for sid, m in store.data.items()} == after_first + assert store.agent_applies[-1] == cr_first + + +def test_quarantine_unknown_service_is_a_no_op(): + catalog, initial = _quarantine_fixture() + store = run_quarantine("not-there", catalog=catalog, store=FakeStore(initial)) + + assert store.service_deletes == [] + assert store.service_writes == [] + assert store.agent_applies == [] + assert store.apply_policy_count == 0 + + +def test_quarantine_given_a_keycloak_uuid_is_a_no_op(): + # The PCE takes only the clientId: a Keycloak UUID is not an SPM key, so it finds nothing. + catalog, initial = _quarantine_fixture() + store = run_quarantine("uuid-github-agent", catalog=catalog, store=FakeStore(initial)) + + assert store.service_deletes == [] + assert store.agent_applies == [] + assert store.apply_policy_count == 0 + + +def test_quarantine_removes_the_grants_of_roles_the_rollback_deleted(): + # X's first onboarding created r-x-new. A concurrent run stored its grant on SPM(other-agent) + # (r-x-new → s-b-in). Then X's build failed and the rollback deleted r-x-new, so the catalog no + # longer lists it. The orchestrator passes it as a deleted role: the quarantine must remove the + # grant and re-derive other-agent, or other-agent keeps allowing X. + XR = _agent_role("r-x-new", "x-new", owner="github-agent") + catalog, initial = _quarantine_fixture() + initial["other-agent"].inbound_allow_rules.append(_rule(XR, initial["other-agent"].owned_scopes[0])) + store = FakeStore(initial) + with engine_env(catalog, store): + from aiac.policy.computation import quarantine + + quarantine("github-agent", [XR]) + + assert _pairs(_inbound(store.data["other-agent"])) == [("r-user-dev", "s-b-in")] + assert "github-agent" not in store.pushed_agent("other-agent").source_roles + + +def test_quarantine_without_the_deleted_roles_keeps_their_grants(): + # The gap the deleted_roles argument closes: the catalog alone does not name r-x-new. + XR = _agent_role("r-x-new", "x-new", owner="github-agent") + catalog, initial = _quarantine_fixture() + initial["other-agent"].inbound_allow_rules.append(_rule(XR, initial["other-agent"].owned_scopes[0])) + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + + assert ("r-x-new", "s-b-in") in _pairs(_inbound(store.data["other-agent"])) + + +def test_quarantine_holds_the_pce_lock(): + catalog, initial = _quarantine_fixture() + store = FakeStore(initial) + with engine_env(catalog, store): + from aiac.policy.computation import quarantine + + _blocks_while_pce_lock_held(lambda: quarantine("github-agent")) + + +# --------------------------------------------------------------------------- # +# Absent services — a service missing from the catalog (its client was deleted, # +# e.g. offboarded while its onboarding was still building) is treated like a # +# disabled one: its rules are dropped, and _run never derives (writes a CR for) # +# an agent that is not in the catalog. The store's 404 placeholder SPM says # +# service_type=AGENT, so without this a deleted tool got a CR. # +# --------------------------------------------------------------------------- # +def test_guard_drops_rule_whose_scope_owner_is_absent_from_the_catalog(): + AR, UR, AS, TS, catalog = _guard_catalog() + live = [svc for svc in catalog if svc.serviceId != "github-tool"] # the tool's client was deleted + store = run_engine([_rule(AR, TS), _rule(UR, TS)], catalog=live, focus_service="github-tool") + + assert "github-tool" not in store.data + assert store.service_writes == [] + assert "github-tool" not in store.pushed_agent_ids + + +def test_guard_drops_rule_whose_agent_role_owner_is_absent_from_the_catalog(): + AR, UR, AS, TS, catalog = _guard_catalog() + live = [svc for svc in catalog if svc.serviceId != "github-agent"] # the agent's client was deleted + store = run_engine([_rule(AR, TS), _rule(UR, TS)], catalog=live) + + assert _pairs(_inbound(store.data["github-tool"])) == [("r-user-dev", "s-tool-read")] + assert "github-agent" not in store.pushed_agent_ids + + +def test_run_never_derives_an_agent_that_is_absent_from_the_catalog(): + # Override-purge touches the stored SPM of a deleted agent (its persisted type is AGENT). The + # purge is persisted, but no CR is written for the deleted agent. + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + ghost_scope = _scope("s-ghost-in", "ghost-inbound", service_id="ghost-agent") + AR, _, AS, _, catalog = _repro() + initial = {"ghost-agent": _spm("ghost-agent", owned_scopes=[ghost_scope], inbound=[_rule(UR, ghost_scope)])} + + store = run_engine([_rule(UR, AS)], catalog=catalog, store_initial=initial, override=True) + + assert _inbound(store.data["ghost-agent"]) == [] # the purge still lands + assert "ghost-agent" not in store.pushed_agent_ids + assert store.pushed_agent_ids == {"github-agent"} + + +# --------------------------------------------------------------------------- # +# PR 227 review fixes — the override purge set comes from the unfiltered input; # +# quarantine and decommission derive only live agents, and keep the grants of # +# a role that another service also holds. # +# --------------------------------------------------------------------------- # +def test_override_purges_a_role_whose_every_new_rule_the_guard_drops(): + # The only new rule for r-user-dev targets the disabled tool, so the guard drops it. Under + # override the role's old grant on SPM(github-agent) must still go. + AR, UR, AS, TS, catalog = _guard_catalog(tool_enabled=False) + initial = {"github-agent": _spm("github-agent", owned_roles=[AR], owned_scopes=[AS], inbound=[_rule(UR, AS)])} + + store = run_engine([_rule(UR, TS)], catalog=catalog, store_initial=initial, override=True) + + assert _inbound(store.data["github-agent"]) == [] + assert "github-tool" not in store.data + assert store.pushed_agent("github-agent").inbound_subject_allow_rules == [] + + +def _shared_role_fixture(): + """r-shared is one realm role on the service accounts of github-agent (X) and other-agent. Its + grant on the tool is other-agent's grant too.""" + catalog, initial = _quarantine_fixture() + SX = _agent_role("r-shared", "shared", owner="github-agent") + SB = _agent_role("r-shared", "shared", owner="other-agent") + TS = initial["github-tool"].owned_scopes[0] + catalog[0].roles.append(SX) + catalog[1].roles.append(SB) + initial["github-agent"].owned_roles.append(SX) + initial["github-tool"].inbound_allow_rules.append(_rule(SB, TS)) + return catalog, initial + + +def test_quarantine_keeps_the_grants_of_a_role_another_service_also_holds(): + catalog, initial = _shared_role_fixture() + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + + assert ("r-shared", "s-tool-read") in _pairs(_inbound(store.data["github-tool"])) + assert ("r-x-src", "s-tool-read") not in _pairs(_inbound(store.data["github-tool"])) + assert ("r-shared", "s-tool-read") in _pairs(store.pushed_agent("other-agent").outbound_target_allow_rules) + + +def test_decommission_keeps_the_grants_of_a_role_another_service_also_holds(): + catalog, initial = _shared_role_fixture() + live = [svc for svc in catalog if svc.serviceId != "github-agent"] # X was offboarded + store = run_decommission("github-agent", catalog=live, store=FakeStore(initial)) + + assert ("r-shared", "s-tool-read") in _pairs(_inbound(store.data["github-tool"])) + assert ("r-x-src", "s-tool-read") not in _pairs(_inbound(store.data["github-tool"])) + + +def _ghost_target_fixture(*, ghost_in_catalog, ghost_enabled=False): + """X (github-agent) holds a grant on ghost-agent, whose stored SPM remains. ghost-agent is + either absent from the catalog (its client was deleted without an offboard) or disabled.""" + catalog, initial = _quarantine_fixture() + AR = catalog[0].roles[0] + GS = _scope("s-ghost-in", "ghost-inbound", service_id="ghost-agent") + initial["ghost-agent"] = _spm("ghost-agent", owned_scopes=[GS], inbound=[_rule(AR, GS)]) + if ghost_in_catalog: + catalog.append(_agent("ghost-agent", scopes=[GS], enabled=ghost_enabled)) + return catalog, initial + + +def test_quarantine_never_derives_an_agent_that_is_absent_from_the_catalog(): + catalog, initial = _ghost_target_fixture(ghost_in_catalog=False) + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + + assert _inbound(store.data["ghost-agent"]) == [] # the purge still lands + assert "ghost-agent" not in store.pushed_agent_ids + assert store.pushed_agent_ids == {"other-agent"} + + +def test_quarantine_never_derives_a_disabled_agent(): + # A disabled agent keeps its no-rules CR: a re-derive must not write over it. + catalog, initial = _ghost_target_fixture(ghost_in_catalog=True) + store = run_quarantine("github-agent", catalog=catalog, store=FakeStore(initial)) + + assert _inbound(store.data["ghost-agent"]) == [] + assert "ghost-agent" not in store.pushed_agent_ids + + +def test_decommission_never_derives_an_agent_that_is_absent_from_the_catalog(): + catalog, initial = _ghost_target_fixture(ghost_in_catalog=False) + live = [svc for svc in catalog if svc.serviceId != "github-agent"] # X was offboarded + store = run_decommission("github-agent", catalog=live, store=FakeStore(initial)) - assert store.pushed_agent_ids == {"agent-1", "agent-2"} - for agent_id in ("agent-1", "agent-2"): - assert store.pushed_agent(agent_id).default_effect == RuleEffect.ALLOW + assert _inbound(store.data["ghost-agent"]) == [] + assert "ghost-agent" not in store.pushed_agent_ids + assert store.pushed_agent_ids == {"other-agent"}