diff --git a/aiac/CLAUDE.md b/aiac/CLAUDE.md index 165c3a006..222a16ed2 100644 --- a/aiac/CLAUDE.md +++ b/aiac/CLAUDE.md @@ -78,8 +78,29 @@ The whole `test/` tree collects and runs green — no `--ignore` flags are neede store surface in Wave 3, which resolved the earlier PCE-chain collection failures.) +The `-m "not integration"` expression needs no external services. The live-LLM +PRB suite (below) is marked **both** `integration` and `llm` — `integration` +because it calls a real LLM endpoint, so `-m "not integration"` already deselects +it (the routine collected count is unchanged by it); `llm` so it can be selected +on its own, cluster-free, via `-m llm`. + Use `ls test/` / `find test -type d` to discover current test directories. +**Live-LLM PRB tests** (`-m llm`) run the **real** LLM end-to-end through the +Policy Rules Builder (`test/agent/policy_rules_builder/test_graph_live_llm.py`) +and assert the emitted `(name, effect)` rule set matches the policy text — for +allow-only policies and for policies with explicit / description-driven / +exclusivity denies. Only the role/scope **descriptions** and the **policy +source** are mocked in-process (the `_structured_call` LLM seam is left live), so +the suite needs **no Kubernetes and no Keycloak** — only an LLM endpoint. It +reuses the same `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` env as the +integration suite and **skips cleanly** when they are unset. Run it opt-in: + +```bash +set -a; . test/integration/.env; set +a # or export LLM_BASE_URL / LLM_MODEL / LLM_API_KEY +.venv/bin/pytest test/ -m llm +``` + **Integration tests** (`-m integration`) now close the **real OPA evaluation loop** — they onboard through the in-cluster Controller, then drive real HTTP requests **through AuthBridge** and assert the **deployed OPA plugin's** allow/deny (no `opa eval`, no `.rego` dump, so `opa` on PATH is no longer diff --git a/aiac/demo/use-cases/uc1-onboarding/lib/_lib.py b/aiac/demo/use-cases/uc1-onboarding/lib/_lib.py index db9831324..a4a49be4a 100644 --- a/aiac/demo/use-cases/uc1-onboarding/lib/_lib.py +++ b/aiac/demo/use-cases/uc1-onboarding/lib/_lib.py @@ -548,11 +548,11 @@ def drive(username: str) -> None: agent_client_id = admin.get_client(agent_uuid)["clientId"] secret = client_secret(admin, cfg, agent_uuid) - # target_scopes is now keyed by the FULL target service id (a SPIFFE id), with bare + # target_allow_scopes is keyed by the FULL target service id (a SPIFFE id), with bare # de-prefixed scope values. next(iter(...)) still yields the id to exchange for. - target_scopes = opa_eval([outbound_rego], "data.authbridge.client.outbound.request.target_scopes", {}) or {} + target_scopes = opa_eval([outbound_rego], "data.authbridge.client.outbound.request.target_allow_scopes", {}) or {} if not target_scopes: - abort(f"outbound rego at {outbound_rego} has no target_scopes — is the tool onboarded?") + abort(f"outbound rego at {outbound_rego} has no target_allow_scopes — is the tool onboarded?") target_uri = next(iter(target_scopes)) token_exchange(cfg, client_id=agent_client_id, client_secret_value=secret, subject_token=subject_token, audience=target_uri) diff --git a/aiac/demo/use-cases/uc1-onboarding/show-state.py b/aiac/demo/use-cases/uc1-onboarding/show-state.py index 969e98b57..e079a45ac 100644 --- a/aiac/demo/use-cases/uc1-onboarding/show-state.py +++ b/aiac/demo/use-cases/uc1-onboarding/show-state.py @@ -57,13 +57,13 @@ def grant_sets(cfg, rego_dir: Path) -> tuple[set[tuple[str, str]], set[tuple[str inbound_rego = rego_dir / cfg.inbound_rego outbound_rego = rego_dir / cfg.outbound_rego # Inbound values stay FULL agent-scope names (not de-prefixed) — the inbound gate compares - # role_scopes against agent_scopes internally, never against input.mcp.params.name. - role_scopes = opa_eval([inbound_rego], "data.authbridge.client.inbound.request.role_scopes", {}) or {} + # subject_role_allow_scopes against agent_scopes internally, never against input.mcp.params.name. + role_scopes = opa_eval([inbound_rego], "data.authbridge.client.inbound.request.subject_role_allow_scopes", {}) or {} agent_scopes = set(opa_eval([inbound_rego], "data.authbridge.client.inbound.request.agent_scopes", {}) or []) inbound = {(role, scope) for role, scopes in role_scopes.items() for scope in scopes if scope in agent_scopes} - # Outbound subject_role_scopes values are now BARE de-prefixed tool scopes. - subj_scopes = opa_eval([outbound_rego], "data.authbridge.client.outbound.request.subject_role_scopes", {}) or {} + # Outbound subject_role_allow_scopes values are BARE de-prefixed tool scopes. + subj_scopes = opa_eval([outbound_rego], "data.authbridge.client.outbound.request.subject_role_allow_scopes", {}) or {} outbound = {(role, scope) for role, scopes in subj_scopes.items() for scope in scopes} return inbound, outbound diff --git a/aiac/docs/examples/opa-team1-policy.yaml b/aiac/docs/examples/opa-team1-policy.yaml index fdc14afa6..8ee544060 100644 --- a/aiac/docs/examples/opa-team1-policy.yaml +++ b/aiac/docs/examples/opa-team1-policy.yaml @@ -1,3 +1,198 @@ +# 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. +# +# 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. +# +# 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 (aiac/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. +# +# 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 `/`). +# +# 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 +# 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). +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 := {} + + subject_role_allow_scopes := { + "developer": ["github-agent.issue_operations", "github-agent.source_operations"], + "tester": ["github-agent.issue_operations"], + } + subject_role_deny_scopes := {} + source_role_allow_scopes := {} + source_role_deny_scopes := {} + + subject_allow_ok if { + some role in subject_roles[input.identity.subject] + some scope in subject_role_allow_scopes[role] + scope in agent_scopes + } + subject_deny_ok if { + some role in subject_roles[input.identity.subject] + 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 { + some role in source_roles[input.identity.client_id] + some scope in source_role_allow_scopes[role] + scope in agent_scopes + } + source_deny_ok if { + some role in source_roles[input.identity.client_id] + 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 + import rego.v1 + + agent_roles := ["github-agent.issue_operations", "github-agent.source_operations"] + subject_roles := { + "dev-user": ["developer"], + "test-user": ["tester"] + } + # The deployed github-tool (aiac/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"], + } + subject_role_deny_scopes := {} + # informational/debugging only — not referenced by allow + agent_role_scopes := { + "github-agent.issue_operations": ["issues-read", "issues-write"], + "github-agent.source_operations": ["source-write", "source-read"], + } + 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 { + 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 diff --git a/aiac/docs/policy-model-store-state-reset-runbook.md b/aiac/docs/policy-model-store-state-reset-runbook.md new file mode 100644 index 000000000..8792087e7 --- /dev/null +++ b/aiac/docs/policy-model-store-state-reset-runbook.md @@ -0,0 +1,202 @@ +# Runbook — Policy Model Store state reset (ALLOW/DENY rollout, no back-compat) + +> **Status: HITL / operational decision.** This runbook documents the *procedure*. +> The **go / no-go decision** — when to actually nuke the persisted Policy Model +> Store state in a given environment — belongs to a human operator. Tracking +> issue: **#121** (under Feature **#116 — Policy Model: ALLOW/DENY**, Wave 2 **#131**). + +## When to run this + +Run this **once per environment** when rolling out the ALLOW/DENY model change +(#117: `RuleEffect` + split rule/target fields). The change **renames** the +`ServicePolicyModel` inbound-rule field: + +| Before (single list) | After (#117, split by effect) | +|---|---| +| `inbound_rules: list[PolicyRule]` | `inbound_allow_rules: list[PolicyRule]` **+** `inbound_deny_rules: list[PolicyRule]` | + +There is **deliberately no alias, no dual-read shim, and no record-migration +script.** The persisted store must be **cleared and re-seeded by re-onboarding**. + +## Why there is no back-compat / migration (the rationale — read this) + +The models declare `model_config = ConfigDict(extra="ignore")` +(`src/aiac/policy/model/models.py`). The Policy Model Store persists each +`ServicePolicyModel` as JSON in SQLite and rehydrates it on startup with +`ServicePolicyModel.model_validate_json(spec)` +(`src/aiac/policy/model_store/service/main.py`, `_load_cache`). + +An **old** persisted row carries `"inbound_rules": [ … grants … ]` in its JSON +`spec`. On load against the **new** model: + +1. `inbound_rules` is no longer a declared field, so `extra="ignore"` + **silently discards it** — no error, no warning. +2. `inbound_allow_rules` / `inbound_deny_rules` are absent from the old JSON, so + they **default to `[]`**. + +**Result:** the store comes up *healthy* with every prior grant silently gone — +a stale, half-migrated read. Worse, a legitimately-empty SPM is now +indistinguishable from a silently-emptied one, so you cannot even detect the +damage after the fact. A partial/aliased migration would only make this failure +mode quieter. **Clearing and re-seeding from the authoritative onboarding inputs +is the only safe path.** + +The Policy Model Store is a **rebuildable projection of onboarding inputs** +(Keycloak clients/roles/scopes + agent cards), not an irreplaceable system of +record — which is what makes a clean reset acceptable. + +## What holds the state + +| Fact | Value | +|---|---| +| Backend | SQLite, table `service_policies (service_id TEXT PRIMARY KEY, spec TEXT NOT NULL)` | +| DB path | `SERVICEPOLICY_DB_PATH`, default **`/data/policy_model.db`** (ConfigMap `aiac-policy-model-store-config`) | +| Storage | PVC from `volumeClaimTemplates` **`policy-model-store-data`** (1Gi, RWO), mounted at `/data` | +| Workload | StatefulSet **`aiac-policy-model-store`**, pod `aiac-policy-model-store-0`, namespace **`aiac-system`** | +| Service / port | `aiac-policy-model-store-service` → **7074** (`/health`, `/policy/services`) | +| Serving layer | In-memory `_cache` loaded from SQLite at startup; **all reads are served from `_cache`** | + +> **Cache caveat:** because reads are served from the in-memory `_cache` (loaded +> once at startup), deleting rows/files on disk **without** also clearing the +> cache (or restarting the pod) leaves stale grants being served. Each method +> below accounts for this. + +--- + +## Reset procedure + +Pick **one** method. **Method A** is preferred (surgical, no pod churn, clears +durable rows *and* cache atomically). Methods B/C are full volume/file wipes for +when you want belt-and-suspenders certainty that nothing file-level survives. + +Namespace is `aiac-system` throughout; adjust if you deploy elsewhere. + +### Method A — Programmatic truncate (preferred) + +The store exposes `DELETE /policy/services`, which runs +`DELETE FROM service_policies` **and** `_cache.clear()` in one locked write +(returns `204`; clearing an already-empty store is a no-op). This is the +cleanest reset — no restart, cache and durable rows cleared together. + +```bash +# Port-forward the store service (leave running in a second terminal) +kubectl -n aiac-system port-forward svc/aiac-policy-model-store-service 7074:7074 & + +# Truncate all SPMs (durable rows + in-memory cache) +curl -fsS -X DELETE http://127.0.0.1:7074/policy/services -o /dev/null -w '%{http_code}\n' +# expect: 204 + +# Verify empty (any known service id should now 404) +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7074/policy/services/ +# expect: 404 +``` + +Then **re-seed** (see below). + +### Method B — Full PVC wipe (StatefulSet reset) + +Use when you want a guaranteed-fresh volume (e.g. suspected file-level cruft, +leftover journals, or you are also changing storage). This deletes the durable +volume; the StatefulSet recreates an empty one on the next pod start, and +`_init_db` creates a fresh empty table. + +```bash +# 1. Scale the StatefulSet down so the PVC is released +kubectl -n aiac-system scale statefulset/aiac-policy-model-store --replicas=0 +kubectl -n aiac-system wait --for=delete pod/aiac-policy-model-store-0 --timeout=60s + +# 2. Delete the PVC (name = --) +kubectl -n aiac-system delete pvc policy-model-store-data-aiac-policy-model-store-0 + +# 3. Scale back up — a fresh empty PVC + empty DB is created +kubectl -n aiac-system scale statefulset/aiac-policy-model-store --replicas=1 +kubectl -n aiac-system rollout status statefulset/aiac-policy-model-store --timeout=120s +``` + +Then **re-seed** (see below). + +### Method C — In-pod DB file delete + restart + +Least disruptive of the file-level options (keeps the PVC). `/data` is writable +even though the root filesystem is read-only. + +```bash +# Remove the SQLite file (and any journal sidecars) inside the pod +kubectl -n aiac-system exec aiac-policy-model-store-0 -- \ + sh -c 'rm -f /data/policy_model.db /data/policy_model.db-wal /data/policy_model.db-shm' + +# Restart so the (now empty) DB is recreated and the cache reloads empty +kubectl -n aiac-system rollout restart statefulset/aiac-policy-model-store +kubectl -n aiac-system rollout status statefulset/aiac-policy-model-store --timeout=120s +``` + +Then **re-seed** (see below). + +--- + +## Re-seed by re-onboarding + +The store is repopulated by **re-onboarding every managed service** through the +stateless Controller. Each call runs the onboarding use-case → `(rules, +override)` → PCE `compute_and_apply`, which writes fresh SPMs (now with +`inbound_allow_rules` populated) and rebuilds all **derived** state — the +per-agent `AgentPolicyModel`s and the generated OPA Rego — automatically. No +separate PDP step is needed. + +Controller onboarding surface (`src/aiac/agent/controller/routes.py`): + +``` +POST /apply/service/{service_id} # onboard (or re-onboard) one service +``` + +Re-onboard **every** managed agent and tool. Reference onboarding drivers live +at `demo/use-cases/uc1-onboarding/onboard/` (`04-onboard-agent.py`, +`05-onboard-tool.py`); in a real environment, drive the same +`POST /apply/service/{service_id}` for each service id in your catalog, e.g.: + +```bash +# Port-forward the Controller (adjust svc name/port to your deployment) +# then, for every managed service id: +for sid in $(< managed-service-ids.txt); do + curl -fsS -X POST "http://127.0.0.1:/apply/service/${sid}" \ + -o /dev/null -w "${sid}: %{http_code}\n" +done +``` + +> Re-onboarding is **order-independent** by design (a `UR→TS` grant lands +> durably on the target's SPM at tool onboarding, independent of agent order), +> so you may re-onboard services in any order. + +## Verification + +```bash +# 1. Store healthy +curl -fsS http://127.0.0.1:7074/health # {"status":"ok"} + +# 2. A re-seeded SPM now carries the NEW split field (spot-check one service) +curl -s http://127.0.0.1:7074/policy/services/ \ + | python -m json.tool | grep -E 'inbound_allow_rules|inbound_deny_rules' +# -> inbound_allow_rules should be populated for a service that had grants; +# the OLD "inbound_rules" key must NOT appear. + +# 3. Derived Rego regenerated for agents (confirm via your PDP/OPA surface). +``` + +**Success criteria:** the store returns SPMs whose grants live under +`inbound_allow_rules` (not the dropped `inbound_rules`), and downstream OPA +policy reflects those grants. + +## Explicit non-goals (state these when executing) + +- **No field alias** (`inbound_rules` → `inbound_allow_rules`). +- **No dual-read / back-fill shim** on load. +- **No record-migration script.** + +These are intentional: given `ConfigDict(extra="ignore")`, any of them would +mask the silent-drop rather than fix it. The clean nuke-and-reseed above is the +supported path. + +--- + +_Part of Feature #116 (Policy Model: ALLOW/DENY), Wave 2 (#131). Depends on the +Wave 1 model change #117._ diff --git a/aiac/docs/specs/PRD.md b/aiac/docs/specs/PRD.md index 0c9db6ebe..7963edd48 100644 --- a/aiac/docs/specs/PRD.md +++ b/aiac/docs/specs/PRD.md @@ -302,8 +302,8 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi - **Clean `idp` / `pdp` / `policy` Python namespace split.** IdP-related code (Keycloak entity management) lives under `aiac.idp.*`; PDP policy code (OPA Rego writing) lives under `aiac.pdp.*`; shared policy model and computation code lives under `aiac.policy.*`. - **`aiac.policy.model` is dependency-free (only `pydantic` + `aiac.idp.configuration.models`).** `PolicyRule`, `AgentPolicyModel`, and `PolicyModel` live in a neutral namespace importable by any consumer — Policy Model Store library, PDP Policy Library, PCE — without forcing a dependency on any service namespace. - **`PolicyRule.role` and `PolicyRule.scope` are typed objects.** They hold `Role` and `Scope` instances from `aiac.idp.configuration.models`, enabling the PCE to call `Configuration.get_services_by_role` and `Configuration.get_services_by_scope` without additional type conversion. -- **`AgentPolicyModel` relationship maps are keyed by string `id`.** `source_roles`, `subject_roles`, and `target_scopes` use the entity's string `id` as the dict key, so `Service`, `Role`, `Scope`, and `Subject` need no custom hash/eq and keep pydantic's default field-based equality. This also lets the maps serialize to JSON without a custom key serializer. -- **PCE merge semantics are additive, with drift-GC and an authoritative offboard.** The default merge (`override=False`) is additive — new rules are appended to a service's SPM `inbound_rules` (dedup by `role.id + scope.id`); existing edges are preserved. Two mechanisms remove edges: (1) **reconcile drift-GC** prunes each *touched* SPM against the `get_services()` catalog on every write, dropping edges whose scope or agent-role no longer exists and collapsing churned/duplicate user-role generations (order-independent; skipped on a catalog miss so a transient outage never wipes an SPM); and (2) **`decommission(service_id)`** — the authoritative service **offboard** — deletes a decommissioned service's SPM, purges its outbound footprint from other SPMs, deletes its APM/Rego if it was an agent, and re-derives every affected agent (keyed by clientId, since an offboarded client is gone from `get_services()`). Fine-grained **single-rule** revocation is still TBD; `override=True` gives role-level replace. +- **`AgentPolicyModel` relationship maps are keyed by string `id`.** `source_roles`, `subject_roles`, and the split target maps (`target_allow_scopes` / `target_deny_scopes`) use the entity's string `id` as the dict key, so `Service`, `Role`, `Scope`, and `Subject` need no custom hash/eq and keep pydantic's default field-based equality. This also lets the maps serialize to JSON without a custom key serializer. +- **PCE merge semantics are additive, with drift-GC and an authoritative offboard.** The default merge (`override=False`) is additive — new rules are appended to a service's SPM inbound rules, routed by effect into `inbound_allow_rules` / `inbound_deny_rules` (dedup by `role.id + scope.id + effect`); existing edges are preserved. Two mechanisms remove edges: (1) **reconcile drift-GC** prunes each *touched* SPM against the `get_services()` catalog on every write, dropping edges whose scope or agent-role no longer exists and collapsing churned/duplicate user-role generations (order-independent; skipped on a catalog miss so a transient outage never wipes an SPM); and (2) **`decommission(service_id)`** — the authoritative service **offboard** — deletes a decommissioned service's SPM, purges its outbound footprint from other SPMs, deletes its APM/Rego if it was an agent, and re-derives every affected agent (keyed by clientId, since an offboarded client is gone from `get_services()`). Fine-grained **single-rule** revocation is still TBD; `override=True` gives role-level replace. - **PDP services bind to `0.0.0.0`.** Exposed as Kubernetes ClusterIP Services so that the Agent Pod can reach them over the cluster network. - **RBAC via OPA Rego rules.** AIAC manages role → service permission mappings by writing `AgentPolicyModel` instances to the `AuthorizationPolicy` CR. Each agent pod's OPA plugin fetches its packages from the CR at startup. - **RAG Pod is a StatefulSet with persistent ChromaDB storage.** ChromaDB data is stored on a 1 Gi `ReadWriteOnce` PersistentVolumeClaim mounted at `/chroma/chroma` (ChromaDB default). On pod recreation, the StatefulSet rebinds the same PVC and ChromaDB resumes from persisted state without re-ingestion. The pod runs a single replica. diff --git a/aiac/docs/specs/components/aiac-agent/policy-rules-builder.md b/aiac/docs/specs/components/aiac-agent/policy-rules-builder.md index 32e86c371..2ca2d4c8d 100644 --- a/aiac/docs/specs/components/aiac-agent/policy-rules-builder.md +++ b/aiac/docs/specs/components/aiac-agent/policy-rules-builder.md @@ -6,7 +6,8 @@ The **Policy Rules Builder** (PRB) is a shared module at `agent/policy_rules_bui exposes two module-level functions that producing sub-agents call directly. Each function internally runs a LangGraph `StateGraph`; callers are decoupled from LangGraph mechanics. The PRB fetches its own policy context (see **Policy source** below), reasons over it with an LLM, -and emits `list[PolicyRule]` scoped to the input. It does **not** call +and emits `list[PolicyRule]` scoped to the input — both grants (`ALLOW`) and explicit prohibitions +(`DENY`). It does **not** call `aiac.pdp.policy.library` or `aiac.policy.model_store.library` directly; only the PCE does. --- @@ -49,15 +50,15 @@ without touching the rest of the graph. | Aspect | Decision | |---|---| -| Structure | LangGraph `StateGraph` — nodes `fetch → propose → precheck → audit → build`; `audit → propose` retry edge; two typed graphs (role / scope) sharing node helpers | +| Structure | LangGraph `StateGraph` — nodes `fetch → propose → precheck → audit → build`; `audit → propose` retry edge plus an `audit → RAISE` contradiction exit; two typed graphs (role / scope) sharing node helpers | | Context retrieval | Two-phase via a `PolicySource` seam — Phase 1 whole-file read; Phase 2 ChromaDB RAG (both collections). See **Policy source** | | Realm parameter | None — inputs are pre-resolved typed objects; the policy source is not realm-scoped | | Trigger type in state | None — the function name encodes the direction; no routing field in state | -| Output shape | Proposer emits **names** (via `with_structured_output`); the PRB rebuilds `PolicyRule`s from the **typed inputs** filtered by name — never from LLM-produced fields | +| Output shape | Proposer emits **names** — granted **and** denied — plus an **exclusivity flag** (via `with_structured_output`); the PRB rebuilds `PolicyRule`s from the **typed inputs** filtered by name, never from LLM-produced fields. Result is a single mixed `list[PolicyRule]` (`effect` `ALLOW`/`DENY`), **allows-then-denies in candidate order**. DENYs = explicit `denied_names` **∪** the **derived** exclusivity complement (`candidate_set − granted` when the flag is set) | | Dedup | PRB generates a full rule set; the PCE's additive merge handles dedup on write | -| LLM call pattern | **Propose → LLM auditor** (2 structured calls). Auditor rejection feeds its reason back into propose (bounded fix-and-retry, `MAX_AUDIT_RETRIES = 3`); raises on exhaustion | -| Empty result | An auditor-**approved** empty selection is a valid `[]` (deny-by-default). Empty proposals are still audited | -| Error contract | Raises on policy-source failure, LLM failure, or audit-budget exhaustion — no silent empty-list returns | +| LLM call pattern | **Propose → LLM auditor** (2 structured calls). The auditor is **three-way**: approve → build; reject → feed its reason back into propose (bounded fix-and-retry, `MAX_AUDIT_RETRIES = 3`); **genuine grant/deny contradiction → raise**. Raises on retry exhaustion | +| Empty result | An auditor-**approved** empty selection is a valid `[]` (deny-by-default). An **all-deny** result (`granted=[]`, `denied≠[]`) is a **first-class valid output** — a durable prohibition is meaningful with no current grant. Empty proposals are still audited | +| Error contract | Raises on policy-source failure, LLM failure, audit-budget exhaustion, or a genuine grant/deny **contradiction** (`PolicyContradictionError`, **fail-closed** — the focal entity's whole rule set is withheld) — no silent empty-list returns | --- @@ -66,48 +67,106 @@ without touching the rest of the graph. Both entry points compile the same node shape (two typed graphs sharing pure node helpers): ``` -fetch ─► propose ─► precheck ─► audit ─┬─ approved ─► build ─► END +fetch ─► propose ─► precheck ─► audit ─┬─ approved ────────────► build ─► END ▲ │ - └───────── retry ────────────┘ (audit feeds its reason back to propose) + └───────── retry ────────────┤ (audit feeds its reason back to propose) │ - rejected & budget exhausted ─► RAISE (inside audit node) + rejected & budget exhausted ─────► RAISE (PolicyRulesBuilderError) + │ + genuine grant/deny contradiction ─► RAISE (PolicyContradictionError) ``` - **fetch** — `PolicySource.fetch()` → `policy_text` (Phase 1: whole file). - **propose** — proposer messages (policy + focal + candidates + any `audit_feedback`); - `with_structured_output(Selection)` → selected names + reasoning. -- **precheck** — deterministic: keep only names present in the candidate set (drop hallucinated - names; log drops). No LLM. -- **audit** — auditor messages; `with_structured_output(AuditVerdict)` → `{approved, reason}`. - Approved → continue; rejected → feed the reason back and retry, or raise once - `MAX_AUDIT_RETRIES` is exhausted. Empty proposals are audited too. -- **build** — reconstruct `PolicyRule`s from the typed inputs filtered by the approved names. + `with_structured_output(Selection)` → granted names, **denied names**, an **exclusivity flag**, + and reasoning. +- **precheck** — deterministic: filter **both** the granted and denied name lists to the candidate + set (drop hallucinated names; log drops — symmetric on both lists). Compute and store + `conflict_names = granted_names ∩ denied_names`. The derived exclusivity complement is disjoint + from grants by construction, so an overlap can only arise from an explicit `denied_names` entry + that also appears in `granted_names` (direct conflict or coarse-scope mismatch) — the genuine + contradiction signal. No LLM. +- **audit** — auditor messages (both name sets + `conflict_names`); + `with_structured_output(AuditVerdict)` → `{approved, reason, contradictions}`. **Three-way route:** + `contradictions` non-empty → `raise PolicyContradictionError(focal, contradictions)`; else + approved → build; else feed the reason back and retry, or raise `PolicyRulesBuilderError` once + `MAX_AUDIT_RETRIES` is exhausted. When `conflict_names` is present the auditor adjudicates each + name: a **genuine** both-grant-and-prohibit lands in `contradictions`; a proposer **generation + error** is an ordinary rejection (reason fed back, re-propose on the shared budget). Empty + proposals are audited too. +- **build** — reconstruct `PolicyRule`s from the typed inputs: `ALLOW` from the granted names, `DENY` + from `denied_names ∪ (candidate_set − granted_names if exclusive else ∅)`. Return the single mixed + list, allows-then-denies in candidate order. ### Structured-output schemas ```python class RoleSelection(BaseModel): # build_role_rules (role focal, scope candidates) granted_scope_names: list[str] + denied_scope_names: list[str] # explicit prohibitions + grant_is_exclusive: bool # focal role's access is closed to exactly the granted set reasoning: str class ScopeSelection(BaseModel): # build_scope_rules (scope focal, role candidates) roles_with_access_names: list[str] + roles_denied_access_names: list[str] # explicit prohibitions + access_is_exclusive: bool # access to the focal scope is closed to exactly the granted set reasoning: str +class Contradiction(BaseModel): + candidate_name: str + description: str # which policy statements collide; names the kind + class AuditVerdict(BaseModel): approved: bool - reason: str | None + reason: str | None = None + contradictions: list[Contradiction] = [] ``` -The PRB rebuilds rules from the typed inputs, e.g. -`[PolicyRule(role=role, scope=s) for s in scopes if s.name in granted_scope_names]`. +The PRB rebuilds rules from the typed inputs, never from LLM string fields — `ALLOW` from the +granted names, `DENY` from `denied_names ∪ (candidate_set − granted_names if exclusive else ∅)`: + +```python +allows = [PolicyRule(role=role, scope=s) for s in scopes if s.name in granted_scope_names] +denies = [PolicyRule(role=role, scope=s, effect=RuleEffect.DENY) + for s in scopes if s.name in denied_scope_names + or (grant_is_exclusive and s.name not in granted_scope_names)] +return allows + denies # allows-then-denies, each in candidate order +``` + +> **Deny extraction (ALLOW/DENY model).** With two-sided rules in the policy model (`PolicyRule.effect`, +> `RuleEffect.ALLOW` / `DENY` — see [`../policy-model.md`](../policy-model.md)), the PRB emits **both +> grants and prohibitions**. A **DENY** is emitted **only** for an **explicit prohibition** — never for +> mere silence or absence of a grant (those stay deny-by-default non-grants: *no rule at all*). Two +> triggers: +> - **Direct prohibition** about a specific pair — "must not", "cannot", "may not", "is forbidden", +> "never", "except", "but not", "read-only" → `DENY(focal, that candidate)`. +> - **Exclusivity / restrictive "only"** — closes a set and denies the **complement within the candidate +> set**: for a focal role, *"developers can **only** access source"* → `ALLOW(dev, source)` + +> `DENY(dev, X)` for every other candidate scope X; symmetric for a focal scope (*"**only** developers +> may access source"* → `DENY(role, source)` for every other candidate role). +> +> A single statement may thus yield **both** an ALLOW and one or more DENYs; a **non-exclusive** grant +> imposes nothing on the complement (ALLOW only). Deny/exclusivity extraction is bound by **layer, not by +> source**: it draws on the **scenario layer** — both the scenario `policy.md` prose **and** the +> focal/candidate entity **descriptions** — exactly **symmetric** with the grant side, which already +> reads descriptions (capability projection, Rule 3). A prohibition stated in a role/scope description +> (e.g. *"works … not in source"*, *"does not manage the issue tracker"*) is a valid DENY trigger just +> as a positive description is a valid grant signal. The generic **baseline** (`generic_policy.md`) +> contributes **grants only** and is never a source of denials. The exclusivity complement is +> **derived** from the typed candidate set (never LLM-enumerated: +> an incomplete enumeration would silently re-open the very paths DENY exists to close) and is bounded +> strictly to the current call's candidates — the PRB can only deny what it was handed. A DENY's whole +> purpose is to be a **durable prohibition** that survives a later, broader grant under deny-overrides. ### State fields ```python class _PRBWorking(TypedDict): policy_text: str - selected_names: list[str] + selected_names: list[str] # granted names (candidate-filtered) + denied_names: list[str] # explicit prohibitions (candidate-filtered) + conflict_names: list[str] # granted ∩ denied — the contradiction signal reasoning: str approved: bool audit_feedback: str | None @@ -122,25 +181,54 @@ class ScopeRulesState(_PRBWorking): # roles: list[Role]; scope: Scope ### Prompts -Lean — task framing, the structured-output contract, and two **safety** meta-rules +Lean — task framing, the structured-output contract, two **safety** meta-rules (**deny-by-default / policy-silence** — grant a pair only if the policy supports it — and -**scope-strictly-to-focal**). On top of those, two shared **mapping** rules (`_MAPPING_RULES`) -govern how evidence becomes a grant: - -- **Capability projection** — a scope names a *set* of operations; any one covered operation - established for a candidate (by the policy or by the focal/candidate descriptions) grants the - whole scope, so partial (e.g. read-only) access still earns it. -- **Relationship scoping** — a policy may state several access relationships over the same - entities; each grant is judged only by evidence about *that* candidate and the focal entity, and - a statement about an entity that is neither the focal nor a candidate (even a same-theme one) is a - different relationship that never counts either way. +**scope-strictly-to-focal**), and the **deny/exclusivity** rules below. The proposer's task framing +is *"you map access policy to concrete grants **and prohibitions**."* + +**Policy-layer labeling.** `_policy_block()` labels the layers so the deny/exclusivity rules bind to +the scenario layer only: `BASELINE POLICY (grants only — never a source of denials):` … then +`SCENARIO POLICY:` …. Correspondingly, `generic_policy.md` is reworded to drop its exclusive tail +(*"…within the domain it is responsible for~~, and nothing outside that domain~~"*) — it still +confines grants to the domain (out-of-domain pairs stay silent non-grants) but contains no +exclusive-language trigger. + +**Deny / exclusivity rules** (shared by proposer AND auditor — see share note below): + +- **Direct-prohibition** and **exclusivity ("only")** triggers as in the deny-extraction callout + above; deny extraction is bound to the **scenario layer** (scenario `policy.md` **and** focal/candidate + descriptions — symmetric with grants; the **baseline** contributes grants only), never source-restricted + to the policy prose; silence and a non-exclusive grant impose nothing on the complement. +- The two name lists (granted / denied) are **mutually exclusive except** when the policy genuinely + establishes both a grant and a prohibition for the same candidate (direct conflict or coarse-scope) + — that overlap is the **contradiction signal**, not a normal proposal. + +On top of those, two shared **mapping** rules (`_MAPPING_RULES`) govern how evidence becomes a grant +or a deny: + +- **Capability projection (Rule 3, now symmetric)** — a scope names a *set* of operations. **Grant + side:** any one covered operation established for a candidate grants the whole scope, so partial + (e.g. read-only) access still earns it. **Deny side (new):** any one covered operation explicitly + *prohibited* for a candidate denies the whole scope. A coarse scope that is **both** partly + permitted and partly prohibited for the same pair legitimately lands in **both** lists → surfaced + as a **contradiction** (a scope-granularity mismatch, not silently resolved). +- **Relationship scoping (Rule 4, amended)** — a policy may state several access relationships over + the same entities; each grant is judged only by evidence about *that* candidate and the focal + entity, and a statement about an entity that is neither the focal nor a candidate (even a + same-theme one) is a different relationship that never counts either way. **One sanctioned + exception:** exclusive/restrictive scoping **about the focal entity** *is* legitimate evidence to + deny the complement (that cross-candidate inference is exactly what "only developers" needs). + Rule 4's protection is otherwise intact for ordinary, non-exclusive multi-relationship statements. No worked examples or domain heuristics; all substantive reasoning is deferred to the (user-authored) policy content and the entity descriptions. The **proposer and auditor share the -same rule set** — both make the same grant decision, so a rule on only one side lets the two -diverge (they did: see issue 3.20 *Follow-up: cross-variant convergence*). The auditor adds only -its framing: approve only if every granted pair is policy-supported and nothing unsupported -slipped in. +same rule set** — both make the same grant/deny decision, so a rule on only one side lets the two +diverge (they did: see issue 3.20 *Follow-up: cross-variant convergence*). The auditor adds only its +framing: approve only if every granted pair is policy-supported, every denied pair is a genuine +explicit-prohibition/exclusivity deny, and the exclusivity flag is truly asserted by the scenario +policy — and, when `conflict_names` is present, adjudicate each as a genuine contradiction (→ +`contradictions`) vs a proposer generation error (→ ordinary rejection). `build_proposer_messages` / +`build_auditor_messages` carry both name sets (the auditor also gets `conflict_names`). ### LLM + retries @@ -155,6 +243,75 @@ retry layers, kept distinct: --- +## Contradiction contract + +The policy model *assumes* no `(role, scope)` is ever both `ALLOW` and `DENY` for the same subject. +The PRB is the producer that must **guarantee** this — it must never pass a contradiction +downstream. Detection and reporting live here; the **treatment** of a reported contradiction (surface +to a human, partial-apply, re-author the policy, split the scope) is a **separate, deferred** task. + +- **Detection is deterministic** (in `precheck`): `conflict_names = granted_names ∩ denied_names`, + after candidate-set filtering. Precheck resolves nothing; it only stores the overlap. Because the + derived exclusivity complement is disjoint from grants by construction, overlap can arise **only** + from an explicit `denied_names` entry that also appears in `granted_names` — a direct policy + conflict or a coarse-scope mismatch, exactly the genuine signal we want. +- **Adjudication is by the auditor** (three-way). For each name in `conflict_names` the auditor + decides whether the policy **genuinely** both grants and prohibits it, or whether it's a proposer + **generation error**: + - **Genuine** → the audit node raises `PolicyContradictionError(focal, contradictions)`. + - **Generation error** → treated as an ordinary rejection: feed the reason back, re-propose, + reusing the shared `MAX_AUDIT_RETRIES` budget. +- **Report shape.** `PolicyContradictionError` carries `focal: str` and + `contradictions: list[Contradiction]`, reporting **all** genuine contradictions in a **single** + raise. **Any** genuine contradiction short-circuits past retry (retrying can't fix a real conflict; + the call fails closed regardless). Generation errors are **never** reported (LLM noise, not a policy + finding). The entry-point signature stays `-> list[PolicyRule]`; **the raise is the report**. +- **Fail-closed.** The focal entity's whole rule set is withheld (whether to salvage the + non-conflicting rules is a treatment decision — deferred). +- **Bounded to the overlap signal.** The PRB is **not** hunting for every latent contradiction in the + policy independently — only the grant/deny overlap it produced. +- **`Contradiction.description`** names the *kind* — direct policy conflict vs coarse-scope + granularity mismatch — so the deferred treatment task knows whether to re-author policy or split the + scope. + +--- + +## Testing + +Two layers, distinguished by whether the LLM is real: + +- **Mocked-boundary unit tests** (default `pytest`, no marker) — patch `graph._structured_call` + (the sole LLM seam) and stub `graph.get_policy_source`, so no endpoint is touched. These pin the + deterministic mechanics: candidate-set precheck/drop, `conflict_names` computation, the three-way + audit route, the derived exclusivity complement, and allows-then-denies rebuild order. They are the + fast, hermetic regression net and must stay green with no environment. + +- **Live-LLM verification tests** (new **`llm`** marker) — run the **real** LLM defined in the + environment (`LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY`) end-to-end through `build_role_rules` / + `build_scope_rules`, and assert the emitted rule set matches the policy text. These verify the + **prompt engineering itself** (that grants, direct-prohibition denies, description-driven denies, + and the exclusivity complement are extracted correctly), which the mocked tests — feeding canned + proposer output — cannot. + - **Only the LLM is real.** Descriptions and policy are **mocked in-process**: inline `Role`/`Scope` + objects carry the descriptions, and the `PolicySource` seam is stubbed to return an inline policy + string. No Kubernetes, no Keycloak, no cluster — the `llm` marker gates on the three `LLM_*` vars + only and **skips cleanly** when they are unset (same pattern as `require_env_or_skip`), so it never + false-passes and never requires the integration stack. + - **Assertion:** exact set equality of the emitted `(candidate_name, effect)` pairs against the + hand-verified expected set for each fixture (not a subset check — an over- or under-grant fails). + - **Fixture matrix** (minimal but representative): allow-only in **both** directions + (`build_role_rules`, `build_scope_rules`); a **direct-prohibition** deny ("must not" / "read-only"); + a **description-driven** deny (a prohibition stated only in an entity description, e.g. "does not + manage the issue tracker"); and an **exclusivity** case ("only …") asserting the derived complement. + The **contradiction** path (`PolicyContradictionError`) is **excluded** — a real LLM's adjudication + of a genuine grant/deny collision is non-deterministic and belongs to focused mocked tests. + +The `llm` marker is registered in `pyproject.toml` alongside `integration`; unlike `integration` (which +needs the full onboarding stack), `llm` needs only an LLM endpoint. Both are deselected by the default +`-m "not integration"` unit run — the `llm` suite is opt-in via `-m llm` with the `LLM_*` env sourced. + +--- + ## Use-case dispatch | Use Case | Caller | Function(s) called | diff --git a/aiac/docs/specs/components/aiac-agent/uc2-policy-update.md b/aiac/docs/specs/components/aiac-agent/uc2-policy-update.md index 7dd923fef..24332612a 100644 --- a/aiac/docs/specs/components/aiac-agent/uc2-policy-update.md +++ b/aiac/docs/specs/components/aiac-agent/uc2-policy-update.md @@ -51,7 +51,8 @@ flowchart TD - Build calls the PRB directly, merges the results, and returns `(list[PolicyRule], override)` to the Controller. - **Composite role flattening:** before calling the PRB, Build flattens every role it reads to its **closure** via the shared `flatten_role` helper — the role plus all descendant roles from `role.childRoles`, de-duplicated by `role.id` (a non-composite role yields just itself). The PRB receives already-flattened roles; the PCE performs no flattening. (Same helper and semantics as UC1 and UC3.) - Rebuild delegates to Build for rule generation and returns Build's rules to the Controller. -- **Append vs override:** the sub-agent conveys an `override` flag to the Controller alongside its rules. **Rebuild is the full-rebuild case (`override=True`)** — the PCE purges every input role's mappings before applying (see [`../policy-computation-engine.md`](../policy-computation-engine.md)). **Build's** `override` value is **TBD** (whether an incremental post-ingest build appends or replaces). +- **Append vs override:** the sub-agent conveys an `override` flag to the Controller alongside its rules. **Rebuild is the full-rebuild case (`override=True`)** — the PCE purges every input role's mappings before applying (see [`../policy-computation-engine.md`](../policy-computation-engine.md)). The override purge is keyed on `role.id` alone, so it clears each input role's **allow *and* deny** edges before re-appending. **Build's** `override` value is **TBD** (whether an incremental post-ingest build appends or replaces). +- **ALLOW/DENY rules:** both Build and Rebuild source their rules from the PRB, which emits **both grants (`ALLOW`) and explicit prohibitions (`DENY`)** — direct prohibitions, description-driven denies, and the derived exclusivity complement (see [`policy-rules-builder.md`](policy-rules-builder.md)). A Rebuild therefore re-asserts both effects: `override=True` purges each input role's allow *and* deny edges (keyed on `role.id`), and the fresh PRB output re-appends the currently-extracted `ALLOW` and `DENY` rules for that role. (A role's deny set is thus replaced with whatever the current policy text yields, rather than preserving previously stored denies.) - The Controller calls `compute_and_apply(merged_rules, override)` via the PCE — the same pattern as all other UCs. - Internal behavior (how Build/Rebuild sub-agents derive their tuple content, what IdP data they read, whether any LLM node is involved) is **deferred** — to be resolved in a dedicated grill session. diff --git a/aiac/docs/specs/components/aiac-agent/uc3-role-update.md b/aiac/docs/specs/components/aiac-agent/uc3-role-update.md index 4f8fca800..71d147771 100644 --- a/aiac/docs/specs/components/aiac-agent/uc3-role-update.md +++ b/aiac/docs/specs/components/aiac-agent/uc3-role-update.md @@ -67,7 +67,7 @@ performs no further flattening. 1. Receives `(list[PolicyRule], override=True)` from the Role sub-agent (PRB already called and merged internally). 2. Calls `compute_and_apply(rules, override=True)` from `aiac.policy.computation`. - - With `override=True`, the PCE purges every input role's existing mappings (both directions, plus `target_scopes` reconciliation) before applying the fresh rules — an authoritative role-keyed replace. Because the sub-agent submits `build_role_rules(r, all_scopes)` output for the full closure, this replaces the complete mapping of the triggering role and every descendant. See [`../policy-computation-engine.md`](../policy-computation-engine.md). + - With `override=True`, the PCE purges every input role's existing mappings — across **both** the allow and deny lists (`inbound_allow_rules` + `inbound_deny_rules`) of every SPM containing the role, keyed on `role.id` alone — before applying the fresh rules, an authoritative role-keyed replace. (The target maps `target_allow_scopes` / `target_deny_scopes` are derived, never stored, so nothing to reconcile there.) Because the sub-agent submits `build_role_rules(r, all_scopes)` output for the full closure, this replaces the complete mapping of the triggering role and every descendant. See [`../policy-computation-engine.md`](../policy-computation-engine.md). 3. Returns bare HTTP status; writes summary + debug to log. ## File structure diff --git a/aiac/docs/specs/components/library-idp.md b/aiac/docs/specs/components/library-idp.md index e953251a9..95e62b480 100644 --- a/aiac/docs/specs/components/library-idp.md +++ b/aiac/docs/specs/components/library-idp.md @@ -38,7 +38,7 @@ All models use `model_config = ConfigDict(extra='ignore')` to silently discard u Model definition order: `Subject` → `Role` → `Service` → `Scope`. Because `Subject`, `Role`, and `Service` reference `Scope` (and `Subject` references `Role`) as forward references, the module calls `Subject.model_rebuild()`, `Role.model_rebuild()`, and `Service.model_rebuild()` after `Scope` is defined. -`Service`, `Role`, `Scope`, and `Subject` use pydantic's default equality (field-based) and are **not hashable** — they define no custom `__hash__`/`__eq__` and are never used as dict keys or set members. The relationship maps in `AgentPolicyModel` (`source_roles`, `subject_roles`, `target_scopes`) are keyed by the entity's string `id` instead, so no identity override is needed. +`Service`, `Role`, `Scope`, and `Subject` use pydantic's default equality (field-based) and are **not hashable** — they define no custom `__hash__`/`__eq__` and are never used as dict keys or set members. The relationship maps in `AgentPolicyModel` (`source_roles`, `subject_roles`, and the split target maps `target_allow_scopes` / `target_deny_scopes`) are keyed by the entity's string `id` instead, so no identity override is needed. #### `Subject` diff --git a/aiac/docs/specs/components/library-pdp-policy.md b/aiac/docs/specs/components/library-pdp-policy.md index 1f4f8d336..31ce3e217 100644 --- a/aiac/docs/specs/components/library-pdp-policy.md +++ b/aiac/docs/specs/components/library-pdp-policy.md @@ -102,7 +102,7 @@ Key behaviors to assert: - **Keycloak interaction:** this library never calls Keycloak directly. All IdP operations go through `aiac.idp.configuration`. - **Policy computation:** translating `list[PolicyRule]` into `AgentPolicyModel` objects is the responsibility of `aiac.policy.computation`, not this library. -- **Policy persistence:** the Policy Model Store (`aiac.policy.model_store`) owns structured `AgentPolicyModel` durability. This library targets the OPA runtime only. +- **Policy persistence:** the Policy Model Store (`aiac.policy.model_store`) owns structured `ServicePolicyModel` durability (the `AgentPolicyModel` is a derived projection, never persisted). This library targets the OPA runtime only. --- diff --git a/aiac/docs/specs/components/library-policy-model-store.md b/aiac/docs/specs/components/library-policy-model-store.md index e2c2d7991..172127f1f 100644 --- a/aiac/docs/specs/components/library-policy-model-store.md +++ b/aiac/docs/specs/components/library-policy-model-store.md @@ -3,15 +3,15 @@ Companion library for the [AIAC Policy Model Store](policy-model-store.md). Follows the same pattern as `aiac.pdp.policy.library` — module-level functions, URL from env var via `python-dotenv`, `RuntimeError` on non-2xx. ## Location -`aiac/src/aiac/policy/store/library/` +`aiac/src/aiac/policy/model_store/library/` ## Package structure ``` -aiac/src/aiac/policy/store/ +aiac/src/aiac/policy/model_store/ └── library/ ├── __init__.py # empty - └── api.py # five module-level functions (SPM-centric surface) + └── api.py # six module-level functions (SPM-centric surface) ``` All `__init__.py` files are empty. Callers use explicit submodule paths: @@ -23,6 +23,7 @@ from aiac.policy.model_store.library.api import ( get_service_policies_by_role, apply_service_policy, delete_service_policy, + clear_service_policies, ) from aiac.policy.model.models import ServicePolicyModel, Scope, Role ``` @@ -40,7 +41,7 @@ exposes any per-agent read/write functions. The library surface is entirely SPM- ## Submodule: `aiac.policy.model_store.library.api` ### Description -HTTP client module wrapping the [AIAC Policy Model Store](policy-model-store.md) REST API. Exposes five module-level functions returning `ServicePolicyModel` objects directly — no Kubernetes client boilerplate. Service URL is read from the `AIAC_POLICY_MODEL_STORE_URL` environment variable (default: `http://127.0.0.1:7074`). All functions raise `RuntimeError` on an unexpected non-2xx response (a `404` on the by-id read is handled, not raised — see below). +HTTP client module wrapping the [AIAC Policy Model Store](policy-model-store.md) REST API. Exposes six module-level functions returning `ServicePolicyModel` objects directly — no Kubernetes client boilerplate. Service URL is read from the `AIAC_POLICY_MODEL_STORE_URL` environment variable (default: `http://127.0.0.1:7074`). All functions raise `RuntimeError` on an unexpected non-2xx response (a `404` on the by-id read is handled, not raised — see below). ### Dependencies ``` @@ -71,8 +72,9 @@ def get_service_policy_by_scope(scope: Scope) -> ServicePolicyModel | None def get_service_policies_by_role(role: Role) -> list[ServicePolicyModel] # GET /policy/services?role={role.id} (the one genuinely new route) # Plural: a role (especially a user role) appears across many SPMs. - # Returns every SPM whose inbound_rules contains a rule referencing - # role.id. Empty list when none match. + # Returns every SPM whose inbound_allow_rules or inbound_deny_rules + # contains a rule referencing role.id (both effect lists are scanned). + # Empty list when none match. def apply_service_policy(service_id: str, spm: ServicePolicyModel) -> None # POST /policy/services/{service_id} — upsert. @@ -80,6 +82,10 @@ def apply_service_policy(service_id: str, spm: ServicePolicyModel) -> None def delete_service_policy(service_id: str) -> None # DELETE /policy/services/{service_id} — off-board a decommissioned service. # No-op on the server if the service is absent (still 204). + +def clear_service_policies() -> None + # DELETE /policy/services — drop every SPM (collection-root clear). + # Test-harness / rebuild clean-slate reset; always 204. ``` `service_id` is a plain string everywhere in this API (slashes and all) — callers never encode diff --git a/aiac/docs/specs/components/pdp-policy-writer-opa.md b/aiac/docs/specs/components/pdp-policy-writer-opa.md index 95bbd05b4..34f1fb4a2 100644 --- a/aiac/docs/specs/components/pdp-policy-writer-opa.md +++ b/aiac/docs/specs/components/pdp-policy-writer-opa.md @@ -5,9 +5,12 @@ ## Description A FastAPI web service that translates a **Policy Model** into OPA Rego packages and, for each agent, **server-side-applies** the two generated packages into a per-agent `AuthorizationPolicy` Kubernetes Custom Resource (`agent.rossoctl.dev/v1alpha1`, `scope: client` — one CR per agent). The `bundle-service` (operator repo) composes those per-agent CRs into per-pod OPA bundles; the OPA plugin embedded in each AuthBridge instance polls the bundle relevant to its pod and evaluates it. +A FastAPI web service that translates a **Policy Model** into OPA Rego packages and, for each agent, **server-side-applies** the two generated packages into a per-agent `AuthorizationPolicy` Kubernetes Custom Resource (`agent.rossoctl.dev/v1alpha1`, `scope: client` — one CR per agent). The `bundle-service` (operator repo) composes those per-agent CRs into per-pod OPA bundles; the OPA plugin embedded in each AuthBridge instance polls the bundle relevant to its pod and evaluates it. +The service is deployed as a container in the **Rossoctl Interface Pod** alongside the IdP Configuration Service, behind the `aiac-pdp-policy-service:7072` ClusterIP. The service is deployed as a container in the **Rossoctl Interface Pod** alongside the IdP Configuration Service, behind the `aiac-pdp-policy-service:7072` ClusterIP. +The service has no dependency on Keycloak. All Keycloak operations (entity reads) are handled by the **IdP Configuration Service** and its library (`aiac.idp.configuration`). The legacy Keycloak composite / authorization-services policy writer has been **removed** (handoff 04); this OPA CR writer is the sole policy-writer surface. The service has no dependency on Keycloak. All Keycloak operations (entity reads) are handled by the **IdP Configuration Service** and its library (`aiac.idp.configuration`). The legacy Keycloak composite / authorization-services policy writer has been **removed** (handoff 04); this OPA CR writer is the sole policy-writer surface. --- @@ -26,8 +29,9 @@ A single access rule pairing a typed role with a typed scope. Used in both inbou |-------|------| | `role` | `Role` | | `scope` | `Scope` | +| `effect` | `RuleEffect` (`Allow` default / `Deny`) | -`Role` and `Scope` are the typed models from `aiac.idp.configuration.models`. The Rego generator emits their `.name` as the string literal OPA matches against. +`Role` and `Scope` are the typed models from `aiac.idp.configuration.models`. The Rego generator emits their `.name` as the string literal OPA matches against. `effect` selects whether the rule contributes to an `*_allow_scopes` or `*_deny_scopes` map (see below). ### `AgentPolicyModel` @@ -36,24 +40,27 @@ 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}`) | -| `agent_roles` | `list[Role]` | Realm roles assigned to this agent | -| `agent_scopes` | `list[Scope]` | Scopes this agent exposes | -| `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. | -| `subject_roles` | `dict[str, list[Role]]` | Inbound + outbound: subject (end-user) **id** → roles held. Keyed by `input.identity.subject`. Inbound gate: **mandatory**. | -| `target_scopes` | `dict[str, list[Scope]]` | Outbound: target service **id** → scopes this agent may request on it. Keys stay the **full** target service id (matching `input.identity.service_id`, a full SPIFFE ID); the scope **values** are de-prefixed to the bare MCP tool names carried in `input.mcp.params.name` (Q9). | -| `inbound_rules` | `list[PolicyRule]` | Who may call this agent: `(subject_role, agent_scope)` tuples | -| `outbound_rules` | `list[PolicyRule]` | What this agent may call: `(this_agent_role, target_scope)` tuples | -| `outbound_subject_rules` | `list[PolicyRule]` | Which users may reach the agent's targets: `(user_role, tool_scope)` tuples. Defaults to `[]`. | +| `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**. | +| `subject_roles` | `dict[str, list[Role]]` | Inbound + outbound: subject (end-user) **id** → roles held. Keyed by `input.identity.subject`. Inbound gate: **mandatory**. Effect-agnostic; **includes deny-edge roles**. | +| `target_allow_scopes` | `dict[str, list[Scope]]` | Outbound: target service **id** → scopes this agent **may** request on it. Keys stay the **full** target service id (matching `input.identity.service_id`, a full SPIFFE ID); the scope **values** are de-prefixed to the bare MCP tool names carried in `input.mcp.params.name` (Q9). | +| `target_deny_scopes` | `dict[str, list[Scope]]` | Outbound: target service **id** → scopes this agent **must not** request on it. Same key/value shape as `target_allow_scopes` (full target service id keys, de-prefixed scope values). | +| `inbound_subject_allow_rules` / `inbound_subject_deny_rules` | `list[PolicyRule]` | Who may / must-not call this agent: `(subject_role, agent_scope)` tuples | +| `inbound_source_allow_rules` / `inbound_source_deny_rules` | `list[PolicyRule]` | Which calling services may / must-not call this agent: `(source_role, agent_scope)` tuples | +| `outbound_target_allow_rules` / `outbound_target_deny_rules` | `list[PolicyRule]` | What this agent may / must-not call: `(this_agent_role, target_scope)` tuples | +| `outbound_subject_allow_rules` / `outbound_subject_deny_rules` | `list[PolicyRule]` | Which users may / must-not reach the agent's targets: `(user_role, tool_scope)` tuples. Default `[]`. | -**`agent_roles` / `agent_scopes` provenance:** these carry the agent's **own** identity — the service-account realm roles it holds and the scopes it exposes. The Policy Computation Engine resolves them from the agent's IdP `Service` record (P2) and embeds them on every agent model it writes; a realm-level agent with no owning service keeps `[]`. +**`agent_roles` / `agent_scopes` provenance:** these carry the agent's **own** identity — the service-account realm roles it holds and the scopes it exposes. The Policy Computation Engine resolves them from the agent's IdP `Service` record (P2) and embeds them on every agent model it writes; a realm-level agent with no owning service keeps `[]`. Together with `subject_roles` / `source_roles` they are **effect-agnostic**: a role appearing only in a DENY rule is still listed here, so the Rego deny lookup can resolve it. -**Inbound rule semantics:** a subject holding realm role `role` is permitted to invoke this agent for the agent scope `scope`. Grouped by role, these rules become the `role_scopes` map (role → agent scopes) that the inbound package evaluates. +**Inbound rule semantics (deny-overrides):** a subject holding realm role `role` may invoke this agent for agent scope `scope` iff an allow edge grants it and no deny edge prohibits it. Grouped by role, the allow/deny lists become `subject_role_allow_scopes` / `subject_role_deny_scopes` (and `source_role_allow_scopes` / `source_role_deny_scopes`) that the inbound package evaluates. -**Outbound rule semantics:** this agent acting as realm role `role` is permitted to request the target scope `scope`. Grouped by role, these rules become the `agent_role_scopes` map (agent role → target scopes) that the outbound package evaluates. +**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:** a subject holding realm role `role` (a **user** role) is permitted to reach a **tool** exposing scope `scope`. Grouped by role, these rules become the `subject_role_scopes` map (user role → tool scopes) that the **outbound** package's subject gate evaluates as `input.mcp.params.name in subject_role_scopes[role]`; its scope **values** are **de-prefixed** to the bare MCP tool name (Q9). This is distinct from `inbound_rules` (user → *agent* scope): the outbound subject gate answers "may this user reach the tool?", not "may this user call the agent?". +**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?". -**Note on `target_scopes` direction:** the map is keyed by **target service id → allowed 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_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). +**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). ### `PolicyModel` @@ -95,6 +102,15 @@ No `?realm=` parameter — the service operates on a Kubernetes CR, not a Keyclo `GET /health` performs a bounded (`limit=1`) cluster-wide list of the CRD: a successful list — **including an empty one** — is `200`; any failure (unreachable API, RBAC-forbidden, CRD not served) is `503`. +**400 vs 502 (Q11).** `400` is reserved strictly for a malformed / namespace-less `agent_id` — the `identity_ref` `ValueError`, whose message names the bad id. `502` is strictly for Kubernetes API failures and the additive rego dump's `OSError`. The two are never conflated. +| `POST /policy` | `204 No Content` | **400** `{"error": …}` for a malformed / namespace-less `agent_id` (batch aborts, naming the bad agent; agents already applied stay written — no rollback); **502** `{"error": …}` for a Kubernetes API failure (or the additive dump's `OSError`) | +| `POST /policy/agents/{agent_id}` | `204 No Content` | **400** for a malformed `agent_id`; **502** for a Kubernetes API / dump failure | +| `DELETE /policy/agents/{agent_id}` | `204 No Content` | **400** for a malformed `agent_id`; **502** for a Kubernetes API failure. Deleting a **missing** agent is a no-op **204** (k8s 404 treated as success — idempotent) | +| `DELETE /policy` | `204 No Content` | **502** for a Kubernetes API failure | +| `GET /health` | `200 OK` `{"status": "ok"}` | `503 Service Unavailable` `{"status": "unavailable", "error": …}` if the bounded CR list fails | + +`GET /health` performs a bounded (`limit=1`) cluster-wide list of the CRD: a successful list — **including an empty one** — is `200`; any failure (unreachable API, RBAC-forbidden, CRD not served) is `503`. + **400 vs 502 (Q11).** `400` is reserved strictly for a malformed / namespace-less `agent_id` — the `identity_ref` `ValueError`, whose message names the bad id. `502` is strictly for Kubernetes API failures and the additive rego dump's `OSError`. The two are never conflated. --- @@ -112,8 +128,21 @@ For each `AgentPolicyModel`, the service generates **two Rego packages** — one Each package begins with `import rego.v1`. The names never contain a slug: the `bundle-service` combiner requires the **exact** path `data.authbridge.client.`, so a per-agent package name would break the composition. Per-agent isolation is achieved at the **CR / bundle level** — bundle-service looks a CR up by namespace + name — not in the package name. +**`identity_ref` drives the CR metadata, not a package name (Q3).** `identity_ref(agent_id) -> (namespace, name)` accepts a SPIFFE URI (`spiffe:///ns//sa/`) or a plain `/` clientId, validates both segments as DNS-1123 labels (`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`, ≤63 chars), and returns the `(namespace, name)` used for the CR's `metadata`. There is **no** fallback — a bare `github-agent` (no derivable namespace) or an invalid label raises `ValueError` (→ 400). This function replaces the former per-package slug: it feeds `metadata`, never a package name. +For each `AgentPolicyModel`, the service generates **two Rego packages** — one for the inbound pipeline and one for the outbound pipeline — and server-side-applies them as the two `policies[]` entries of the agent's `AuthorizationPolicy` CR. + +**Fixed package names — no slug (Q2).** Both packages use **fixed** names, regardless of agent: + +| Tier | Package | CR `policies[].path` | +|------|---------|----------------------| +| inbound | `authbridge.client.inbound.request` | `inbound/request.rego` | +| outbound | `authbridge.client.outbound.request` | `outbound/request.rego` | + +Each package begins with `import rego.v1`. The names never contain a slug: the `bundle-service` combiner requires the **exact** path `data.authbridge.client.`, so a per-agent package name would break the composition. Per-agent isolation is achieved at the **CR / bundle level** — bundle-service looks a CR up by namespace + name — not in the package name. + **`identity_ref` drives the CR metadata, not a package name (Q3).** `identity_ref(agent_id) -> (namespace, name)` accepts a SPIFFE URI (`spiffe:///ns//sa/`) or a plain `/` clientId, validates both segments as DNS-1123 labels (`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`, ≤63 chars), and returns the `(namespace, name)` used for the CR's `metadata`. There is **no** fallback — a bare `github-agent` (no derivable namespace) or an invalid label raises `ValueError` (→ 400). This function replaces the former per-package slug: it feeds `metadata`, never a package name. +> **Two identifiers, two layers (no contradiction).** UC-1 onboarding and the Trigger use the internal Keycloak **client UUID** (`service.id` / `Trigger.entity_id`) purely to *look up* a service in the IdP — that UUID **never reaches this writer**. What flows down the policy pipeline into `PolicyRule.scope.serviceId` / `Role.actorIds` and lands as `AgentPolicyModel.agent_id` is the **clientId** (the `/` / SPIFFE form), which `identity_ref` maps to the CR's `(namespace, name)`. The UUID→clientId resolution happens once, in the IdP Configuration Service, before the AgentPolicyModel is ever built. > **Two identifiers, two layers (no contradiction).** UC-1 onboarding and the Trigger use the internal Keycloak **client UUID** (`service.id` / `Trigger.entity_id`) purely to *look up* a service in the IdP — that UUID **never reaches this writer**. What flows down the policy pipeline into `PolicyRule.scope.serviceId` / `Role.actorIds` and lands as `AgentPolicyModel.agent_id` is the **clientId** (the `/` / SPIFFE form), which `identity_ref` maps to the CR's `(namespace, name)`. The UUID→clientId resolution happens once, in the IdP Configuration Service, before the AgentPolicyModel is ever built. ### Live plugin input shape (Q4) @@ -127,28 +156,55 @@ The Rego packages evaluate the `input` document the live AuthBridge OPA plugin p | `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 | +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**. +### Live plugin input shape (Q4) + +The Rego packages evaluate the `input` document the live AuthBridge OPA plugin populates — never IDs-plus-roles supplied per request. The fields the packages read: + +| Input field | Meaning | Tier | +|-------------|---------|------| +| `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 | + 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**. The generator embeds these symbols, derived from the `AgentPolicyModel`: +**Symmetric rename — no alias, no back-compat.** The single inbound `role_scopes` map splits into `subject_role_allow_scopes` / `subject_role_deny_scopes` / `source_role_allow_scopes` / `source_role_deny_scopes`; the outbound `subject_role_scopes` splits into `subject_role_allow_scopes` / `subject_role_deny_scopes`; `target_scopes` splits into `target_allow_scopes` / `target_deny_scopes`. Identity maps `subject_roles` / `source_roles` / `agent_roles` keep their names. + | Rego symbol | Source | Shape | De-prefixed? | |-------------|--------|-------|--------------| | `agent_scopes` | `model.agent_scopes` | `[scope.name, …]` — **inbound only** (the audience gate) | no — full scope names | -| `subject_roles` | `model.subject_roles` | subject id → `[role.name, …]` | n/a (roles) | -| `source_roles` | `model.source_roles` | source client id → `[role.name, …]` — **inbound only** | n/a (roles) | -| `role_scopes` | grouped `model.inbound_rules` | role → `[agent scope name, …]` — **inbound only** | no — full scope names | +| `subject_roles` | `model.subject_roles` | subject id → `[role.name, …]` (effect-agnostic; includes deny-edge roles) | n/a (roles) | +| `source_roles` | `model.source_roles` | source client id → `[role.name, …]` — **inbound only** (effect-agnostic; includes deny-edge roles) | n/a (roles) | +| `subject_role_allow_scopes` / `subject_role_deny_scopes` | grouped `inbound_subject_{allow,deny}_rules` (inbound) / `outbound_subject_{allow,deny}_rules` (outbound) | role → `[scope name, …]` — inbound: agent scopes; outbound: tool names | inbound no; outbound **yes** | +| `source_role_allow_scopes` / `source_role_deny_scopes` | grouped `inbound_source_{allow,deny}_rules` | role → `[agent scope name, …]` — **inbound only** | no — full scope names | | `agent_roles` | `model.agent_roles` | `[role.name, …]` — **outbound only** (informational) | n/a (roles) | -| `subject_role_scopes` | grouped `model.outbound_subject_rules` | user role → `[tool name, …]` — **outbound only** | **yes** — bare tool names | -| `agent_role_scopes` | grouped `model.outbound_rules` | agent role → `[tool name, …]` — **outbound only** (informational) | **yes** — bare tool names | -| `target_scopes` | `model.target_scopes` | full target service id → `[tool name, …]` — **outbound only** | **values yes, keys no** | +| `agent_role_scopes` | grouped `outbound_target_allow_rules` | agent role → `[tool name, …]` — **outbound only** (informational; single map, no deny variant emitted) | **yes** — bare tool names | +| `target_allow_scopes` / `target_deny_scopes` | `model.target_allow_scopes` / `model.target_deny_scopes` | full target service id → `[tool name, …]` — **outbound only** | **values yes, keys no** | + +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. -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_scopes` stay the full target service id (they match `input.identity.service_id`). Inbound `agent_scopes` / `role_scopes` keep their **full** names — the inbound gate compares scopes internally, never against `input.mcp.params.name`. +**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. + +**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. ### Inbound package: `authbridge.client.inbound.request` -Evaluated by the AuthBridge OPA plugin in the **inbound pipeline** — "who may call this agent". `allow` requires `subject_ok` **and** `source_ok`. `subject_ok` passes when the subject (`input.identity.subject`) holds a role granting at least one of the agent's own `agent_scopes`. `source_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. +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 is reproduced **verbatim** from `docs/examples/opa-team1-policy.yaml` (`inbound/request.rego`): +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.) ```rego package authbridge.client.inbound.request @@ -163,34 +219,84 @@ subject_roles := { source_roles := {} -role_scopes := { +subject_role_allow_scopes := { "developer": ["github-agent.issue_operations", "github-agent.source_operations"], "tester": ["github-agent.issue_operations"], } +subject_role_deny_scopes := {} +source_role_allow_scopes := {} +source_role_deny_scopes := {} -subject_ok if { +subject_allow_ok if { + some role in subject_roles[input.identity.subject] + some scope in subject_role_allow_scopes[role] + scope in agent_scopes +} +subject_deny_ok if { some role in subject_roles[input.identity.subject] - some scope in role_scopes[role] + some scope in subject_role_deny_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 { +source_allow_ok if { not input.identity.client_id } +source_allow_ok if { input.identity.client_id == "rossoctl" } +source_allow_ok if { some role in source_roles[input.identity.client_id] - some scope in role_scopes[role] + some scope in source_role_allow_scopes[role] + scope in agent_scopes +} +source_deny_ok if { + some role in source_roles[input.identity.client_id] + some scope in source_role_deny_scopes[role] scope in agent_scopes } default allow := false -allow if { subject_ok; source_ok } +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` +> bypass sets only the *allow* gate; `allow` still requires `not +> source_deny_ok` **and** `not subject_deny_ok`, so a bypassed source is +> **not** immune to a deny — a subject-side deny still applies, and a +> source-side deny applies too *when it can fire*. The limit is on the +> source deny gate specifically: +> - **Pure end-user traffic (no `client_id`)** is structurally +> un-revokable on the **source** side: `source_roles[input.identity.client_id]` +> is undefined, so `source_deny_ok` can never fire against it. Such +> traffic can still be denied by a **subject**-side deny (it always +> carries `input.identity.subject`). +> - A **platform bypass client** (`rossoctl` et al.) keeps +> `source_allow_ok` unconditionally, but `source_deny_ok` *does* fire +> if an explicit deny edge names a role that client holds. In normal +> operation platform clients carry no authored rules, so their source +> trust is effectively un-revokable — but it is not structurally +> un-revokable, and no separate exemption shields them from a deny that +> is actually authored against their role. +> +> Net: **DENY cannot revoke *source-side* trust for a caller that presents +> no `client_id`.** This is deliberate — dropping the bypass would deny +> the platform-fronted end-user traffic the mesh depends on (see +> `PLATFORM_SOURCE_CLIENTS`, Q5) — and is a property of the source gate, +> not a global "platform clients are always allowed" carve-out. + ### 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`: `subject_ok` (the delegated user's role admits the tool — `input.mcp.params.name in subject_role_scopes[role]`, de-prefixed values) AND `target_ok` (the target service, keyed by the full `input.identity.service_id`, admits the tool — `input.mcp.params.name in target_scopes[input.identity.service_id]`). `agent_roles` / `agent_role_scopes` are emitted for debugging but are **not** referenced by `allow` — `target_scopes[input.identity.service_id]` already *is* the per-scope capability gate. This package emits neither `agent_scopes` nor the inbound `role_scopes` 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", **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. -The block below is reproduced **verbatim** from `docs/examples/opa-team1-policy.yaml` (`outbound/request.rego`): +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.) ```rego package authbridge.client.outbound.request @@ -206,29 +312,51 @@ subject_roles := { # 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 := { +subject_role_allow_scopes := { "developer": ["issues-read", "source-write", "source-read"], "tester": ["issues-read", "issues-write"], } +subject_role_deny_scopes := {} +# informational/debugging only — not referenced by allow agent_role_scopes := { "github-agent.issue_operations": ["issues-read", "issues-write"], "github-agent.source_operations": ["source-write", "source-read"], } -target_scopes := { +target_allow_scopes := { "spiffe://localtest.me/ns/team1/sa/github-tool": ["source-read", "source-write", "issues-read", "issues-write"], } -subject_ok if { +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_scopes[role] + input.mcp.params.name in subject_role_allow_scopes[role] } -target_ok if { - input.mcp.params.name in target_scopes[input.identity.service_id] +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 allow := false -allow if { subject_ok; target_ok } +allow if { subject_allow_ok; target_allow_ok; not subject_deny_ok; not target_deny_ok } +``` + +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 } ``` -A worked example (agent `github-agent`, users `developer`/`tester`, tool `github-tool`) is maintained alongside the tests, and mirrored in `docs/examples/opa-team1-policy.yaml`. +**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. ### `AuthorizationPolicy` Custom Resource (Q6) @@ -310,7 +438,7 @@ apply_policy(full_model) | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `PLATFORM_SOURCE_CLIENTS` | No | `rossoctl` | Comma-separated platform bypass clients, sourced from the `aiac-pdp-config` ConfigMap. Drives the inbound package's `source_ok if { input.identity.client_id == "" }` bypass rules (Q5). Blanks are dropped; an unset or all-blank value falls back to `rossoctl` (dropping the bypass would deny end-user traffic, which carries the platform client). | +| `PLATFORM_SOURCE_CLIENTS` | No | `rossoctl` | Comma-separated platform bypass clients, sourced from the `aiac-pdp-config` ConfigMap. Drives the inbound package's `source_allow_ok if { input.identity.client_id == "" }` bypass rules (Q5). Blanks are dropped; an unset or all-blank value falls back to `rossoctl` (dropping the bypass would deny end-user traffic, which carries the platform client). | | `POLICY_WRITER_DUMP_REGO` | No | off | When truthy (`1`/`true`/`yes`/`on`) enables the **additive** local rego dump (see below). Never gates the CR write. | | `REGO_OUTPUT_DIR` | No | `/rego` | Destination for the additive dump — only consulted when `POLICY_WRITER_DUMP_REGO` is on. | @@ -320,6 +448,9 @@ There are **no** CR-name or CR-namespace env vars — CR coordinates are derived ## Always-on CR write + additive debug dump +The **CR server-side-apply is always active** — it is never gated by an env var. The former filesystem-stub behaviour survives **only** as an additive debug/test aid, toggled by `POLICY_WRITER_DUMP_REGO` (default off). When on, `_upsert_agent` **also** writes the same rego to `///inbound/request.rego` and `///outbound/request.rego`, mirroring the CR `policies[].path` so the on-disk output equals the CR content; `_delete_agent` / `_delete_all` clear the corresponding dumped tree. The dump is **never** a substitute for, or a switch away from, the CR write — production runs with it off (`k8s/pdp-interface-deployment.yaml` sets no `POLICY_WRITER_DUMP_REGO`). A dump `OSError` maps to 502, so a broken debug mount surfaces rather than silently dropping files. +## Always-on CR write + additive debug dump + The **CR server-side-apply is always active** — it is never gated by an env var. The former filesystem-stub behaviour survives **only** as an additive debug/test aid, toggled by `POLICY_WRITER_DUMP_REGO` (default off). When on, `_upsert_agent` **also** writes the same rego to `///inbound/request.rego` and `///outbound/request.rego`, mirroring the CR `policies[].path` so the on-disk output equals the CR content; `_delete_agent` / `_delete_all` clear the corresponding dumped tree. The dump is **never** a substitute for, or a switch away from, the CR write — production runs with it off (`k8s/pdp-interface-deployment.yaml` sets no `POLICY_WRITER_DUMP_REGO`). A dump `OSError` maps to 502, so a broken debug mount surfaces rather than silently dropping files. --- @@ -383,7 +514,9 @@ docker build -f aiac/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. The inbound generator emits one `source_ok` bypass rule per `platform_clients` entry (plus the no-`client_id` and role-based rules); the outbound generator de-prefixes its map values. +- **`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)). + +> **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`. - **`_upsert_agent(model)`:** server-side apply via `patch_namespaced_custom_object` (`_content_type="application/apply-patch+yaml"`, `field_manager=_FIELD_MANAGER`, `force=True`); then, if the dump is enabled, `_dump_cr`. - **`_delete_agent(agent_id)`:** `delete_namespaced_custom_object` for the single `(name, namespace)`; a k8s **404 is swallowed** (idempotent → 204); then dump-clear the agent's tree if enabled. diff --git a/aiac/docs/specs/components/policy-computation-engine.md b/aiac/docs/specs/components/policy-computation-engine.md index 42800f6c6..7ddd02be6 100644 --- a/aiac/docs/specs/components/policy-computation-engine.md +++ b/aiac/docs/specs/components/policy-computation-engine.md @@ -21,10 +21,12 @@ The same shape produces a **latent sibling bug**: a user role added *later* (UC3 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_rules`: every `(role → scope)` rule whose `scope` this service owns. `UR→TS` lives durably on `SPM(T)`. +- **`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). - **`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 `SPM(scope.serviceId).inbound_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. +`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. Because `UR→TS` is durable on `SPM(T)` and is reconstructed onto `A` whenever `A` is derived, both onboarding orders converge to the same `APM(A)` = inbound `{UR→AS}`, outbound `{AR→TS, UR→TS}`. The latent sibling bug is fixed too: a late UC3 user role routes to `SPM(T)`, marks `A` affected, and re-derives `A`'s subject gate. @@ -52,6 +54,7 @@ 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. --- @@ -77,12 +80,17 @@ No FastAPI. No Kubernetes deployment. No container image. Imported as a library Two entry points — an incremental fold and an authoritative offboard: ```python -def compute_and_apply(rules: list[PolicyRule], override: bool = False) -> None +def compute_and_apply( + rules: list[PolicyRule], + override: bool = False, + default_effect: RuleEffect = RuleEffect.DENY, +) -> None def decommission(service_id: str) -> None # service_id = clientId (SPM key), not the Keycloak UUID ``` - **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. - **`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. - **`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` @@ -99,9 +107,9 @@ Given `rules: list[PolicyRule]` and an `override` flag, `compute_and_apply` exec 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. -2. **Route each rule to its owning service's SPM.** For each rule `(role, scope)`, append it to `SPM(scope.serviceId).inbound_rules` — fetch the SPM via `get_service_policy_by_scope` / `get_service_policy`. **Append-dedup by `role.id + scope.id`.** 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 SPM of the service that owns its scope, whatever the kinds. +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 **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 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** — `target_scopes` is a derived, never-stored quantity. +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. 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. @@ -120,30 +128,48 @@ Given `rules: list[PolicyRule]` and an `override` flag, `compute_and_apply` exec 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`. -- **Inbound:** `inbound_rules` ← all of `SPM(A).inbound_rules`. Split each by `role.kind`: - - `User` → `subject_roles[username] += role` (usernames from `role.actorIds`); - - `Agent` → `source_roles[serviceId] += role` (serviceIds from `role.actorIds`). -- **Outbound:** for each `r ∈ R_A`, find the `r`-rules in `get_service_policies_by_role(r)`. For each such `(r → s)`: add to `outbound_rules` and `target_scopes[s.serviceId] += s`. -- **Outbound subject gate:** for each target `(X, s)` in `target_scopes` — where `X` is the callee, a **tool or another agent** — take the **User**-kind inbound rules `(u → s)` on `SPM(X)`, append them to `outbound_subject_rules`, and `subject_roles += u.actorIds`. The gate's range is tool ∪ agent scopes. +- **Default effect:** `default_effect` ← the value threaded into `_derive` (default `DENY`; see [Per-policy default effect](#per-policy-default-effect-threading-default_effect)). +- **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`. + - **Identity registration is effect-agnostic:** for **every** inbound edge (allow *or* deny), register the role into the identity map — `User` → `subject_roles[username] += role` (usernames from `role.actorIds`); `Agent` → `source_roles[serviceId] += role` (serviceIds from `role.actorIds`). A role seen only in a DENY edge must still land in these maps, or the Rego deny lookup cannot resolve it. +- **Outbound:** for each `r ∈ R_A`, find the `r`-rules across **both** lists in `get_service_policies_by_role(r)`. For each such `(r → s)`: route by effect — `Allow` → `outbound_target_allow_rules` and `target_allow_scopes[s.serviceId] += s`; `Deny` → `outbound_target_deny_rules` and `target_deny_scopes[s.serviceId] += s`. +- **Outbound subject gate:** for each target `(X, s)` in the target maps — where `X` is the callee, a **tool or another agent** — take the **User**-kind inbound rules `(u → s)` on `SPM(X)`, route each by effect into `outbound_subject_allow_rules` / `outbound_subject_deny_rules`, and register `subject_roles += u.actorIds` (effect-agnostic). The gate's range is tool ∪ agent 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_ok` needs a non-empty `agent_scopes`; outbound `target_ok` needs a non-empty `agent_roles`). -- **P4 (only agents modelled):** emit an APM / Rego only for SPMs with `service_type == Agent`. Tools keep an SPM (durable `inbound_rules`) but never get an APM — no `github_tool.*.rego` is emitted. +- **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`). +- **P4 (only agents modelled):** emit an APM / Rego only for SPMs with `service_type == Agent`. Tools keep an SPM (durable `inbound_allow_rules` / `inbound_deny_rules`) but never get an APM — no `github_tool.*.rego` is emitted. - **P5b (classification):** now expressed purely as `role.kind` + `scope.serviceId`. The write-time 3-way routing table is gone; classification happens at **derive** time by splitting inbound rules on `role.kind`. ### Agent → agent access — in scope, for free An agent-to-agent edge `AR→BS` (agent A's role → agent B's scope) is stored on `SPM(B)` and handled uniformly, with **no target-type branching anywhere**: -- A's derivation: `AR ∈ R_A`, so `get_service_policies_by_role(AR)` finds `AR→BS` on `SPM(B)` → `outbound_rules += AR→BS`, `target_scopes[B] += BS`, plus B's user gates as `outbound_subject_rules`. -- B's derivation: `AR→BS ∈ SPM(B).inbound_rules`, `AR.kind == Agent` → `source_roles[A] += AR`. +- A's derivation: `AR ∈ R_A`, so `get_service_policies_by_role(AR)` finds `AR→BS` on `SPM(B)` → (assuming `Allow`) `outbound_target_allow_rules += AR→BS`, `target_allow_scopes[B] += BS`, plus B's user gates as `outbound_subject_allow_rules` (a `Deny` edge routes to the deny counterparts identically). +- B's derivation: `AR→BS ∈ SPM(B).inbound_allow_rules`, `AR.kind == Agent` → `inbound_source_allow_rules += AR→BS` and `source_roles[A] += AR` (effect-agnostic identity). Add a test for this. -**Future-optimization note (document, do NOT build now):** a shared edge like `AR→BS` is stored **once** canonically on `SPM(B)` but **projected into two APMs** (A's `outbound_rules` and B's `source_roles`), so the generated Rego duplicates it across two packages. This is acceptable; a future optimization could share the representation. +**Future-optimization note (document, do NOT build now):** a shared edge like `AR→BS` is stored **once** canonically on `SPM(B)` but **projected into two APMs** (A's `outbound_target_allow_rules` and B's `source_roles`), so the generated Rego duplicates it across two packages. This is acceptable; a future optimization could share the representation. ### Two implementation-time verification gates @@ -156,8 +182,8 @@ Confirm both while coding (they gate correctness of the whole approach): The `override` flag (set by the caller from the producing UC's choice) selects the merge mode, applied at the **SPM layer**: -- **`override=False` (default — additive append):** each rule is appended to `SPM(scope.serviceId).inbound_rules` if not already present (dedup by `role.id + scope.id`). Existing SPM rules are preserved. Incremental path (e.g. UC1 Service Onboarding, where existing roles must not lose their other access). -- **`override=True` (authoritative role-keyed replace):** before appending, the engine purges the distinct input-role set from **every** SPM containing them (`get_service_policies_by_role`), once, up-front, so the fresh rules become each role's complete mapping. Used by role-scoped recomputes (UC3 Role Update) and full rebuilds (UC2 Rebuild). +- **`override=False` (default — additive append):** each rule is appended to the effect-appropriate list — `SPM(scope.serviceId).inbound_allow_rules` or `.inbound_deny_rules` — if not already present (dedup by `role.id + scope.id + effect`). Existing SPM rules are preserved. Incremental path (e.g. UC1 Service Onboarding, where existing roles must not lose their other access). +- **`override=True` (authoritative role-keyed replace):** before appending, the engine purges the distinct input-role set from **both** lists of **every** SPM containing them (`get_service_policies_by_role`), once, up-front, so the fresh rules become each role's complete mapping. Because the purge is keyed on `role.id` alone (not effect), it clears a role's allow **and** deny edges together before re-appending whatever the input carries. Used by role-scoped recomputes (UC3 Role Update) and full rebuilds (UC2 Rebuild). `override=True` provides **role-level** revocation. Finer-grained single-rule revocation (removing one `PolicyRule` without replacing its whole role) is still **TBD**. @@ -167,7 +193,7 @@ SPM identity keys on Keycloak UUIDs, which **churn on delete/recreate**. Because **Reconcile** closes this. After routing (step 2) and any override purge (step 3), and **before** persist (step 4), each **touched** SPM is pruned against the step-1 catalog. It **reuses that same `get_services()` result** — no additional IdP read, so the *only-runtime-IdP-read-is-`get_services()`* invariant holds. It runs under **both** merge modes and is **order-independent** — it removes *only* edges whose entity genuinely no longer exists, never a live edge, so both onboarding orders still converge. "Touched SPMs only": at that point the SPM cache holds exactly the routed + override-purged SPMs (agent-derive SPMs aren't loaded yet). -For each touched `SPM(X)` whose owner `X` **is present in the catalog** (a catalog **miss ⇒ skip pruning**, never wipe on a transient outage), an inbound edge is kept iff: +The prune runs over **both** `inbound_allow_rules` and `inbound_deny_rules` — the keep rules below are applied per edge in each list identically (a dangling deny edge is GC'd exactly as a dangling allow edge). For each touched `SPM(X)` whose owner `X` **is present in the catalog** (a catalog **miss ⇒ skip pruning**, never wipe on a transient outage), an inbound edge is kept iff: 1. **Scope still exists** — `edge.scope.id ∈ {s.id for s in owned_scopes}` (X's current `aiac.managed` scopes, seeded from the catalog). Drops retired/churned scopes (kills the `*-aud` species and scope-model cruft). 2. **Agent role still exists** — for `role.kind == Agent`, `edge.role.id ∈` the catalog's `aiac.managed` role ids (all services). Drops retired/churned agent client roles (kills self-references and agent-role UUID churn). @@ -183,12 +209,12 @@ 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)` (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`; mark `B` changed and, if `B` is an agent, affected (its inbound `source_roles[X]` vanished). +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). 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 `outbound` / `target_scopes` / `source_roles` referencing `X` drop automatically. +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. **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. @@ -196,7 +222,7 @@ Steps: | Module | Purpose | |--------|---------| -| `aiac.policy.model` | `PolicyRule`, `ServicePolicyModel`, `AgentPolicyModel`, `PolicyModel` | +| `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 | @@ -233,17 +259,23 @@ Key behaviors to assert: - **Original repro, both orders → identical `APM(A)`.** Onboard **A then T** and **T then A**; assert the derived `APM(A)` is identical (inbound `{UR→AS}`, outbound `{AR→TS, UR→TS}`), compared as order-independent `(role, scope)` sets. This is the headline regression guard. - **Latent sibling bug (late UC3 user role).** After A+T exist, a later user-role rule `(UR2 → TS)` routes to `SPM(T)`, marks A affected, and A's re-derived subject gate includes `UR2`. -- **Agent → agent (`AR→BS`).** Stored on `SPM(B)`; A's derived APM has `AR→BS` in `outbound_rules` + `target_scopes[B]`; B's derived APM has `source_roles[A] += AR`. +- **Agent → agent (`AR→BS`).** Stored on `SPM(B)`; A's derived APM has `AR→BS` in `outbound_target_allow_rules` + `target_allow_scopes[B]`; B's derived APM has `source_roles[A] += AR`. - **Override role-level purge across SPMs.** `override=True` with an input role already present on multiple SPMs → that role is purged from **every** SPM (via `get_service_policies_by_role`) once, up-front, before the fresh rules are appended; a role shared across the input is not wiped after being added. - **Append dedup.** A rule already present on the target SPM (same `role.id + scope.id`) is not appended twice; map list entries (same `id`) are not duplicated. - **No flattening.** Rules arrive pre-flattened; the PCE issues at most one `get_service_policies_by_role` call **per distinct role** — a rule carrying a composite role does not trigger per-child calls inside the PCE. -- **Tool gets an SPM but no APM (P4).** A Tool service accrues durable `inbound_rules` on its SPM but is never emitted as an APM/Rego; the agent→tool `target_scopes` edge still appears on the agent's derived APM. +- **Tool gets an SPM but no APM (P4).** A Tool service accrues durable inbound edges (`inbound_allow_rules` / `inbound_deny_rules`) on its SPM but is never emitted as an APM/Rego; the agent→tool `target_allow_scopes` edge still appears on the agent's derived APM. - **P2 identity from `owned_*`.** Each derived APM's `agent_roles` / `agent_scopes` come from `SPM(A).owned_roles` / `owned_scopes`, AIAC-managed-filtered; an agent with no AIAC-managed catalog roles/scopes keeps `[]`. - **Directional relevance — no false outbound edge.** A user role shared between `AS` and `TS` does **not** by itself make A "target" T; A's outbound edge to T appears only if one of A's **agent** roles maps to a T scope. - **Affected set from the batch, not a full scan.** The affected-agent set is computed from the batch roles/scopes; agents unrelated to the batch are never derived or upserted. - **`apply_policy` called exactly once** after all `apply_service_policy` writes complete (partial upsert of only the affected agents). - **Reconcile (drift GC).** A touched SPM carrying dangling edges (retired scope, churned scope UUID, churned/duplicate user role, retired agent-role self-reference) is pruned against the catalog on re-onboarding; live edges survive and the pass is idempotent; a catalog miss (owner absent) leaves the SPM untouched. - **Decommission (service offboard).** Onboard an agent A targeting tool T, then `decommission(T)`: `SPM(T)` is deleted, no `delete_agent_policy` (tool has no APM), and A is re-derived with an empty outbound while its inbound survives. `decommission(A)`: `SPM(A)` deleted, `delete_agent_policy(A)` called, A's outbound footprint (`AR→TS` on `SPM(T)`) purged while T keeps its user grant, and no APM re-derived for the deleted agent. A never-onboarded / 404 service is a no-op. +- **Effect routing.** A `Deny` rule routes to `SPM(scope.serviceId).inbound_deny_rules`; an `Allow` rule to `inbound_allow_rules`. Append-dedup keys on `role.id + scope.id + effect`, so the same `(role, scope)` can be present once in each list. +- **Effect-aware derivation.** A subject DENY edge on `SPM(A)` derives into `inbound_subject_deny_rules`, and its role still appears in the effect-agnostic `subject_roles`; an agent-role → target-scope DENY edge derives into `outbound_target_deny_rules` + `target_deny_scopes[target]`. +- **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`. **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/aiac/docs/specs/components/policy-model-store.md b/aiac/docs/specs/components/policy-model-store.md index 794293bf7..a3e33d7ff 100644 --- a/aiac/docs/specs/components/policy-model-store.md +++ b/aiac/docs/specs/components/policy-model-store.md @@ -38,7 +38,7 @@ The SPM is the **source of truth**. The PDP Policy Writer retains sole ownership ### Policy Model Store Service -**Location:** `aiac/src/aiac/policy/store/service/` +**Location:** `aiac/src/aiac/policy/model_store/service/` **Port:** `0.0.0.0:7074` @@ -76,10 +76,12 @@ consistent. - Per-service upsert (`POST /policy/services/{service_id}`): `INSERT OR REPLACE INTO service_policies VALUES (?, ?)`. - Per-service delete (`DELETE /policy/services/{service_id}`): `DELETE FROM service_policies WHERE service_id = ?`; evict the cache entry. -**By-role query:** `GET /policy/services?role={role_id}` scans the cache and returns every SPM whose `inbound_rules` contains a rule referencing `role_id`. **Why a store query and not an IdP lookup:** the SPM is the source of truth, so this must return *stored* rows — including stale role→service mappings that the live IdP no longer reflects, which override-purge depends on to remove access that should no longer exist. It may start as a full scan; a `role.id -> {service_id}` index can be added later behind the same route/signature without changing callers. +**By-role query:** `GET /policy/services?role={role_id}` scans the cache and returns every SPM whose `inbound_allow_rules` **or** `inbound_deny_rules` contains a rule referencing `role_id` (the scan covers **both** effect lists). **Why a store query and not an IdP lookup:** the SPM is the source of truth, so this must return *stored* rows — including stale role→service mappings that the live IdP no longer reflects, which override-purge depends on to remove access that should no longer exist. It may start as a full scan; a `role.id -> {service_id}` index can be added later behind the same route/signature without changing callers. **Future normalization:** migrate to `service_policies` + `policy_rules(service_id, role, scope)` tables once `ServicePolicyModel`/rule schema stabilizes — a future observability UI (and a native by-role index) will benefit from queryable columns. JSON column in the current schema avoids migration churn during active development. +**ALLOW/DENY rollout — state reset, no back-compat.** With two-sided rules (see [policy-model.md](policy-model.md)), the stored `ServicePolicyModel.spec` JSON carries `inbound_allow_rules` + `inbound_deny_rules` in place of the former single `inbound_rules`. Because the models use `ConfigDict(extra='ignore')`, loading an old row would **silently drop** the renamed field — a stale half-migrated read. There is **no alias / no dual-read shim / no row migration**: the store's SQLite state is **cleared out-of-band and re-seeded by re-onboarding**. The `spec` JSON column itself needs no schema change (it is opaque to the store), so the reset is a data operation, not a table migration. + **Endpoints:** | Method | Path | Body | Returns | @@ -87,7 +89,8 @@ consistent. | `GET` | `/policy/services/{service_id}` | — | `ServicePolicyModel` (from cache) | | `GET` | `/policy/services?role={role_id}` | — | `list[ServicePolicyModel]` (SPMs referencing the role) | | `POST` | `/policy/services/{service_id}` | `ServicePolicyModel` | `204 No Content` (upsert) | -| `DELETE` | `/policy/services/{service_id}` | — | `204 No Content` (off-board) | +| `DELETE` | `/policy/services/{service_id}` | — | `204 No Content` (off-board a single service) | +| `DELETE` | `/policy/services` | — | `204 No Content` (clear all SPMs — rebuild / test-harness clean slate) | | `GET` | `/health` | — | `200` / `503` | The by-scope lookup has **no dedicated route** — it collapses to the by-id read via `scope.serviceId` and is implemented entirely in the library. @@ -100,7 +103,9 @@ decoded real id — every `service_id` in a request/response *body* (including t always the decoded, real form. The by-role query's `role={role_id}` param is unaffected (not a path segment). -`DELETE /policy/services/{service_id}` removes a single SPM row (SQLite `DELETE` + cache eviction) so a service can be off-boarded when it is decommissioned. Deleting a service that is not present is a no-op (`204`). Override-purge still edits `inbound_rules` in place via the upsert; the delete route is for whole-service removal, not per-rule purging. +`DELETE /policy/services/{service_id}` removes a single SPM row (SQLite `DELETE` + cache eviction) so a service can be off-boarded when it is decommissioned. Deleting a service that is not present is a no-op (`204`). Override-purge still edits the SPM's `inbound_allow_rules` / `inbound_deny_rules` in place via the upsert; the delete route is for whole-service removal, not per-rule purging. + +`DELETE /policy/services` (no `service_id`) is the collection-root **clear-all**: it drops every SPM row and empties the cache, giving a clean slate for a full rebuild or a test harness. Always `204`. **Error responses:** - `404 Not Found` with `{"error": "service {id} not found"}` when `GET /policy/services/{service_id}` finds no entry in cache. The library's `get_service_policy` catches this and returns a fresh empty SPM (per the "engine creates a fresh model on 404" convention); the by-role query never 404s (empty list on no match). @@ -109,11 +114,12 @@ segment). **`main.py` functions:** -- `_get_db() -> sqlite3.Connection` — open `SERVICEPOLICY_DB_PATH` with `check_same_thread=False`; run `CREATE TABLE IF NOT EXISTS` on first open. -- `_upsert_service(service_id: str, model: ServicePolicyModel)` — under the write lock: `INSERT OR REPLACE INTO service_policies VALUES (?, ?)` with `model.model_dump_json()`, then update cache (DB + cache write as one locked critical section). -- `_delete_service(service_id: str)` — under the write lock: `DELETE FROM service_policies WHERE service_id = ?`, then evict the cache entry (no-op if absent) — DB + cache eviction as one locked critical section. -- `_get_service(service_id: str) -> ServicePolicyModel` — read from in-memory cache; raise `404` if absent. -- `_list_by_role(role_id: str) -> list[ServicePolicyModel]` — return every cached SPM whose `inbound_rules` references `role_id`. +- `get_db() -> sqlite3.Connection` — open `SERVICEPOLICY_DB_PATH` with `check_same_thread=False` (FastAPI dependency); `_init_db` runs `CREATE TABLE IF NOT EXISTS` on first open. +- `upsert_service_policy(service_id: str, model: ServicePolicyModel)` — `POST /policy/services/{service_id}`; under the write lock: `INSERT OR REPLACE INTO service_policies VALUES (?, ?)` with `model.model_dump_json()`, then update cache (DB + cache write as one locked critical section). +- `delete_service_policy(service_id: str)` — `DELETE /policy/services/{service_id}`; under the write lock: `DELETE FROM service_policies WHERE service_id = ?`, then evict the cache entry (no-op if absent) — DB + cache eviction as one locked critical section. +- `clear_service_policies()` — `DELETE /policy/services`; under the write lock: `DELETE FROM service_policies` (all rows) and clear the cache — the collection-root clean slate. +- `get_service_policy(service_id: str) -> ServicePolicyModel` — `GET /policy/services/{service_id}`; read from in-memory cache; raise `404` if absent. +- `list_service_policies_by_role(role_id: str) -> list[ServicePolicyModel]` — `GET /policy/services?role={role_id}`; return every cached SPM whose `inbound_allow_rules` or `inbound_deny_rules` references `role_id`. - `_load_cache()` — on startup, load all rows from SQLite into the in-memory cache. **Configuration:** @@ -129,7 +135,7 @@ segment). **File structure:** ``` -aiac/src/aiac/policy/store/service/ +aiac/src/aiac/policy/model_store/service/ ├── __init__.py ├── Dockerfile ├── requirements.txt @@ -154,7 +160,7 @@ Good tests assert external behavior at the system boundary — not internal impl Key behaviors to assert: - `GET /policy/services/{id}`: returns `ServicePolicyModel` deserialized from cache (hit); `404 {"error": "service {id} not found"}` when the service is not in cache (miss). -- `GET /policy/services?role={role_id}`: returns every SPM whose `inbound_rules` references the role; `[]` when none match; multiple when several match. +- `GET /policy/services?role={role_id}`: returns every SPM whose `inbound_allow_rules` or `inbound_deny_rules` references the role; `[]` when none match; multiple when several match. - `POST /policy/services/{id}`: `spec` stored in SQLite; cache updated; `204` returned. Upsert round-trip: a second `POST` for the same id replaces the row. - `DELETE /policy/services/{id}`: row removed from SQLite; cache entry evicted; `204` returned. Deleting an absent service is a no-op (`204`). - SQLite write error on the write or delete endpoint → `502`. diff --git a/aiac/docs/specs/components/policy-model.md b/aiac/docs/specs/components/policy-model.md index 74fc1dc60..0c770cc6a 100644 --- a/aiac/docs/specs/components/policy-model.md +++ b/aiac/docs/specs/components/policy-model.md @@ -25,6 +25,10 @@ Concretely, let `UR` be a user (realm) role mapped to agent `A`'s scope `AS` and The fix is a **two-layer model**: a per-service persistent source of truth (`ServicePolicyModel`) that stores every inbound edge durably on the service that owns the scope — so `UR→TS` lands on `SPM(T)` at tool-onboarding, no agent required, and can never be lost — with `AgentPolicyModel` demoted to a **pure derived projection** that is no longer persisted. +### Allowlist-only (no negative rules) + +The model can express only **grants**. A `PolicyRule(role, scope)` is always positive — "this role *may* reach this scope" — and everything not granted is implicitly unreachable (`default allow := false`). There is no way to record that a role **must not** reach a scope. Policy authors describe access in mixed terms ("developers can read source files **but must not** touch issues"), but an allowlist-only model forces the negative to be expressed as the *absence* of a grant. That is fragile: any later, broader grant (a composite role, a role update, another onboarding) silently re-opens the path the author meant to keep closed, because no durable fact records the prohibition. + ## Solution A canonical, dependency-free model module at `aiac.policy.model` defines `ServicePolicyModel`, `PolicyRule`, `AgentPolicyModel`, and `PolicyModel` with typed fields. No HTTP client, no service code — importable by any consumer without side effects. `PolicyRule.role` and `PolicyRule.scope` are typed `Role` and `Scope` objects from `aiac.idp.configuration.models`. @@ -33,7 +37,11 @@ A canonical, dependency-free model module at `aiac.policy.model` defines `Servic **Canonical form.** *Every rule is an inbound edge on the SPM of the service that owns the rule's scope.* An agent's outbound edge is the target's inbound edge — `AR→TS` is stored on `SPM(T)`, not on `A`. The routing key is `Scope.serviceId`: a rule `(role, scope)` routes to `SPM(scope.serviceId)`. -The relationship maps (`source_roles`, `subject_roles`, `target_scopes`) are keyed by the string `id` of the referenced entity rather than by a typed object, so they serialize to JSON natively and carry no hashability requirement into `aiac.policy.model`. Typed `Role` / `Scope` objects are retained as the map *values* (and in `PolicyRule`), preserving the typing the PCE needs for IdP queries. The outbound map is `target_scopes` (`target service id → scopes permitted`), the inverse of the former `scope_targets`. +The relationship maps (`source_roles`, `subject_roles`, `target_allow_scopes` / `target_deny_scopes`) are keyed by the string `id` of the referenced entity rather than by a typed object, so they serialize to JSON natively and carry no hashability requirement into `aiac.policy.model`. Typed `Role` / `Scope` objects are retained as the map *values* (and in `PolicyRule`), preserving the typing the PCE needs for IdP queries. The outbound maps are `target_allow_scopes` / `target_deny_scopes` (`target service id → scopes permitted / prohibited`), the inverse of the former `scope_targets`. + +**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. --- @@ -43,10 +51,16 @@ The relationship maps (`source_roles`, `subject_roles`, `target_scopes`) are key 2. As the PDP Policy Library, I want to import `PolicyModel` and `AgentPolicyModel` from `aiac.policy.model`, so that my HTTP serialization logic does not duplicate model definitions. 3. As the Policy Model Store Library, I want to import `AgentPolicyModel` and `PolicyModel` from `aiac.policy.model`, so that response deserialization uses the same canonical types as every other consumer. 4. As an AIAC Agent sub-UC agent, I want to construct a `PolicyRule` with typed `Role` and `Scope` objects, so that the PCE can use them for IdP queries without additional type conversion. -5. As the Policy Computation Engine, I want `source_roles`, `subject_roles`, and `target_scopes` keyed by string entity IDs, so that I build them with `entity.id` and they serialize to JSON without custom key handling. +5. As the Policy Computation Engine, I want `source_roles`, `subject_roles`, `target_allow_scopes`, and `target_deny_scopes` keyed by string entity IDs, so that I build them with `entity.id` and they serialize to JSON without custom key handling. 6. As a developer, I want all models to silently ignore unknown fields from API responses, so that IdP API additions do not break deserialization. 7. As the PDP Policy Library, I want outbound permissions expressed as `target service id → allowed scopes`, so that I can emit per-target authorization directly without inverting a `scope → targets` map. 8. As a consumer serializing an `AgentPolicyModel` to JSON, I want every relationship map to have string keys, so that `model_dump(mode="json")` round-trips without a custom key serializer. +9. As a policy author, I want to tag a rule `Deny` so that it records a durable prohibition, independent of the grants around it. +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. --- @@ -93,6 +107,16 @@ The two-layer model requires ownership and a user/agent distinction on the IdP t A `model_validator` on `Role` enforces what it can locally (`kind` present/valid; `actorIds` is a `list[str]`). The **cross-kind** invariant (Assumption 1) and the **client/realm ⇔ agent/user** invariant (Assumption 3) are enforced **upstream at construction** (the Keycloak IdP boundary), because the raw Keycloak facts are only visible there — see handoff 02 for that enforcement and field population. +#### `RuleEffect` + +```python +class RuleEffect(str, Enum): + ALLOW = "Allow" + DENY = "Deny" +``` + +A string enum (mirroring `ServiceType` / `RoleKind`) tagging a `PolicyRule` as a **grant** (`Allow`) or a **prohibition** (`Deny`). Serializes as the string `"Allow"` / `"Deny"`. + #### `ServicePolicyModel` The persistent source of truth — one per service (agent *and* tool), keyed by `service_id`. Holds the service's inbound rules plus its own identity. @@ -103,18 +127,22 @@ The persistent source of truth — one per service (agent *and* tool), keyed by | `service_type` | `ServiceType` | `Agent` or `Tool`. Drives derivation: only `Agent` services get an APM. | | `owned_roles` | `list[Role]` | This service's own client roles (`aiac.managed` marker only). | | `owned_scopes` | `list[Scope]` | This service's exposed scopes (`aiac.managed` marker only). | -| `inbound_rules` | `list[PolicyRule]` | Canonical: every edge granting access to `owned_scopes`. | +| `inbound_allow_rules` | `list[PolicyRule]` | Canonical positive edges: every `Allow` rule granting access to `owned_scopes`. | +| `inbound_deny_rules` | `list[PolicyRule]` | Canonical negative edges: every `Deny` rule prohibiting access to `owned_scopes`. | -`owned_roles` / `owned_scopes` are the service's own identity, filtered to the `aiac.managed` marker (this is where the PCE's P2 identity now lives). They are seeded from the catalog by the PCE; this module only defines the shape. `ServicePolicyModel` round-trips through `model_dump(mode="json")` / `model_validate()` with string keys only. +`inbound_rules` splits into two **explicitly separated** parallel lists — `inbound_allow_rules` + `inbound_deny_rules` — not one intermixed list filtered by `effect`. `owned_roles` / `owned_scopes` are the service's own identity, filtered to the `aiac.managed` marker (this is where the PCE's P2 identity now lives). They are seeded from the catalog by the PCE; this module only defines the shape. `ServicePolicyModel` round-trips through `model_dump(mode="json")` / `model_validate()` with string keys only. #### `PolicyRule` -A single access rule pairing a typed role with a typed scope. Used in both inbound and outbound rule sets. +A single access rule pairing a typed role with a typed scope, tagged with an effect. Used in both inbound and outbound rule sets. | Field | Type | Description | |-------|------|-------------| | `role` | `Role` | Typed role from `aiac.idp.configuration.models` | | `scope` | `Scope` | Typed scope from `aiac.idp.configuration.models` | +| `effect` | `RuleEffect` | `Allow` (default) or `Deny`. Defaulting to `Allow` keeps existing allow-only producers working unchanged. | + +**Dedup identity is `(role.id, scope.id, effect)`** (was `(role.id, scope.id)`). Including `effect` lets the same `(role, scope)` exist once as `Allow` and once as `Deny` without one clobbering the other on append. (Under the no-conflict assumption that never happens for the *same subject*, but the identity is effect-aware regardless.) #### `AgentPolicyModel` @@ -122,23 +150,36 @@ Complete policy definition for a single agent (service). Inbound and outbound ru > **Derived, not persisted.** `AgentPolicyModel` is now a **pure derived projection** built by the PCE from the relevant `ServicePolicyModel`s. It is **no longer a persisted entity** — the durable source of truth is `ServicePolicyModel`. Its shape is **unchanged** so existing consumers (PDP Policy Library, Policy Model Store readers) keep working; the docstring on the model states this explicitly. +The rule lists split into **8 entity×effect lists** — {inbound subject, inbound source, outbound subject, outbound target} × {allow, deny} — plus split target maps. The identity/aggregate maps (`subject_roles`, `source_roles`, `agent_roles`, `agent_scopes`) stay **effect-agnostic**. + | Field | Type | Description | |-------|------|-------------| | `agent_id` | `str` | Service ID from the AIAC trigger event (`aiac.apply.service.{id}`) | -| `agent_roles` | `list[Role]` | Realm roles assigned to this agent | -| `agent_scopes` | `list[Scope]` | Scopes this agent exposes | -| `source_roles` | `dict[str, list[Role]]` | Inbound: source (calling service) **id** → roles held. **Optional** gate input — an absent source passes. | -| `subject_roles` | `dict[str, list[Role]]` | Inbound: subject (end-user) **id** → roles held. **Mandatory** gate input. | -| `target_scopes` | `dict[str, list[Scope]]` | Outbound: target service **id** → scopes this agent may request on it | -| `inbound_rules` | `list[PolicyRule]` | Who may call this agent: `(subject_role, agent_scope)` tuples | -| `outbound_rules` | `list[PolicyRule]` | What this agent may call: `(this_agent_role, target_scope)` tuples | -| `outbound_subject_rules` | `list[PolicyRule]` | Which users may reach the agent's targets: `(user_role, tool_scope)` tuples. Defaults to `[]`. | +| `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). | +| `subject_roles` | `dict[str, list[Role]]` | Inbound: subject (end-user) **id** → roles held. **Mandatory** gate input. **Effect-agnostic identity** (see deny-inclusion note). | +| `target_allow_scopes` | `dict[str, list[Scope]]` | Outbound: target service **id** → scopes this agent **may** request on it | +| `target_deny_scopes` | `dict[str, list[Scope]]` | Outbound: target service **id** → scopes this agent **must not** request on it | +| `inbound_subject_allow_rules` | `list[PolicyRule]` | Who may call this agent: `(subject_role, agent_scope)` `Allow` tuples | +| `inbound_subject_deny_rules` | `list[PolicyRule]` | Which subjects are barred: `(subject_role, agent_scope)` `Deny` tuples | +| `inbound_source_allow_rules` | `list[PolicyRule]` | Which calling services may call this agent: `(source_role, agent_scope)` `Allow` tuples | +| `inbound_source_deny_rules` | `list[PolicyRule]` | Which calling services are barred: `(source_role, agent_scope)` `Deny` tuples | +| `outbound_target_allow_rules` | `list[PolicyRule]` | What this agent may call: `(this_agent_role, target_scope)` `Allow` tuples | +| `outbound_target_deny_rules` | `list[PolicyRule]` | What this agent must not call: `(this_agent_role, target_scope)` `Deny` tuples | +| `outbound_subject_allow_rules` | `list[PolicyRule]` | Which users may reach the agent's targets: `(user_role, tool_scope)` `Allow` tuples. Defaults to `[]`. | +| `outbound_subject_deny_rules` | `list[PolicyRule]` | Which users are barred from the agent's targets: `(user_role, tool_scope)` `Deny` tuples. Defaults to `[]`. | + +> **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. -**Inbound rule semantics:** a subject holding realm role `role` is permitted to invoke this agent for the agent scope `scope`. The PDP Policy Writer consumes `inbound_rules` as a role → agent-scope map; its inbound gate is keyed on the subject id (mandatory), with the calling source id optional. +> **`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. -**Outbound rule semantics:** this agent acting as realm role `role` is permitted to request the target scope `scope`. The PDP Policy Writer consumes `outbound_rules` as an agent-role → target-scope map; its outbound gate requires both the subject and the agent to be authorized. +**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. -**Outbound subject rule semantics:** `outbound_subject_rules` holds `(user role, tool scope)` pairs — the outbound subject gate; a user holding `role` may reach a tool exposing `scope`. It is the outbound counterpart of `inbound_rules` (which pairs a user role with an *agent* scope): where `inbound_rules` answers "may this user call the agent?", `outbound_subject_rules` answers "may this user reach the tool the agent targets?". The PDP Policy Writer groups it into `subject_role_scopes` (user role → tool-scope names) and matches against `target_scopes[input.target]`, not against `agent_scopes`. +**Outbound target rule semantics (deny-overrides):** this agent acting as realm role `role` is permitted to request the target scope `scope` iff an `outbound_target_allow` edge grants it **and no** `outbound_target_deny` edge prohibits it. The PDP Policy Writer consumes the allow/deny lists as separate agent-role → target-scope maps; its outbound gate requires both the subject and the agent to be authorized and neither to be denied. + +**Outbound subject rule semantics (deny-overrides):** the outbound subject gate pairs `(user role, tool scope)` — a user holding `role` may reach a tool exposing `scope` iff an `outbound_subject_allow` edge grants it and no `outbound_subject_deny` edge prohibits it. It is the outbound counterpart of the inbound subject rules (which pair a user role with an *agent* scope): where those answer "may this user call the agent?", these answer "may this user reach the tool the agent targets?". The PDP Policy Writer groups them into `subject_role_allow_scopes` / `subject_role_deny_scopes` (user role → tool-scope names) and matches against `target_allow_scopes[input.target]` / `target_deny_scopes[input.target]`, not against `agent_scopes`. #### `PolicyModel` @@ -150,7 +191,7 @@ A partial or full system policy model. When sent to `POST /policy` on the Policy ### Map keys are string IDs -`source_roles`, `subject_roles`, and `target_scopes` are keyed by the string `id` of the referenced Keycloak entity (source service id, subject id, target service id) rather than by the typed `Service` / `Subject` / `Scope` object. Rationale: +`source_roles`, `subject_roles`, `target_allow_scopes`, and `target_deny_scopes` are keyed by the string `id` of the referenced Keycloak entity (source service id, subject id, target service id) rather than by the typed `Service` / `Subject` / `Scope` object. Rationale: - JSON object keys must be strings. A dict keyed by a pydantic model does not round-trip through `model_dump(mode="json")` / JSON without a custom key serializer; a `str` key serializes natively. - The IdP models are plain pydantic models (default field-based equality, not hashable). Consumers build these maps with `entity.id` as the key. @@ -160,23 +201,33 @@ As a result, no field in `aiac.policy.model` uses a typed object as a dict key, ### Usage ```python -from aiac.policy.model.models import PolicyRule, AgentPolicyModel, PolicyModel +from aiac.policy.model.models import PolicyRule, RuleEffect, AgentPolicyModel, PolicyModel from aiac.idp.configuration.models import Role, Scope -role = Role(id="r1", name="weather-reader", composite=False) -scope = Scope(id="s1", name="read") +reader = Role(id="r1", name="weather-reader", composite=False) +issues_role = Role(id="r2", name="developer", composite=False) +read = Scope(id="s1", name="read") +issues = Scope(id="s2", name="issues") + +allow_rule = PolicyRule(role=reader, scope=read) # effect defaults to Allow +deny_rule = PolicyRule(role=issues_role, scope=issues, effect=RuleEffect.DENY) -rule = PolicyRule(role=role, scope=scope) agent_model = AgentPolicyModel( agent_id="weather-agent", - agent_roles=[role], - agent_scopes=[scope], + agent_roles=[reader], + agent_scopes=[read], source_roles={}, - subject_roles={"u1": [role]}, # keyed by subject id - target_scopes={"github-tool": [scope]}, # target service id → scopes - inbound_rules=[rule], - outbound_rules=[], - outbound_subject_rules=[], # (user_role, tool_scope) pairs; defaults to [] + subject_roles={"u1": [reader], "u2": [issues_role]}, # keyed by subject id; deny-only role u2 still listed + target_allow_scopes={"github-tool": [read]}, # target service id → allowed scopes + target_deny_scopes={"github-tool": [issues]}, # target service id → prohibited scopes + inbound_subject_allow_rules=[allow_rule], + inbound_subject_deny_rules=[], + inbound_source_allow_rules=[], + inbound_source_deny_rules=[], + outbound_target_allow_rules=[], + outbound_target_deny_rules=[deny_rule], + outbound_subject_allow_rules=[], # (user_role, tool_scope) pairs; defaults to [] + outbound_subject_deny_rules=[], # defaults to [] ) model = PolicyModel(agents=[agent_model]) ``` @@ -203,13 +254,17 @@ 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. -- `ServicePolicyModel` constructs with `service_id`, `service_type`, `owned_roles`, `owned_scopes`, `inbound_rules`, and round-trips via `model_dump(mode="json")` / `model_validate()` (string keys only) with typed `Role` / `Scope` / `PolicyRule` values preserved. +- `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"`. +- 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. -- `AgentPolicyModel` with string-ID keys in `source_roles`, `subject_roles`, and `target_scopes` round-trips through `model_dump(mode="json")` / `model_validate()` with the typed `Role` / `Scope` list values preserved. -- `target_scopes` maps a target service id to the list of `Scope` objects permitted on it (outbound direction is `target → scopes`, not `scope → targets`). -- `outbound_subject_rules` defaults to `[]` (constructors that omit it still validate) and round-trips through `model_dump(mode="json")` / `model_validate()` with its `(user_role, tool_scope)` `PolicyRule` values preserved. +- `AgentPolicyModel` with string-ID keys in `source_roles`, `subject_roles`, `target_allow_scopes`, and `target_deny_scopes`, and the 8 entity×effect rule lists populated, round-trips through `model_dump(mode="json")` / `model_validate()` with the typed values preserved. +- `target_allow_scopes` / `target_deny_scopes` each map a target service id to the list of `Scope` objects permitted / prohibited on it (outbound direction is `target → scopes`, not `scope → targets`). +- **Deny-edge role registered in identity maps:** a role appearing **only** in a DENY rule is still present in the `subject_roles` / `source_roles` value list for its subject/source — assert the identity map is effect-agnostic and complete. +- The 8 rule lists and both target maps default to empty (constructors that omit them still validate) and round-trip with their `PolicyRule` / `Scope` values preserved. - A relationship map keyed by a plain string serializes to a JSON object without a custom key serializer. -- `ConfigDict(extra='ignore')` causes unknown fields to be silently discarded on `model_validate()`. +- `ConfigDict(extra='ignore')` causes unknown fields to be silently discarded on `model_validate()` (this is exactly why the rename requires a store reset — see Migration). --- @@ -218,10 +273,16 @@ Key behaviors to assert: - HTTP serialization logic — handled by `aiac.policy.model_store.library`, `aiac.policy.model_store.service`, and `aiac.pdp.policy.library`. - IdP API integration — `Service`, `Role`, `Scope` shapes are owned by `aiac.idp.configuration.models`. - Rule revocation semantics — TBD; no model changes required until the design is finalised. +- **PRB deny-extraction** — pulling `Deny` rules out of natural-language policy text is the Policy Rules Builder's concern, not this model module's; this module only defines the `effect` field the PRB populates. (The PRB now extracts `Deny` rules — explicit prohibitions, description-driven denies, and the derived exclusivity complement — see [`aiac-agent/policy-rules-builder.md`](aiac-agent/policy-rules-builder.md).) +- **Conflict / precedence resolution** — the model assumes no `(role, scope)` is both `Allow` and `Deny` for the same subject, so there is no tie-break. Cross-role ALLOW-vs-DENY conflict resolution is an explicit later concern. --- +## Migration (state reset, no back-compat) + +**Symmetric rename, no alias, no dual-read shim, no record migration.** `inbound_rules` → `inbound_allow_rules` + `inbound_deny_rules` (SPM) and the APM rule-list/target-map renames are hard renames. Because every model uses `ConfigDict(extra='ignore')`, loading an old single-list record would **silently drop** the now-unknown `inbound_rules` field and yield a stale, half-migrated read. So the Policy Model Store state is **nuked out-of-band and re-seeded by re-onboarding** — there is no alias, no dual-read compatibility path, and no migration of old records. All generated `.rego` golden fixtures are regenerated as part of the rollout. + ## Further Notes - Keying maps by string `id` sidesteps the previous reliance on id-only hashing of the IdP models: two records for the same Keycloak entity fetched at different times (with potentially different enrichment fields) collapse to the same string key regardless of those differences. -- `aiac/src/aiac/agent/policy/api.py` imports `PolicyRule` from `aiac.policy.model`. The `role_to_scopes` / `roles_to_scope` helpers in that file remain in place and are used by AIAC Agent sub-UC agents directly; they are not consumed by the PCE. +- The `effect` split lives **only** in the rule lists (`*_allow_rules` / `*_deny_rules`) and the target maps (`target_allow_scopes` / `target_deny_scopes`). The identity/aggregate maps (`subject_roles`, `source_roles`, `agent_roles`, `agent_scopes`) remain effect-agnostic and must include deny-edge roles so the Rego deny lookup can resolve them. diff --git a/aiac/docs/specs/integration-test/pdp-policy-writer.md b/aiac/docs/specs/integration-test/pdp-policy-writer.md index 4b0cb8eeb..5ef9bc068 100644 --- a/aiac/docs/specs/integration-test/pdp-policy-writer.md +++ b/aiac/docs/specs/integration-test/pdp-policy-writer.md @@ -62,9 +62,9 @@ Role → access, as encoded by the model's inbound and outbound rules: - `developer` — source read/write, issues read. - `tester` — issues read/write. -This user→tool access is encoded in the model's `outbound_subject_rules` (`(user_role, tool_scope)` -pairs), which the outbound package renders as `subject_role_scopes`. The model's -`inbound_rules` (user→agent-scope) and `outbound_rules` (agent-role→tool-scope) are unchanged. +This user→tool access is encoded in the model's `outbound_subject_allow_rules` (`(user_role, tool_scope)` +pairs — this fixture is allow-only), which the outbound package renders as `subject_role_allow_scopes`. The model's +`inbound_subject_allow_rules` (user→agent-scope) and `outbound_target_allow_rules` (agent-role→tool-scope) are unchanged. Applying this `PolicyModel` produces exactly two files in `REGO_OUTPUT_DIR`: @@ -75,12 +75,15 @@ Both must match the **ID-only** package shapes in [../components/pdp-policy-writer-opa.md](../components/pdp-policy-writer-opa.md): input is IDs only (`{subject, source}` inbound, `{subject, target}` outbound); all role/scope maps are embedded in the package; the inbound gate is subject-mandatory + source-optional; the outbound gate requires both -subject and agent to pass, but its **subject** gate is now user→**tool** — it reads -`subject_role_scopes` (grouped from `outbound_subject_rules`) and matches against -`target_scopes[input.target]`, distinct from the inbound user→agent gate — while `target_ok` -(agent→tool, from `agent_roles` × `agent_role_scopes`) is unchanged; and `target_scopes` is emitted -verbatim (target id → scopes, no inversion). Because the input carries no per-request scope, the -decision is coarse — a principal passes on having access to **at least one** relevant scope. +subject and target capability to pass, but its **subject** gate is now user→**tool** — it reads +`subject_role_allow_scopes` (grouped from `outbound_subject_allow_rules`) and matches against +`target_allow_scopes[input.target]`, distinct from the inbound user→agent gate — while `target_allow_ok` +(the capability gate, `input.function_name in target_allow_scopes[input.target]`) is unchanged; and +`target_allow_scopes` is emitted verbatim (target id → scopes, no inversion). The `agent_roles` × +`agent_role_scopes` maps are still emitted (informational — a single map, no allow/deny split). All rule lists here are allow-only, so no +deny maps (`*_deny_scopes`) appear; the generated `allow` still applies deny-overrides, which is vacuous when +the deny lists are empty. Because the input carries no per-request scope on the inbound side, that decision is +coarse — a principal passes on having access to **at least one** relevant scope. The `PolicyModel` / `AgentPolicyModel` / `PolicyRule` objects come from `aiac.policy.model.models` ([../components/policy-model.md](../components/policy-model.md)); the script constructs them in diff --git a/aiac/docs/specs/integration-test/policy-pipeline.md b/aiac/docs/specs/integration-test/policy-pipeline.md index 4b08eef8e..328b05583 100644 --- a/aiac/docs/specs/integration-test/policy-pipeline.md +++ b/aiac/docs/specs/integration-test/policy-pipeline.md @@ -4,145 +4,131 @@ > Integration-test specs live **one spec per test** under `docs/specs/integration-test/` > (a sibling of `components/`), and the master PRD's *Integration test specifications* section > ([../PRD.md](../PRD.md)) is the index of them. This is the **policy-pipeline** integration test — -> the full identity→policy pipeline — not the definition of integration testing in general, and not -> the only integration-test PRD. +> the full identity→policy→**enforcement** pipeline — not the definition of integration testing in +> general, and not the only integration-test PRD. ## Location `aiac/test/integration/test_policy_pipeline.py` — a pytest module marked `@pytest.mark.integration`. -It imports two shared modules: `aiac/test/integration/scenario.py` — the canonical `github-agent` -scenario as pure data (one of the role→access fact sources the *Further Notes* mandate — the pair-lists, -alongside the *Scenario* table and both `policy.md` variants) — and `aiac/test/integration/launcher.py` -— the shared `uvicorn` subprocess-lifecycle helpers. It also ships a new -`aiac/test/integration/probe.rego` — a small standalone Rego module used only as the outbound -verification query (see *[What it does](#what-it-does)*). The `5.2` launcher -`test/pdp/policy/generate_rego.py` was refactored onto the same `launcher.py` + `scenario.py` so the two -launchers cannot drift. +It imports two shared modules: `aiac/test/integration/scenario_uc1.py` — the canonical `github-agent` +scenario as pure data (the role→access truth table the *Expected output* renders — the pair-lists, +expressed over the **discovered, workload-prefixed** names `github-tool.source-read`, +`github-agent.source_operations`, …) — and `aiac/test/integration/uc1_onboard.py` — the shared live +harness (Keycloak provisioning/cleanup, the `POST /apply/service/{id}` onboard trigger, the outbound +token-exchange-leg prep, the bundle-convergence poll, and the live decision oracle + probes). The +harness in turn builds on `aiac/test/integration/launcher.py`'s live-cluster half (`kubectl` wrappers, +`port_forward`, `resolve_pod`, `mint_token`, `inbound_probe` / `outbound_probe`, `inbound_outcome` / +`outbound_outcome`, `poll_until`, and the skip gates). There is **no** standalone Rego module and **no** +`opa` binary here anymore: the evaluator is the deployed AuthBridge OPA plugin (see *[What it +does](#what-it-does)*). ## Description -A `@pytest.mark.integration` test that drives the **whole identity→policy pipeline** — -**Keycloak → PRB → PCE → OPA Policy Writer** — end-to-end, then **asserts** the generated Rego decides -correctly by running the standalone `opa eval` binary as its verification oracle. The `.rego` files are -still left on disk per policy variant, so the test doubles as the eyeball workflow: running the test -*is* the eyeball. There is no separate standalone script. - -The generated Rego is the **artifact under test** — the LLM/PCE that produced it might be wrong — so the -test never trusts it. Instead it feeds `opa eval` requests derived from the **scenario spec (the -intended policy)** and asserts the real Rego admits/denies each one as the scenario truth table -requires. A mismatch fails the test and names the exact cell. - -This is the same *flavor* as the PDP Policy Writer launcher -([pdp-policy-writer.md](pdp-policy-writer.md), issue `testing/5.2-pdp-writer-integration-test.md`) but -**broader**: where `5.2` hand-builds a `PolicyModel` in Python and POSTs it to the OPA stub — -deliberately bypassing Keycloak, the PRB, and the PCE — this test provisions a **live Keycloak** realm, -calls the real **Policy Rules Builder (PRB)** to map roles→scopes with a real LLM, then calls the real -**Policy Computation Engine (PCE)** to build the `PolicyModel` and drive the **OPA Policy Writer** to -emit Rego. Nothing is mocked; the only shortcut is that the OPA target is the filesystem stub -([../components/pdp-policy-writer-opa.md](../components/pdp-policy-writer-opa.md) §1.14) rather than the -Kubernetes-CR implementation, so the output is `.rego` files instead of a patched -`AuthorizationPolicy` CR — identical to `5.2`. - -Because it needs a live Keycloak and a real LLM, it is `@pytest.mark.integration` and stays out of the -default unit-test run (`-m "not integration"`); it additionally `pytest.skip`s when no `opa` binary is -found. +A `@pytest.mark.integration` test that drives the **whole identity→policy→enforcement pipeline** — +**Keycloak → UC-1 onboarding → PRB → PCE → OPA Policy Writer → AuthBridge OPA plugin** — end-to-end, +then **asserts** that the **enforced decision** is correct by driving a **real HTTP request through +AuthBridge** and reading the **deployed OPA plugin's** allow/deny. Nothing is mocked or dumped; the +artifact under test is the *enforced decision*, not a file on disk. + +The generated policy is the **artifact under test** — the LLM/PCE that produced it might be wrong — so +the test never trusts it. Instead it sends requests derived from the **scenario spec (the intended +policy)** and asserts the real plugin admits/denies each one as the scenario truth table +(`scenario_uc1.py`) requires. A mismatch fails the test and names the exact `subject[ / tool]` cell. + +This is the **umbrella full-matrix e2e** for the fixed `github-agent` scenario. It onboards **both** the +`github-agent` and the `github-tool` through the real in-cluster UC-1 Controller +(`POST /apply/service/{id}`, which upserts the `AuthorizationPolicy` CR on the live Kubernetes API), +enables the outbound token-exchange leg, waits for `bundle-service` + the AuthBridge OPA sidecars to +recompose and reload the bundle, then asserts the **full happy-path matrix + negative controls** over +the fully onboarded stack. Both gates are exercised through AuthBridge's own parsers: `jwt-validation` +builds `input.identity` on the inbound leg; `token-exchange` + `mcp-parser` build the outbound +`input.identity` + `input.mcp.params.name` (the **bare** tool name) — so the test never hand-builds an +input document and there is no standalone probe module. + +Where this sits vs. the UC-1 ladder ([uc1-onboarding-pipeline.md](uc1-onboarding-pipeline.md)): rungs +1–3 isolate onboarding-**order** properties (agent-only; agent→tool; tool→agent + order-independence); +this module is the **full matrix + negative controls** over the fully onboarded stack. Both share the +same live stack — the `rossoctl` realm and the deployed `team1` workloads — so there is exactly one +deployed pipeline to enforce against; the former explicit-vs-abstract two-policy equivalence check is +therefore **deferred to the two-policy rung** `testing/5.4.4` (only one `policy.md` is mounted on the +live stack). + +Because it needs a live rossoctl/Kind cluster with the AuthBridge OPA pipeline wired in, a real LLM, +and Keycloak admin creds, it is `@pytest.mark.integration` and stays out of the default unit-test run +(`-m "not integration"`); it **skips cleanly** when the cluster is not wired or the env is unset (it +never false-passes). ### What it does -The pipeline (provision → PRB → PCE → OPA) is driven **once per `policy.md` variant** — `explicit` and -`abstract` — each writing into its own `rego_out/policy_pipeline//` directory (a sibling of -the UC-1 ladder's `rego_out/uc1/`). `opa eval` then asserts the -scenario truth table against **each** variant's Rego (step 7). Steps 1–6 below describe one such run. - -1. **Set service URLs in env before importing the aiac libraries.** Export `AIAC_PDP_CONFIG_URL`, - `AIAC_POLICY_MODEL_STORE_URL`, `AIAC_PDP_POLICY_URL`, and `KEYCLOAK_REALM` *before* importing the aiac - libraries — the libraries read env at import time. This is the pattern - `test/pdp/policy/generate_rego.py` already follows. (The PCE resolves its realm via - `Configuration.for_default_realm()`, the single source of truth reading `KEYCLOAK_REALM`; the - former `AIAC_REALM` is retired.) -2. **Spawn the three services as `uvicorn` subprocesses** (no Docker) and poll each `GET /health` - until ready, with a bounded timeout: - - IdP Configuration Service — `aiac.idp.service.configuration.keycloak.main:app` on `7071`. - - Policy Model Store — its ASGI app on `7074`, with `SERVICEPOLICY_DB_PATH` pointed at a fresh temp dir. - - OPA Policy Writer — `aiac.pdp.service.policy.opa.main:app` on `7072`, with `REGO_OUTPUT_DIR` - (pointed at the current variant's `rego_out/policy_pipeline//`) and the Policy Model Store DB path in its env. -3. **Provision Keycloak** (idempotent — delete-if-exists the realm first, then create): - - via **`python-keycloak` `KeycloakAdmin`** (test fixture): create realm `AIAC_TEST_REALM`; create - users `dev-user`, `test-user`, and `devops-user`; create realm roles `developer`, `tester`, and - `devops`; assign roles to users (`devops-user`→`devops`, which maps to **no** agent/tool scope — - the inbound deny case, see *[Scenario](#scenario)*); create the `github-agent` and `github-tool` - clients with the descriptions in - *[Scenario inputs](#scenario-inputs-prb-functional-inputs)* and with the `client.type` - client attribute set to the plain string `"Agent"` / `"Tool"` respectively, so `Service` type - resolution tags them from the attribute (not from description prose). Set the type via the product - surface `Configuration.set_service_type(service, type)` (`POST /services/{id}/type`) or by writing - the `client.type` attribute directly at client create. The attribute value is a plain string, - **not** a list — a list fails the `in ("Agent","Tool")` check, resolves the type to `None`, and - yields empty pipeline output. - - via the **aiac IdP `Configuration` library** (the real product surface the PCE reads back): create - the client roles (`source_operations`, `issue_operations`) and scopes (`source-access`, `issues-access`, - `source-read`, `source-write`, `issues-read`, `issues-write`) with the descriptions in - *[Scenario inputs](#scenario-inputs-prb-functional-inputs)*, and map roles→services and - scopes→services so `get_services_by_role` / `get_services_by_scope` and `get_service().roles` / - `.scopes` resolve correctly. -4. **Read-back type guard** — after provisioning, call `Configuration.get_service` for both clients and - assert each resolved `.type` (`github-agent` ⇒ `Agent`, `github-tool` ⇒ `Tool`) **before** spawning - the pipeline; abort with a clear message otherwise. This is a provisioning sanity check on the - `client.type` attribute, distinct from the step-7 Rego-decision assertions. -5. **Proto-UC1 orchestration** — run the three PRB mappings against a pinned LLM (`temperature=0`) and - concatenate the results into one `list[PolicyRule]`: - - **(a)** `build_scope_rules(user_roles, agent_scope)` per agent scope → user→agent-scope rules. - - **(b)** `build_scope_rules(user_roles, tool_scope)` per tool scope → user→tool-scope rules. - - **(c)** `build_role_rules(agent_role, tool_scopes)` per agent role → agent-role→tool-scope rules. - - Concatenate into a single `list[PolicyRule]` and call - `aiac.policy.computation.engine.compute_and_apply(rules, override=False)` against a **fresh** Policy - Store. The PCE resolves the IdP relationships, builds the `github-agent` model (with `agent_roles` / - `agent_scopes`; mapping (b) routed into `outbound_subject_rules`; and **no** `github-tool` model), - writes it to the store, and pushes it to the OPA stub. -6. **Terminate the three subprocesses in `finally`.** The realm and the `.rego` files are left in - place for eyeballing. -7. **Assert the truth table with `opa eval`.** Once both variants' Rego is on disk, evaluate a matrix of - **(request JSON, rego file)** tuples with the standalone `opa` binary and hard-assert each decision - against the scenario truth table (see *[Expected output](#expected-output)*): - - **`opa` discovery** — `$OPA_BIN` → else `shutil.which("opa")` → else `pytest.skip("opa not - found")`. Missing `opa` skips (does not fail) the suite. - - **Inbound** — one node per `(variant × subject)`. Request `{"subject": }` (source omitted, so - the generated `source_ok` passes) is evaluated against the real - `data.authz.github_agent.inbound.allow`. Coarse "can this user reach the agent at all" — there is - no intent field. - - **Outbound** — one node per `(variant × subject × function_name)`, where `function_name` is the - agent's operation (a tool scope). Because the generated `allow` / `subject_ok` are existential and - ignore any scope, the outbound decision is evaluated by a **probe query**, - `data.probe.outbound.allow` (defined in `test/integration/probe.rego`), which binds - `input.function_name` against the generated data maps and requires **both** the user→tool gate and - the agent→tool gate to admit the function. Request shape `{"subject", "target", "function_name"}`. - - **Soft match** `function_name`↔scope — the probe compares names by splitting **both** on `[._-]+`, - lowercasing, and comparing as **sets** (token-set equality): `source.read`, `read_source`, and - `Source-Read` all match `source-read`; bare `source` matches nothing. - - The expected verdict for every cell is **computed from** the scenario pair-lists - (`INBOUND_PAIRS` / `OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_PAIRS` in `scenario.py`), not from a second - hand-maintained copy — a wrong LLM/PCE mapping therefore fails the test. A failing node names the - exact `variant / subject / function_name` cell. -8. **Assert grant-set equivalence (semantic, beyond the decision oracle).** The `opa eval` matrix in - step 7 is deliberately coarse: inbound `allow` only checks "reaches *some* agent scope," and the - agent→tool gate covers all four scopes so only the user gate discriminates — so a **verdict-neutral** - mapping error (a missing or spurious `(role, scope)` grant) passes step 7 unseen. To close that gap - the test also captures the PRB's `list[PolicyRule]` per variant and asserts, as order-independent - `(role, scope)` **sets** per gate, that **each variant equals the `scenario.py` truth table** and - **the two variants equal each other**. This compares grant *sets*, not Rego text (formatting/ordering - may differ; the grant set may not). This is what enforces the *both variants reproduce the same Rego* - intent stated in *Further Notes*. +A single session fixture (`uc1.onboarded_stack([AGENT_WORKLOAD, TOOL_WORKLOAD])`) drives the whole +identity→policy→enforcement pipeline once; the individual tests then assert the real plugin's decisions +over the fully onboarded stack. + +1. **Skip gates first — before any cluster mutation.** `require_pipeline` skips cleanly if the live + AuthBridge OPA pipeline is not wired (no `kubectl`, `AuthorizationPolicy` CRD not served, + `bundle-service` not Running, the `opa` plugin not on **both** legs, or a workload pod not Running); + `require_env_or_skip` skips if `KEYCLOAK_URL` / admin creds are unset. The suite never false-passes. +2. **Clean slate.** Delete the agent's `AuthorizationPolicy` CR, `cleanup_provisioned` (drop the + `github-agent.` / `github-tool.`-prefixed realm roles + client scopes UC-1 provisions), and + `clear_policy_store` (drop persisted SPMs from the in-cluster Policy Store, whose SQLite outlives + redeploys). Then `provision_realm_and_users` idempotently ensures the scenario's three users + + realm roles (`developer` / `tester` / `devops`) with the descriptions the PRB reads (the fixture + provisions these; UC-1 does not), `verify_subject_mapper` confirms the realm's `username → sub` + mapper + Direct Access Grants are in place (else skip), and `ensure_agent_policy` mounts the single + abstract `policy.md` on the Controller pod. +3. **Onboard both workloads through the real in-cluster UC-1 Controller.** `POST /apply/service/{id}` + for the `github-tool` and the `github-agent`, where `{id}` is the client's **internal Keycloak + UUID** (`resolve_service_id`), not the slash-bearing `clientId`. UC-1 classifies each service, + reads the MCP `tools/list` / AgentCard skills, provisions the **workload-prefixed** scopes + (`github-tool.{source-read, source-write, issues-read, issues-write}`) and the agent's **one + operator role per skill** (`github-agent.{source_operations, issue_operations}`), maps roles→scopes + via the real PRB (real LLM, `temperature=0`), and the Controller calls + `compute_and_apply(rules, override=False)`; the OPA Policy Writer upserts the agent's + `AuthorizationPolicy` CR on the live Kubernetes API. +4. **Enable the outbound token-exchange leg (Part B).** `ensure_github_tool_route` adds the + `github-tool` outbound route to `authproxy-routes`, `grant_exchange_scope` grants the agent's + Keycloak client the `github-tool` audience scope as optional, and `restart_agent` restarts the + agent so it reloads the route (and its OPA sidecar re-fetches the recomposed bundle). Without this + the outbound call would pass through unexchanged and never reach OPA. +5. **Wait for the pipeline to converge.** `poll_until` drives real decisions until this run's CR is + reflected: `dev-user` reaches the agent (inbound allow), `devops-user` is blocked (inbound deny — + proving the restrictive client-scoped gate is live, not the allow-all baseline), and `dev-user`'s + outbound `source-read` has reached its terminal `allow` (waiting out the post-restart + token-exchange window). Keycloak cleanup + CR delete run **before and after**; the clients stay + registered as before. +6. **Assert the enforced decisions over the full matrix.** Each test mints a fresh user token and + sends a **real HTTP request through AuthBridge**: + - **Inbound** — one node per `subject`. A request as `subject` reaches the agent iff the user's + role may reach some agent scope. `jwt-validation` builds `input.identity`; the real OPA plugin + decides (200 → `allow`, 403 → `deny`). + - **Outbound** — one node per `(subject × bare tool name)`. A real MCP `tools/call` for the **bare** + tool through AuthBridge's forward proxy (token-exchange → OPA) is allowed iff **both** the subject + and some agent role are entitled to that tool's scope (the per-scope two-gate AND). `mcp-parser` + surfaces `input.mcp.params.name`; a denial is a JSON-RPC error frame (`error.data.plugin: "opa"`) + at HTTP 200 that the harness classifies as `deny`. + - The expected verdict for every cell is **computed from** the `scenario_uc1.py` pair-lists + (`INBOUND_PAIRS` / `OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_TARGET_PAIRS`), keyed on the **bare** + runtime tool names AuthBridge sends — not a second hand-maintained copy. A wrong LLM/PCE mapping + therefore fails the exact `subject / tool` cell. +7. **Negative controls.** An otherwise-allowed subject (`dev-user`) invoking a tool name in **no** + allowed scope (`nonexistent-tool`) is denied — the outbound gate matches `input.mcp.params.name` + exactly, so an unknown tool falls through to deny-by-default. A bogus, destructive-sounding tool + name (`delete_everything`) matching no discovered scope is likewise denied — guarding against an + over-broad match letting an unrecognized operation through. +8. **Oracle-contract tests (fixture-independent).** A handful of tests need neither the cluster nor + the env: they assert the intended matrix itself — `expected_inbound` / `expected_outbound_bare` + over the scenario pair-lists — the tracer bullet. If these are wrong, every live assertion is + meaningless. ## Expected output -The test passes when `opa eval` decides every cell of the scenario truth table as follows, for **both** -policy variants. Verdicts are **computed from** the `scenario.py` pair-lists (`INBOUND_PAIRS` / -`OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_PAIRS`), not a hand-maintained copy — this table is the human- -readable rendering of them. +The test passes when the deployed OPA plugin decides every cell of the scenario truth table as follows. +Verdicts are **computed from** the `scenario_uc1.py` pair-lists (`INBOUND_PAIRS` / +`OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_TARGET_PAIRS`), keyed on the bare runtime tool names; this table +is the human-readable rendering of them. `USERS`: `dev-user`→`developer`, `test-user`→`tester`, `devops-user`→`devops`. -**Inbound allow** (`data.authz.github_agent.inbound.allow`, from `INBOUND_PAIRS`, user-role→agent-scope): +**Inbound allow** (the real plugin's inbound decision, from `INBOUND_PAIRS`, user-role→agent-scope): | Subject | Inbound | |---|---| @@ -150,8 +136,8 @@ readable rendering of them. | test-user | ✅ | | devops-user | ❌ | -**Outbound allow(subject, function)** (`data.probe.outbound.allow`, from `OUTBOUND_SUBJECT_PAIRS` -user→tool; the agent→tool gate covers all four scopes, so the user gate discriminates): +**Outbound allow(subject, tool)** (the real plugin's outbound decision, per-scope two-gate AND over +the **bare** tool names; the agent reaches all four tool scopes, so the user gate discriminates): | | source-read | source-write | issues-read | issues-write | |---|---|---|---|---| @@ -159,211 +145,210 @@ user→tool; the agent→tool gate covers all four scopes, so the user gate disc | test-user | ❌ | ❌ | ✅ | ✅ | | devops-user | ❌ | ❌ | ❌ | ❌ | -Alongside the assertions, each variant leaves exactly **two** files on disk in its -`rego_out/policy_pipeline//` for eyeballing: +Plus the negative controls: `dev-user` invoking `nonexistent-tool` or `delete_everything` is **denied** +(deny-by-default; no accidental allow on an unknown tool name). -- `github_agent.inbound.rego` — package `authz.github_agent.inbound`; the **user→agent** gate. - `subject_roles` = `{dev-user: [developer], test-user: [tester]}`; `agent_scopes` populated. - (`devops-user` holds `devops`, which maps to no agent scope, so it is absent from `subject_roles` and - denied inbound.) -- `github_agent.outbound.rego` — package `authz.github_agent.outbound`; `allow if { subject_ok; - target_ok }`. Its **`subject_ok`** is the new **user→tool** gate (mapping (b), grouped from - `outbound_subject_rules` into `subject_role_scopes`, matched against - `target_scopes[input.target]`); its **`target_ok`** is the **agent→tool** gate (mapping (c), over - `agent_roles` × `agent_role_scopes`). `agent_roles` and `target_scopes` are populated. - -Explicitly **no** `github_tool.*.rego` — the pipeline emits no tool model. Eyeball both files against -the **ID-only** package shapes in -[../components/pdp-policy-writer-opa.md](../components/pdp-policy-writer-opa.md) -(§ *Rego package structure*), the same source of truth `5.2` uses. +The pipeline emits an agent `AuthorizationPolicy` CR only — explicitly **no** standalone tool policy +(the tool is a pure target; no rules are written for it directly). This fixture is **ALLOW-only** (see +*[Further Notes](#further-notes)*): the single `policy.md` carries only positive fine-grained grants and +no exclusivity / prohibition prose, and the entity/role descriptions stay deny-neutral, so the +DENY-aware PRB emits **no** `DENY` rules. Extending the fixture to exercise the PRB's ALLOW+DENY path +(explicit-prohibition prose and/or description-driven denies, plus a grant-set assertion that compares +**deny** sets) is deferred to the sibling "for later" issue (#142, ALLOW+DENY policy support); see the +*Deny-extraction interaction* note under *[Further Notes](#further-notes)*. ## Scenario -A single agent + tool + three users, fixed so the generated Rego is reproducible and reviewable by -inspection. This is the same canonical `github-agent` worked example as `5.2`, driven end to end -through the real pipeline rather than a hand-built `PolicyModel`, plus a third `devops-user` that -exercises the deny-by-default path. +A single agent + tool + three users, fixed so the enforced decisions are reproducible and reviewable. +This is the canonical `github-agent` worked example, driven end to end through the real UC-1 onboarding +pipeline and enforced by the deployed OPA plugin, plus a third `devops-user` that exercises the +deny-by-default path. Entities are **discovered** by UC-1 (tool scopes from the MCP `tools/list` +manifest, agent roles/scopes from the AgentCard skills), so every scope is **workload-prefixed**. | Element | Value | |---------|-------| -| Realm | `AIAC_TEST_REALM` (default `aiac-pp`) | -| Agent | `github-agent` (client roles `source_operations`, `issue_operations`; scopes `source-access`, `issues-access`) | -| Tool | `github-tool` (scopes `source-read`, `source-write`, `issues-read`, `issues-write`) | +| Realm | `AIAC_TEST_REALM` (must match the deployed stack's `KEYCLOAK_REALM`; default `rossoctl`) | +| Agent | `github-agent` — **discovered** per-skill operator roles `github-agent.source_operations`, `github-agent.issue_operations` (mirroring the scopes); scopes `github-agent.source_operations`, `github-agent.issue_operations` (from AgentCard skills) | +| Tool | `github-tool` — **discovered** scopes `github-tool.{source-read, source-write, issues-read, issues-write}` (from MCP `tools/list`) | | Users | `dev-user` (role `developer`), `test-user` (role `tester`), `devops-user` (role `devops`) | | `developer` | source read/write + issues read | | `tester` | issues read/write | -| `devops` | no access (inbound deny; denied every outbound function) | +| `devops` | no access (inbound deny; denied every outbound tool) | -Role → access (confirmed with the user; the fixed facts that both `policy.md` versions below and the -`scenario.py` pair-lists must agree with — the generic descriptions are not part of this triad): +Role → access (the fixed facts the single `policy.md` and the `scenario_uc1.py` pair-lists must agree +with — the generic descriptions are not part of this triad): - `developer` — source read/write, issues read. - `tester` — issues read/write. - `devops` — no access. Conveyed by the **role description only** — it is absent from every pair-list - and both `policy.md` variants are **unchanged** (deny-by-default), so it is denied inbound and on - every outbound function. + and from the `policy.md` (deny-by-default), so it is denied inbound and on every outbound tool. ## Configuration (env) +The suite reads its config from `test/integration/.env` (gitignored); source it before running +(`set -a; . test/integration/.env; set +a`). The drivers read these: + | Variable | Purpose | Default | |----------|---------|---------| +| `KUBECONFIG` | Kubeconfig for the live rossoctl/Kind cluster | — (required) | | `KEYCLOAK_URL` | External Keycloak base URL | — (required) | +| `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin creds (user/realm-role provisioning + cleanup) | — (required) | | `KEYCLOAK_ADMIN_REALM` | Realm the admin creds live in | `master` | -| `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin creds | — (required) | -| `AIAC_TEST_REALM` | Realm the test provisions | `aiac-pp` | -| `KEYCLOAK_REALM` | Realm the PCE reads back, via `Configuration.for_default_realm()` (single source of truth; = `AIAC_TEST_REALM`) | `aiac-pp` | -| `AIAC_PDP_CONFIG_URL` | IdP Configuration Service base URL (set before import) | `http://127.0.0.1:7071` | -| `AIAC_POLICY_MODEL_STORE_URL` | Policy Model Store base URL (set before import) | `http://127.0.0.1:7074` | -| `AIAC_PDP_POLICY_URL` | OPA Policy Writer base URL (set before import) | `http://127.0.0.1:7072` | -| `REGO_OUTPUT_DIR` | Base dir the OPA stub subprocess writes `.rego` to; the test points it at `rego_out/policy_pipeline//` per variant and leaves the files on disk | operator-chosen local dir | -| `SERVICEPOLICY_DB_PATH` | Policy Model Store DB path for the subprocess (fresh temp dir) | temp | -| `AIAC_POLICY_FILE` | PRB whole-file policy — path to the `policy.md` variant fed to the PRB; the test sets it per variant (`policy.explicit.md`, `policy.abstract.md`) | `/etc/aiac/policy.md` | -| `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` | PRB LLM (pinned `temperature=0`) | — (required) | -| `OPA_BIN` | Path to the standalone `opa` binary used as the verification oracle; else `PATH` (`shutil.which`), else the test `pytest.skip`s | — (optional; PATH lookup) | - -> When the test is written, confirm the Policy Model Store's ASGI import path and its DB-path env-var -> name against the Policy Model Store component spec / issue — `SERVICEPOLICY_DB_PATH` is the placeholder used -> here; use the real one. `AIAC_POLICY_FILE` selects which `policy.md` variant (see -> *[Scenario inputs](#scenario-inputs-prb-functional-inputs)*) the PRB reads. +| `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` | PRB LLM (pinned `temperature=0`), consumed by the in-cluster AIAC pod | — (required) | +| `AIAC_TEST_REALM` | Realm the tests resolve/provision against. **Must match the deployed AIAC stack's `KEYCLOAK_REALM`** — the in-cluster Controller resolves the onboarding trigger in *its own* realm | `rossoctl` | +| `AIAC_DEMO_NAMESPACE` | Namespace the demo workloads are deployed in (precondition) | `team1` | +| `AIAC_TRUST_DOMAIN` | SPIFFE trust domain the operator registers the demo workloads under | `localtest.me` | + +> Cluster/stack knobs the harness also honors, with defaults matching the deployed stack (rarely +> overridden): the Controller target/namespace/ports (`AIAC_CONTROLLER_*`, default +> `svc/aiac-agent-service` in `aiac-system` on `7070`), the Policy Store target +> (`AIAC_STORE_*`, `svc/aiac-policy-model-store-service` on `7074`), the policy ConfigMap/mount +> (`AIAC_POLICY_CONFIGMAP` / `AIAC_POLICY_MOUNT_PATH`), and the timeouts +> (`AIAC_ONBOARD_TIMEOUT`, `AIAC_BUNDLE_TIMEOUT`, `AIAC_BUNDLE_POLL_INTERVAL`). ## Runbook -Runnable only once the pipeline fixes (handoffs 01 + 02, P1–P5) have landed, and requires a live -Keycloak, a real LLM, and an `opa` binary on `PATH` (or `$OPA_BIN`). +Runnable against a live rossoctl/Kind cluster (operator + Keycloak + SPIRE) with the AuthBridge OPA +pipeline wired into **both** legs, `github-agent` + `github-tool` **deployed and registered** into +`AIAC_TEST_REALM`, and a real LLM in-pod. Stand the pipeline up with `k8s/opa-kind-enable.sh`; the full +prerequisites, wiring, and manual probe commands are in `k8s/opa-kind-runbook.md`. ```bash -# env: KEYCLOAK_URL + admin creds + LLM_* set; realm defaults to aiac-pp; opa on PATH or $OPA_BIN +k8s/opa-kind-enable.sh # one-time: wire the OPA plugin into both legs of the Kind cluster +set -a; . test/integration/.env; set +a .venv/bin/pytest test/integration/test_policy_pipeline.py -m integration -v -# ~30 parametrized nodes (variant × subject inbound; variant × subject × function_name outbound). +# Parametrized over subject inbound + (subject × bare tool) outbound + negative controls. # A failing node names the exact cell, e.g.: -# test_outbound[abstract-test-user-source-read] — expected deny, opa allowed -# The generated Rego is left on disk per variant for eyeballing: -# rego_out/policy_pipeline/explicit/github_agent.{inbound,outbound}.rego -# rego_out/policy_pipeline/abstract/github_agent.{inbound,outbound}.rego -# (no github_tool.*.rego in either) +# test_outbound[test-user-source-read] — expected deny, plugin allowed ``` -The suite `pytest.skip`s when no `opa` binary is found (`$OPA_BIN` → `PATH`). Eyeball the persisted -Rego against the adjusted package shapes in -[../components/pdp-policy-writer-opa.md](../components/pdp-policy-writer-opa.md); optionally inspect the -Policy Model Store DB and the provisioned Keycloak realm. +Without `-m integration` the suite is not collected; when the cluster is not wired or the env is unset +it **skips cleanly** (it never false-passes). To eyeball the pipeline manually, follow +`k8s/opa-kind-runbook.md` (Part A inbound, Part B outbound) and inspect the upserted +`AuthorizationPolicy` CR and the provisioned Keycloak realm. ## Testing Decisions -- **Highest seam available, verified by a real oracle.** Real libraries + real services + real Keycloak - + real LLM. The test drives the pipeline through its real surfaces — the IdP `Configuration` library, - the PRB entry points (`build_scope_rules` / `build_role_rules`), and the PCE's `compute_and_apply` — - and then verifies the real filesystem output with the standalone **`opa eval`** binary. The only - shortcut is the OPA filesystem stub (same as `5.2`). A good test here asserts only **external - behavior** — the policy *decisions* the generated Rego makes for scenario-derived requests — never the - internal Rego structure (which the OPA Policy Writer's own unit tests own). -- **Rego is the artifact under test; the scenario is the oracle.** The LLM/PCE that produced the Rego - might be wrong, so the expected verdicts are **computed from** the scenario pair-lists - (`INBOUND_PAIRS` / `OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_PAIRS`), not from a second hand-maintained - copy or from the Rego itself. A wrong role→scope mapping therefore fails the test at the exact cell. -- **Outbound needs a probe.** The generated `allow` / `subject_ok` are existential and ignore any - scope, so a raw query cannot answer "may this subject invoke *this* function." A small - `test/integration/probe.rego` (`data.probe.outbound.allow`) binds `input.function_name` against the - generated data maps and requires **both** the user→tool and agent→tool gates to admit it. Names are - compared by **token-set equality** (split on `[._-]+`, lowercased) so `source.read` / `read_source` / - `Source-Read` all match `source-read` while bare `source` matches nothing. -- **Attribute-based client typing + read-back guard.** Clients are typed by the `client.type` - attribute (plain string `"Agent"` / `"Tool"`), provisioned by the test — not by description keywords. - Because that attribute drives whether the PCE emits an agent model (and suppresses the tool model), - the test reads each service back via `Configuration.get_service` and asserts its `.type` before - running the pipeline, aborting on mismatch. This is a **provisioning** sanity check, distinct from the - Rego-decision assertions. -- **Self-contained subprocess lifecycle.** The test spawns IdP (7071), Policy Model Store (7074), and OPA - (7072) as `uvicorn` subprocesses, polls each `GET /health` before use, and tears them all down in - `finally`. Keycloak and the LLM are **external** (reached via env); `opa` is an external binary. -- **LLM nondeterminism, contained.** The PRB LLM is pinned to `temperature=0`, and the **explicit** - `policy.md` variant states each `(role, scope)` grant outright, so its mapping is stable. The - **abstract** variant leans on the LLM to expand prose + descriptions into concrete scopes; both - variants are asserted not only cell-by-cell via `opa eval` (step 7) but at the **grant-set** level - (step 8) — each variant's `(role, scope)` set must equal the truth table *and* the other variant's. - Grant-set equivalence catches the verdict-neutral under/over-grants the decision oracle hides. Some - model-dependence remains, which is why the suite is `@pytest.mark.integration`, out of default CI. -- **Prior art, shared not copied.** `test/pdp/policy/generate_rego.py` (the `5.2` launcher) established - the shape this test reuses — `uvicorn` subprocess spawn, `GET /health` poll, env-before-import - ordering, and `finally` teardown. Rather than duplicate it, that machinery lives in the shared - `test/integration/launcher.py`, and the fixed scenario lives in `test/integration/scenario.py`; - `generate_rego.py` was refactored onto both (its `.rego` output verified byte-identical to before the - refactor). The live-Keycloak pytest suite (`testing/5.1-integration-tests.md`) is the sibling - marker-gated, decision-asserting counterpart for the read-side services and is the prior art for the - `@pytest.mark.integration` + `opa eval` shape. +- **Highest seam available, verified by the real evaluator.** Real deployed workloads + real operator + + real UC-1 onboarding + real PRB/PCE + real Keycloak + real LLM, driven through the production + trigger (`POST /apply/service/{id}`) and enforced by the **deployed AuthBridge OPA plugin**. The + test asserts only **external behavior** — the allow/deny decisions the plugin makes for + scenario-derived requests — never internal policy structure (which the OPA Policy Writer's own unit + tests own). +- **The enforced decision is the artifact under test; the scenario is the oracle.** The LLM/PCE that + produced the policy might be wrong, so the expected verdicts are **computed from** the + `scenario_uc1.py` pair-lists, keyed on the bare runtime tool names — not from a second hand-maintained + copy or from the policy itself. A wrong role→scope mapping therefore fails the exact cell. +- **Both gates go through AuthBridge's own parsers.** Inbound `input.identity` is built by + `jwt-validation`; outbound `input.identity` + `input.mcp.params.name` (the **bare** tool name) are + built by `token-exchange` + `mcp-parser`. The test never hand-builds an input document and there is + no standalone probe module — the deployed plugin sees exactly what production sees. +- **Outbound needs the token-exchange leg live.** The outbound OPA gate is only reached if + `token-exchange` first intercepts + exchanges the agent's call to `github-tool`; Part B (route + + optional client scope + agent restart) enables it, and the fixture polls real decisions until it + settles before asserting. +- **Negative controls.** Unknown/bogus tool names (`nonexistent-tool`, `delete_everything`) must be + denied — guarding against an over-broad match or accidental allow on a name in no discovered scope. +- **Skip cleanly, never false-pass.** The suite skips (does not fail) when the pipeline is not wired + or the integration env is unset, and it skips before any cluster mutation. +- **LLM nondeterminism, contained.** The in-cluster PRB LLM is pinned to `temperature=0`, and the + single abstract `policy.md` leans on the LLM to expand prose + descriptions into concrete scopes; + the enforced decisions are asserted cell-by-cell against the truth table. Some model-dependence + remains, which is why the suite is `@pytest.mark.integration`, out of default CI. +- **Shared harness, one live stack.** The onboarding, Part-B prep, bundle-convergence poll, and live + decision oracle live in `test/integration/uc1_onboard.py` and are shared with the UC-1 ladder; the + fixed scenario lives in `test/integration/scenario_uc1.py`. Both suites enforce against the same + deployed pipeline (the `rossoctl` realm + the `team1` workloads), left in place across runs with + per-run cleanup of only the provisioned prefixed roles/scopes — neither suite deletes/recreates the + realm. ## Relationship to other integration tests This is **one** integration-test spec among several indexed by the master PRD ([../PRD.md](../PRD.md), § *Integration test specifications*). -- Same flavor as the **live-Keycloak pytest integration tests** (`testing/5.1-integration-tests.md`) — - both are `@pytest.mark.integration`, run outside the default unit run against live dependencies, and - assert on decisions. This test additionally uses `opa eval` as its oracle and skips when `opa` is - absent. -- **Broader than** the OPA-stub-only **PDP Policy Writer** launcher - ([pdp-policy-writer.md](pdp-policy-writer.md), `testing/5.2-pdp-writer-integration-test.md`): `5.2` - hand-builds a `PolicyModel`, exercises only OPA, and is still a write-only eyeball launcher; this test - adds Keycloak provisioning + PRB + PCE in front of the **same** OPA stub and **asserts** the resulting - decisions with `opa eval`. Both still leave `.rego` on disk against the same package shapes. +- **Umbrella sibling of the UC-1 onboarding ladder** ([uc1-onboarding-pipeline.md](uc1-onboarding-pipeline.md), + `testing/5.4.x`): identical scenario facts/tables and the **same** live enforcement loop (onboard + through the Controller → real HTTP through AuthBridge → deployed OPA plugin's allow/deny). The ladder + isolates onboarding-**order** properties across three rungs; this test is the **full happy-path + matrix + negative controls** over the fully onboarded stack. +- Same `@pytest.mark.integration` + live-enforcement flavor as `testing/5.1-integration-tests.md`; + runs outside the default unit run against live dependencies and skips cleanly when the cluster/env + is not wired. Tracking issue for this test: `testing/5.3-policy-pipeline-integration-test.md`. ## Out of Scope -- **Writing `test_policy_pipeline.py`, `probe.rego`, or any P1–P5 pipeline code** — this spec - *describes* the test; the test itself is written in a later session against the fixed pipeline - (tracked by `testing/5.3-policy-pipeline-integration-test.md` and the prerequisite issues). -- **The Rego generator, the canonical policy model, the PRB, and the PCE implementations** — specified - and unit-tested by their own components ([../components/pdp-policy-writer-opa.md](../components/pdp-policy-writer-opa.md), +- **Writing `test_policy_pipeline.py` or any pipeline code** — this spec *describes* the test; the + implementation is owned by `testing/5.3-policy-pipeline-integration-test.md` and the prerequisite + issues. +- **The Rego generator, the canonical policy model, the PRB, the PCE, and the AuthBridge OPA plugin + implementations** — specified and unit-tested by their own components + ([../components/pdp-policy-writer-opa.md](../components/pdp-policy-writer-opa.md), [../components/policy-model.md](../components/policy-model.md), [../components/policy-computation-engine.md](../components/policy-computation-engine.md), and the PRB - component spec), not here. In particular, the internal **structure** of the generated Rego is the - Policy Writer's concern; this test asserts only the **decisions** that Rego makes. -- **The Kubernetes-CR Policy Writer (1.13)** — this test targets the filesystem **stub** (1.14) only. -- **Default-CI wiring** — the test is `@pytest.mark.integration` and requires live Keycloak + LLM + an - `opa` binary, so it runs on demand, not in the default `-m "not integration"` unit run. + component spec), not here. This test asserts only the **enforced decisions**, never the internal + structure of the generated policy. +- **Deploying / registering the workloads and wiring the OPA pipeline** — preconditions + (`k8s/opa-kind-enable.sh`), not part of the test. +- **Two-policy explicit-vs-abstract equivalence** — deferred to the two-policy rung + `testing/5.4.4`; the live stack mounts a single `policy.md`. +- **Default-CI wiring** — the test is `@pytest.mark.integration` and requires a live cluster + + Keycloak + LLM, so it runs on demand, not in the default `-m "not integration"` unit run. ## Further Notes -- The scenario is deliberately fixed. The role→access facts are owned by **three** artefacts that must - agree: the *Scenario* table, **both** `policy.md` versions in *Scenario inputs*, and the - `scenario.py` pair-lists (`INBOUND_PAIRS` / `OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_PAIRS`). The +> **Note — ALLOW-only fixture for now (deny-extraction deferred to #142).** The Policy Rules Builder now +> emits explicit `DENY` rules from direct-prohibition / exclusivity prose (see +> [../components/aiac-agent/policy-rules-builder.md](../components/aiac-agent/policy-rules-builder.md), +> § *Deny extraction*). This fixture is deliberately kept **ALLOW-only for now** so the suite builds and +> passes under the DENY-aware PRB (split from #140; the ALLOW+DENY half is the sibling "for later" +> issue #142). The single `policy.md` therefore carries only positive grants — no `exclusively`, no +> `read-only`, no `no access to source` — and the entity/role descriptions stay deny-neutral, so the PRB +> emits **no** `DENY` rules and the enforced policy is allow-only. This keeps the two claims below +> intact: the descriptions stay generic and drop out of the fact triad, and `devops` stays the pure +> **deny-by-default / silence** exemplar. +> +> Exercising the PRB's ALLOW+DENY path against this fixture — explicit-prohibition prose, the +> description-driven denies the `tester` (*"…not in source"*) and `devops` descriptions would supply +> under the PRB's symmetric rule, and a grant-set assertion that compares **deny** sets as well as allow +> sets — is out of scope here and tracked by #142. Note that the enforced **verdicts** (the truth table) +> are the same either way: `tester` is denied source and `devops` is denied everywhere whether by +> explicit `DENY` or by deny-by-default; only the generated policy's **deny-map content** would differ. + +- The scenario is deliberately fixed. The role→access facts are owned by artefacts that must agree: + the *Scenario* table, the single `policy.md` (see *Scenario inputs*), and the `scenario_uc1.py` + pair-lists (`INBOUND_PAIRS` / `OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_TARGET_PAIRS`). The entity/role/scope **descriptions no longer encode those facts** — they are generic and functional and drop out of the fact triad; they must stay generic and simply not contradict the facts. If the - role→access facts change, update the *Scenario* table, both `policy.md` variants, and the pair-lists - together so the eyeballed output stays reviewable. + role→access facts change, update the *Scenario* table, the `policy.md`, and the pair-lists together. - The least-privilege **deny-by-default** directive is supplied by the PRB prompt itself (`_GRANT_ACCESS` in `agent/policy_rules_builder/prompts.py`), which prepends it — followed by the bundled generic baseline policy (`generic_policy.md`) — ahead of the scenario `policy.md` on every - call, so every policy decision gets it regardless of which variant is read. The **explicit** variant - still spells the directive out (its whole point is to state everything outright); the **abstract** - variant relies on the prompt and does not restate it — do not re-add it to the abstract variant. -- Two `policy.md` variants are shipped on purpose (see *Scenario inputs*): an **explicit** one and an - **abstract** one. `AIAC_POLICY_FILE` selects which the PRB reads, so a reviewer can compare the PRB's - output on explicit vs. abstract policy text against the same expected Rego. The abstract variant - carries **no** agent-capability bullet; it relies on the elaborated `source_operations` / - `issue_operations` role descriptions (provisioned into Keycloak) for mapping (c), so it survives - deny-by-default and both variants reproduce the same Rego. -- Descriptions are ≤255 characters and written **verbatim** into Keycloak; there is no shortened / - verbatim split. (Keycloak caps role and client descriptions at 255 chars, and the generic descriptions - are authored to stay within that cap.) + call, so every policy decision gets it. The abstract `policy.md` relies on the prompt and does not + restate the directive. +- The single mounted `policy.md` is **user-intent-only** (see *Scenario inputs*): it states only what + users may do and does **not** name the agent's operator roles. The agent's own capability (the + outbound target gate) comes from the generic rubric (`generic_policy.md`) applied to the operator-role + descriptions, not from naming those roles in the policy. Keeping the policy purely fine-grained and + positive is also what keeps the fixture ALLOW-only. +- Descriptions are ≤255 characters and written **verbatim** into Keycloak (Keycloak caps role and + client descriptions at 255 chars, and the generic descriptions are authored to stay within that cap). - The `devops` role's **zero access** is conveyed by its **role description only**. It is absent from - every pair-list (`INBOUND_PAIRS` / `OUTBOUND_SUBJECT_PAIRS` / `OUTBOUND_PAIRS`) and both `policy.md` - variants are **unchanged**, so deny-by-default alone denies it inbound and on every outbound function — - which is precisely what the truth table's `devops-user` row asserts. Because `devops-user` lives in - the shared `scenario.py`, it also appears in the `5.2` launcher's eyeball output (denied everywhere); - that is intentional and keeps the two launchers consistent. + every pair-list and from the `policy.md`, so deny-by-default alone denies it inbound and on every + outbound tool — which is precisely what the truth table's `devops-user` row asserts. -## Blocked-by +## Prerequisites -The pipeline can only produce correct output once handoffs 01 (P1, P3) and 02 (P2, P4, P5) land; those -are **resolved**, so this test is ready to be written. Component prerequisites: +The live enforcement loop is in place (drivers, `k8s/opa-kind-*` scripts + runbook, and the +AuthBridge OPA plugin), so this test is ready to run once the pipeline is stood up. It requires a wired +cluster (`k8s/opa-kind-enable.sh`); the components it exercises end-to-end are specified/unit-tested by +their own issues: - PRB — `agent/3.20-policy-rules-builder.md` - PCE — `policy/pce/8.10-policy-computation-engine.md` - Policy model — `policy/model/8.1-policy-model.md` -- OPA filesystem stub — `pdp-policy-writer/1.14-pdp-policy-writer-opa-stub.md` - Rego package generator — `pdp-policy-writer/1.10-rego-package-generator.md` - pdp-policy library — `library/pdp/8.9-pdp-policy-library-rename.md` - Policy Model Store library / service — `policy/store/8.7-policy-store-library.md` / @@ -371,103 +356,58 @@ are **resolved**, so this test is ready to be written. Component prerequisites: ## Scenario inputs (PRB functional inputs) -These are **functional** inputs — the LLM reads the entity/role/scope descriptions and the `policy.md` -to produce the role→scope mappings, so they are part of the fixed scenario, not decoration. Confirmed -with the user; keep them in sync with the *Scenario* table (see *Further Notes*). - -### Entity descriptions +These are **functional** inputs — the PRB reads the entity/role/scope descriptions and the `policy.md` +to produce the role→scope mappings, so they are part of the fixed scenario, not decoration. The +entity/role descriptions and the agent/tool scopes are **discovered by UC-1** from the deployed +workloads (MCP `tools/list`, AgentCard skills); the realm roles and the `policy.md` are provisioned by +the fixture. Keep them in sync with the *Scenario* table (see *Further Notes*). -The descriptions are **generic and keyword-free** — they describe what each entity/role/scope *does*, -carry no policy grant ("Resolves to…") and no owning-client naming, and stay within Keycloak's 255-char -cap so they are written verbatim (no shortened renderings). Client `type` is **not** inferred from -description prose: the test sets each client's `client.type` attribute directly — as a plain string -`"Agent"` / `"Tool"` written onto the client — rather than discovering it from a `rossoctl.io/type` -label, so `Service` type resolution ([../../../src/aiac/idp/configuration/models.py:79-87](../../../src/aiac/idp/configuration/models.py#L79-L87)) -tags each client from the attribute without touching the TEMP description-keyword fallback. +### Discovered entities (what UC-1 provisions) -**`github-agent`** — client (Agent): -> Autonomous Agent acting on a user's behalf against source repositories and an issue tracker. It -> inspects and changes repository source contents and reads, creates, and updates issues and their -> threads. +Descriptions are **generic and keyword-free** and stay within Keycloak's 255-char cap (written +verbatim). Client `type` is set by UC-1 from the `rossoctl.io/type` label — not inferred from +description prose. -**`github-tool`** — client (Tool): -> Capability provider Tool for source repositories and an issue tracker. It performs read and write -> operations on repository source contents and on issues and their comment threads. +- **`github-tool`** (Tool) → scopes, from MCP `tools/list` (verbatim descriptions): + - `github-tool.source-read` — "Read source repository contents: file listings and file bodies. Read-only." + - `github-tool.source-write` — "Create, modify, or delete source repository contents; commit file changes." + - `github-tool.issues-read` — "Read issues and their comment threads. Read-only." + - `github-tool.issues-write` — "Create and update issues: open, edit, comment, and close." +- **`github-agent`** (Agent) → **one operator role per skill** (name + description mirror each scope) + + scopes from the AgentCard skills: + - `github-agent.source_operations` — "Browse and search code; read, create, and modify repository file contents, branches, and commits." + - `github-agent.issue_operations` — "Read, search, create, and update issues, comments, sub-issues, and pull requests." -**`developer`** — realm role (user): -> Developer — an engineering user who develops the source codebase (writing and maintaining code) and -> fixes code defects reported in the issue tracker; works primarily in source and consults issues for -> defect reports. + The operator roles carry the same descriptions as the scopes they mirror; those descriptions drive + the PRB capability-match that populates the agent→tool gate. -**`tester`** — realm role (user): -> Tester — a quality-assurance user who verifies software quality and tracks defects through the issue -> tracker: filing, triaging, and updating issue reports; works in the issue tracker, not in source. +### Realm roles (provisioned by the fixture) -**`devops`** — realm role (user): -> DevOps — an operations user who manages deployment infrastructure and runtime environments; does not -> author source code and does not manage the issue tracker. +- `developer` — "Developer — an engineering user who develops the source codebase (writing and maintaining code) and fixes code defects reported in the issue tracker; works primarily in source and consults issues for defect reports." +- `tester` — "Tester — a quality-assurance user who verifies software quality and tracks defects through the issue tracker: filing, triaging, and updating issue reports; works in the issue tracker, not in source." +- `devops` — "DevOps — an operations user who manages deployment infrastructure and runtime environments; does not author source code and does not manage the issue tracker." > The `devops` description is deliberately **unrelated** to source and issue work, so the PRB derives no > agent or tool scope for it and deny-by-default leaves `devops-user` denied everywhere — the inbound -> deny case. It is added to the realm-role set only; the pair-lists and both `policy.md` variants stay -> unchanged (see *Further Notes*). - -### Role & scope descriptions - -**Client roles (agent):** - -- `source_operations` — Covers read and write access to source repository contents — listing, reading, - creating, and modifying files. -- `issue_operations` — Covers read and write access to the issue tracker — reading, filing, updating, - and commenting on issues and their threads. - -**Agent scopes:** - -- `source-access` — Scope granting use of a source-code capability — invoking source-code functions such - as reading and changing repository contents. -- `issues-access` — Scope granting use of an issue-management capability — invoking issue functions such - as reading and updating issues. - -**Tool scopes:** +> deny case. -- `source-read` — Read source repository contents: file listings and file bodies. Read-only. -- `source-write` — Create, modify, or delete source repository contents; commit file changes. -- `issues-read` — Read issues and their comment threads. Read-only. -- `issues-write` — Create and update issues: open, edit, comment, and close. +### `policy.md` — the single (abstract) variant -### `policy.md` — Version 1 (explicit) - -Each granted `(role, scope)` pair is spelled out; the three sections map 1:1 to PRB mappings (a)/(b)/(c) -and to the expected Rego gates. +Phase-1's intent-only prose. The PRB/LLM expands intent into the discovered scopes via the entity/role +descriptions. It stays **user-intent-only** and **does not name the agent's operator roles** — the +agent's capability gate comes from the generic rubric (`generic_policy.md`) matching the operator-role +descriptions to the tool-scope descriptions, not from the policy naming them. Deny by default. Phrased +**purely positively** — no `exclusively`, `read-only`, or `no access to source` prose — so absences +(developer's lack of issues-write, tester's lack of source access) are conveyed by **silence + +deny-by-default**, not by prohibition triggers that would drive the DENY-aware PRB to emit `DENY` rules +(kept ALLOW-only for now; see *Further Notes*). ```markdown -# Access Control Policy — github-agent / github-tool - -Grant access on a least-privilege basis. Only grant a (role, scope) pair when this -policy supports it; deny by default. - -## Users → agent capabilities (inbound; user may call the agent) -- developer may use source-access and issues-access. -- tester may use issues-access. - -## Users → tool operations (outbound subject; user may reach the tool) -- developer may perform source-read, source-write, and issues-read. -- tester may perform issues-read and issues-write. +Grant access on a least-privilege basis: allow only what this policy states; deny by default. -## Agent roles → tool operations (outbound target; agent may reach the tool) -- source_operations may perform source-read and source-write. -- issue_operations may perform issues-read and issues-write. +- Developers may read and modify source, and read issues. +- Testers may read and modify issues. ``` -### `policy.md` — Version 2 (abstract) - -Relies on the PRB / LLM to expand "read and modify source" into the concrete scopes. Encodes the same -role→access facts as Version 1. It carries **no** agent-capability bullet; mapping (c) -(agent-role→tool-scope) is instead derived from the elaborated `source_operations` / `issue_operations` -role descriptions (see *Role & scope descriptions*), so it survives the PRB's deny-by-default-on-silence -rule and both variants reproduce the same Rego. - -```markdown -- Developers work primarily in source — writing and maintaining code — and consult the issue tracker only to follow defect reports; grant them full read and write access to source contents, and read-only access to issues. -- Testers work exclusively in the issue tracker — filing, triaging, and updating defect reports — and do not work in source; grant them full read and write access to issues, and no access to source. -``` +> The **explicit** enumerated variant and the cross-variant equivalence check are deferred to the +> two-policy rung `testing/5.4.4`; the two-stack topology that once served both variants is discarded. diff --git a/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md b/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md index 708a20502..fe52779b7 100644 --- a/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md +++ b/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md @@ -4,7 +4,8 @@ > Integration-test specs live under `docs/specs/integration-test/` (a sibling of `components/`), indexed > by the master PRD's *Integration test specifications* section ([../PRD.md](../PRD.md)). This is the > phase-1 service-onboarding demo driven end-to-end through the **real UC-1 agent** against -> **really-deployed** demo workloads — not the definition of integration testing in general. +> **really-deployed** demo workloads, and **enforced by the deployed AuthBridge OPA plugin** — not the +> definition of integration testing in general. > **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 @@ -13,73 +14,94 @@ > > | Rung | Issue | Onboards | Proves | > |---|---|---|---| -> | 1 | `testing/5.4.1-uc1-onboard-agent-only.md` | agent only | agent discovery + inbound generation stand alone; outbound empty with no tool | -> | 2 | `testing/5.4.2-uc1-onboard-agent-then-tool.md` | agent → tool | onboarding the tool **after** the agent completes the agent's outbound (PCE additive merge) | +> | 1 | `testing/5.4.1-uc1-onboard-agent-only.md` | agent only | agent discovery + inbound enforcement stand alone; outbound empty (all deny) with no tool | +> | 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 | -> **Relationship to `policy-pipeline`.** This is the **discovery-driven sibling** of +> **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, -> same role→access facts, same inbound/outbound matrices); the difference is provenance — `policy-pipeline` -> *hand-provisions* the agent/tool roles/scopes in process, this ladder *infers them via real UC-1 -> onboarding* of deployed workloads. That inference makes the generated Rego **semantically similar but not -> byte-identical** to `policy-pipeline` (see *[Semantic similarity](#semantic-similarity-not-byte-identity)*). +> same role→access facts, same inbound/outbound matrices) and the **same** live enforcement loop — both +> onboard through the real in-cluster UC-1 Controller and assert the **deployed OPA plugin's** allow/deny +> over real HTTP through AuthBridge. `policy-pipeline` is the **full happy-path matrix + negative +> controls** over the fully onboarded stack; this ladder **isolates onboarding-order properties** across +> three rungs (agent-only; agent→tool; tool→agent + order-independence). ## Location -`aiac/test/integration/` — pytest modules marked `@pytest.mark.integration`, one per rung (or one module -with one test per rung). They import two shared modules: +`aiac/test/integration/` — pytest modules marked `@pytest.mark.integration`, one per rung +(`test_uc1_onboard_agent_only.py`, `test_uc1_onboard_agent_then_tool.py`, +`test_uc1_onboard_tool_then_agent.py`). Each 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: - `scenario_uc1.py` — the pure-data scenario (users/roles + the pair-lists expressed over the - **discovered, workload-prefixed** names `github-tool.source-read`, `github-agent.source_operations`, …). - The old two-variant machinery (`VARIANTS`, `POLICY_EXPLICIT`, per-variant URLs/pods) is removed; the - truth tables (`USERS`, `USER_ROLES`, `INBOUND_PAIRS`, `OUTBOUND_SUBJECT_PAIRS`, `TOOL_SCOPES`, - `AGENT_SCOPES`, `AGENT_ROLE`) and the single **abstract** `policy.md` remain. -- `launcher.py` — the shared `kubectl`/port-forward + `opa` helpers (`kubectl`, `kubectl_cp`, - `port_forward`, `resolve_pod`, `opa_eval`, `require_env`, …). - -They also ship `probe_uc1.rego` — the outbound verification query, matching `input.function_name` against -the generated data maps by **exact string equality** on full discovered names, binding **both** the user -(subject) gate and the agent capability gate (the per-scope two-gate AND). + **discovered, workload-prefixed** names `github-tool.source-read`, `github-agent.source_operations`, …, + plus the **bare** runtime names `source-read` the oracle keys on). The old two-variant machinery + (`VARIANTS`, `POLICY_EXPLICIT`, per-variant URLs/pods) is gone; the truth tables (`USERS`, + `USER_ROLES`, `INBOUND_PAIRS`, `OUTBOUND_SUBJECT_PAIRS`, `OUTBOUND_TARGET_PAIRS`, `TOOL_SCOPES`, + `AGENT_SCOPES`, `TOOL_REQUEST_NAMES`) and the single **abstract** `policy.md` remain. +- `uc1_onboard.py` — the shared live harness: config, Keycloak provisioning/cleanup + (`provision_realm_and_users` / `resolve_service_id` / `cleanup_provisioned` / `clear_policy_store`), + the onboard trigger (`onboard`), the outbound token-exchange-leg prep (`ensure_github_tool_route` / + `grant_exchange_scope` / `restart_agent`), the bundle-convergence poll, the live decision oracle + (`expected_inbound` / `expected_outbound_bare`, `inbound_decision` / `outbound_decision`), and + `onboarded_stack(workloads)` — the whole per-rung fixture flow parameterised by the ordered workload + list. +- `launcher.py` — the shared live-cluster half: `kubectl` wrappers, `port_forward`, `resolve_pod`, + `mint_token`, `jwt_claim`, `inbound_probe` / `outbound_probe`, `inbound_outcome` / `outbound_outcome` + (OPA denial classified by body, not status), `poll_until`, and the skip gates (`require_pipeline`, + `require_env_or_skip`, `verify_subject_mapper`). There is **no** `opa_eval`, no `kubectl_cp` of + `/rego`, and no standalone probe module: the evaluator is the deployed AuthBridge OPA plugin, and the + input documents are built by AuthBridge's own parsers. ## Description `@pytest.mark.integration` tests that validate the **phase-1 deliverable** and confirm the runnable demo: they drive the **real UC-1 Service Onboarding agent** (`POST /apply/service/{id}` on the in-cluster AIAC -Controller) against **already-deployed** `github-agent` + simplified `github-tool`, and assert the -generated Rego decides correctly using the standalone `opa eval` binary as the oracle. - -Phase-1 is explicit that **live enforcement / live traffic is out of scope** — correctness is shown by -**evaluating the generated rules**, not by routing requests. So each rung is *onboard + evaluate*: the -workloads are really deployed and really discovered by UC-1 (classify from the `rossoctl.io/type` label, -read the AgentCard / MCP `tools/list`, provision roles/scopes into Keycloak, model access, emit Rego), but -**no A2A message is ever sent through the agent**. - -The generated Rego is the **artifact under test** — the LLM/PCE that produced it might be wrong — so the -tests never trust it. Expected verdicts are **computed from** the `scenario_uc1.py` pair-lists (the -intended policy). A mismatch fails the test and names the exact cell. - -Because they need a live rossoctl cluster + operator + Keycloak + a real LLM, they are -`@pytest.mark.integration` (out of the default unit run, `-m "not integration"`) and additionally -`pytest.skip` when no `opa` binary is found. +Controller, which upserts the `AuthorizationPolicy` CR on the live Kubernetes API) against +**already-deployed** `github-agent` + simplified `github-tool`, and then assert the **enforced decision** +is correct by driving **real HTTP requests through AuthBridge** and reading the **deployed OPA plugin's** +allow/deny. + +Live enforcement is now **in scope and is the whole point**: each rung onboards, enables the outbound +token-exchange leg where a tool is present (Part B), waits for `bundle-service` + the AuthBridge OPA +sidecars to recompose and reload the bundle, then drives real requests through AuthBridge on both legs +(`jwt-validation` builds `input.identity` inbound; `token-exchange` + `mcp-parser` build the outbound +`input.identity` + `input.mcp.params.name`). The agent's own CrewAI reasoning flow is **not** triggered — +the probes are synthetic requests through AuthBridge (an inbound `ping` / `nonexistent`; an outbound bare +`tools/call`) — but the traffic is real and the deployed plugin enforces it. + +The enforced decision is the **artifact under test** — the LLM/PCE that produced the policy might be +wrong — so the tests never trust it. Expected verdicts are **computed from** the `scenario_uc1.py` +pair-lists (the intended policy), keyed on the **bare** runtime tool names AuthBridge sends. A mismatch +fails the test and names the exact cell. + +Because they need a live rossoctl/Kind cluster with the AuthBridge OPA pipeline wired into both legs + +operator + Keycloak + a real LLM, they are `@pytest.mark.integration` (out of the default unit run, +`-m "not integration"`) and **skip cleanly** when the cluster/pipeline is not wired or the env is unset +(they never false-pass). ## Topology -- **One in-cluster AIAC stack.** A single AIAC agent (Controller, `POST /apply/service/{id}`) + Policy - Store + **OPA Policy Writer (filesystem stub)**, mounting the **single abstract** `policy.md`. AIAC runs - in-cluster so UC-1's `analyze_tool` can reach the tool's MCP endpoint at its cluster-internal DNS name - (`github-tool.{ns}.svc.cluster.local`); the tests trigger over `kubectl port-forward`. -- **OPA filesystem-stub writer.** The stack must run - `aiac-pdp-policy-opa` (the filesystem stub: writes `{slug}.inbound.rego` + `{slug}.outbound.rego` to - `REGO_OUTPUT_DIR`, default `/rego`) — this is what the K8s Phase 1 Interface Pod actually deploys. - The `.rego` files are - the artifact under test; without the OPA writer there is nothing to capture. -- **Rego capture.** `kubectl cp` the writer's `/rego` to a per-rung host dir (a `rung{1,2,3}` subfolder - under the gitignored `test/integration/rego_out/uc1/` tree, so artifacts stay in the project for - eyeballing but are never committed; each rung clears its dir first), then run `opa eval` on the host. +- **One in-cluster AIAC stack + the deployed AuthBridge OPA pipeline.** A single AIAC agent (Controller, + `POST /apply/service/{id}`) + Policy Model Store + **OPA Policy Writer**, mounting the **single + abstract** `policy.md`. AIAC runs in-cluster so UC-1's `analyze_tool` can reach the tool's MCP endpoint + at its cluster-internal DNS name (`github-tool.{ns}.svc.cluster.local`); the tests trigger the + Controller over `kubectl port-forward`. +- **The deployed OPA plugin is the evaluator.** Onboarding upserts the agent's `AuthorizationPolicy` CR + on the live Kubernetes API; `bundle-service` (in `rossoctl-system`) recomposes the namespace bundle, + and each workload pod's AuthBridge OPA sidecar polls + reloads it (~20–30 s). There is **no** `/rego` + dump and **no** `kubectl cp` — the artifact under test is the enforced decision, not a file. +- **Convergence by polling real decisions.** After the CR is upserted (and, for the outbound leg, after + Part B + the agent restart), `onboarded_stack` polls real requests through AuthBridge until this run's + policy is reflected in the plugin's decisions, up to `AIAC_BUNDLE_TIMEOUT`. ## Preconditions (assumed, not performed by the tests) +- **Pipeline wired.** The AuthBridge OPA plugin is wired into both legs (`k8s/opa-kind-enable.sh`); + `require_pipeline` skips cleanly if not (no `kubectl`, `AuthorizationPolicy` CRD not served, + `bundle-service` not Running, the `opa` plugin not present on both legs, or a workload pod not Running). - **Workloads deployed + registered.** Both `github-agent` and simplified `github-tool` are **already deployed** in `AIAC_DEMO_NAMESPACE` and **already registered as Keycloak clients** (`client.name = "{ns}/{workload}"`) into `AIAC_TEST_REALM`. The tests do **not** `kubectl apply` @@ -91,22 +113,26 @@ Because they need a live rossoctl cluster + operator + Keycloak + a real LLM, th > operator sets `client.name = "{ns}/{workload}"`, and the `clientId` is slash-bearing either way > (`"{ns}/{workload}"` with SPIRE off, a SPIFFE URI under `--spire-trust-domain`), so it cannot be a > path segment. Resolve by looking up the client whose **name** is `"{ns}/github-tool"` / - > `"{ns}/github-agent"`, then trigger with that client's **`id`** (the UUID). + > `"{ns}/github-agent"`, then trigger with that client's **`id`** (the UUID) — `resolve_service_id`. - **Users + realm roles.** The fixture provisions them (UC-1 does not) — see - *[Scenario](#scenario)* — via `KeycloakAdmin` into `AIAC_TEST_REALM`, **before** onboarding; idempotent; - left in place. + *[Scenario](#scenario)* — via `KeycloakAdmin` into `AIAC_TEST_REALM`, **before** onboarding; + idempotent; left in place. `verify_subject_mapper` confirms the realm's `username → sub` mapper + + Direct Access Grants (else skip). ## Per-rung flow -**Keycloak cleanup → onboard (rung order) → validate end state → Keycloak cleanup.** +**Keycloak cleanup + policy-store clear → onboard (rung order) → enable outbound leg → poll bundle → +drive real requests + assert → Keycloak cleanup + CR delete.** -1. **Cleanup** (before and after each rung). Unmap composites and delete the **agent's and tool's** - provisioned realm roles + client scopes, leaving the clients registered exactly as before the first - run; clear the writer's `/rego`. This gives every rung a clean slate and makes reruns converge. (With - the OPA writer there are no composites — the only Keycloak mutations are the roles/scopes the onboarding - agent provisions — but cleaning both is harmless.) +1. **Cleanup** (before and after each rung, all before any assertion). `cleanup_provisioned` deletes the + **agent's and tool's** provisioned realm roles + client scopes (leaving the clients registered exactly + as before the first run), delete the agent's `AuthorizationPolicy` CR, and `clear_policy_store` drops + persisted SPMs from the in-cluster Policy Store (whose SQLite outlives redeploys, so pre-fix cruft + would otherwise accumulate — onboarding appends with `override=False`). This gives every rung a clean + slate and makes reruns converge. Then `provision_realm_and_users` (idempotent) + `ensure_agent_policy` + (mount the abstract `policy.md` on the Controller pod). 2. **Onboard** in the rung's order via `POST /apply/service/{service_id}`, where `{service_id}` is the - internal Keycloak UUID (`Service.id`), **not** the clientId — the onboard route is keyed on the UUID. + internal Keycloak UUID (`resolve_service_id`), **not** the clientId. - `POST /apply/service/{github-tool id}` → UC-1 classifies it a **Tool**, reads the MCP manifest, provisions scopes `github-tool.{source-read, source-write, issues-read, issues-write}`, sets `client.type=Tool`. **No rules are written for the tool directly.** @@ -114,42 +140,55 @@ Because they need a live rossoctl cluster + operator + Keycloak + a real LLM, th provisions **one operator role per skill** `github-agent.{source_operations, issue_operations}` (mirroring the scopes) + scopes `github-agent.{source_operations, issue_operations}`, sets `client.type=Agent`; the Service Policy Builder maps roles→scopes via the real PRB (real LLM, - `temperature=0`) and the Controller calls `compute_and_apply(rules, override=False)`. -3. **Validate two outcomes at the end** (no intermediate checks): + `temperature=0`) and the Controller calls `compute_and_apply(rules, override=False)`; the OPA Policy + Writer upserts the agent's `AuthorizationPolicy` CR. +3. **Enable the outbound token-exchange leg (Part B)** — only meaningful when a tool is onboarded (rungs 2 + and 3). `ensure_github_tool_route` adds the `github-tool` outbound route to `authproxy-routes`, + `grant_exchange_scope` grants the agent's client the `github-tool` audience scope as optional, and + `restart_agent` restarts the agent so it reloads the route (and its OPA sidecar re-fetches the + recomposed bundle). Without this the outbound call passes through unexchanged and never reaches OPA. +4. **Poll until the pipeline converges.** `poll_until` drives real decisions until this run's CR is + reflected (inbound `dev-user` allow, `devops-user` deny; outbound `dev-user` `source-read` at its + terminal verdict), waiting out the bundle poll + post-restart token-exchange window. +5. **Validate two outcomes at the end** (no intermediate checks): 1. **Keycloak provisioning.** The expected realm role(s) + client scopes exist with the expected - names/descriptions (via `KeycloakAdmin` / the IdP Configuration read API). - 2. **Generated Rego decisions.** `kubectl cp` the `/rego` files to the host and `opa eval`: - - **`opa` discovery** — `$OPA_BIN` → `shutil.which("opa")` → `pytest.skip`. - - **Inbound** — per `subject`: `{"subject": }` vs `data.authz.team1_github_agent.inbound.allow`. - - **Outbound (per-scope two-gate AND)** — per `(subject × function_name)`, `function_name` a full - discovered tool-scope name, via the probe `data.probe.outbound.allow` in `probe_uc1.rego`, which - binds `input.function_name` against **both** the user (subject) gate and the agent capability - gate by exact string equality — a request is allowed iff both reach the same scope (see - *[The agent→tool gate](#the-agenttool-gate-capability-matched)*). - - **Grant sets** — re-derive the `(role, scope)` grant sets from the Rego data maps and compare, as - order-independent sets, to the `scenario_uc1.py` truth table. - - Verdicts are **computed from** `scenario_uc1.py`, never from the Rego. A failing node names the + names/descriptions (via `KeycloakAdmin`) — and, for rung 1, that **no** tool scopes were provisioned. + 2. **Enforced decisions.** Drive **real HTTP requests through AuthBridge** and read the **deployed OPA + plugin's** allow/deny: + - **Inbound** — per `subject`, `inbound_decision` (200 → `allow`, 403 → `deny`); expected from + `expected_inbound`. + - **Outbound (per-scope two-gate AND)** — per `(subject × bare tool name)`, a real MCP `tools/call` + for the **bare** tool through AuthBridge's forward proxy (`outbound_decision`); a denial is a + JSON-RPC error frame (`error.data.plugin: "opa"`) at HTTP 200 that the harness classifies as + `deny`. Expected from `expected_outbound_bare` — allowed iff the subject **and** some agent role + both reach that tool's scope. + - Verdicts are **computed from** `scenario_uc1.py`, never from the policy. A failing node names the exact cell. -4. **Cleanup** — restore the clients to their pre-run state. +6. **Cleanup** — restore the clients to their pre-run state and delete this run's CR. ## Onboarding order is irrelevant (rungs 2 vs 3) -The **final** policy must not depend on the order services are onboarded. This is a **requirement**: if -onboarding order changes the end state, that is a **bug** the ladder exists to catch — not an accepted -difference. (This corrects an earlier "order matters" note in this spec and the tracking issue.) +The **final** enforced policy must not depend on the order services are onboarded. This is a +**requirement**: if onboarding order changes the end state, that is a **bug** the ladder exists to catch — +not an accepted difference. Rung 3 (tool → agent) is the **live counterpart of the PCE's +order-independence unit test (8.11)** and the exact repro of the original order-dependence bug: under the +old APM-only design, tool-then-agent **lost** the outbound gate. Why it holds: `compute_and_apply` is **affected-agent** oriented and **additive** (`override=False`, see [../components/policy-computation-engine.md](../components/policy-computation-engine.md)). When the **tool** is onboarded, its Service Policy Builder pairs the tool's scopes against the rest of the role universe, producing `(agent-role, tool-scope)` and `(user-role, tool-scope)` rules; the PCE resolves those roles to -the **agent** and merges them onto the agent's stored `AgentPolicyModel`, rewriting -`team1_github_agent.outbound.rego`. So: +the **agent** and merges them onto the agent's stored `AgentPolicyModel`, re-upserting the agent's +`AuthorizationPolicy` CR. So: -- **Rung 2 (agent → tool):** agent onboarding leaves outbound empty; **tool onboarding fills it in**. +- **Rung 2 (agent → tool):** agent onboarding leaves the outbound gate empty; **tool onboarding fills it + in**. - **Rung 3 (tool → agent):** the tool's scopes already exist, so **agent onboarding produces the full gate** in one pass. -- **Both converge** to the same grant sets. Rung 3 asserts grant-set equivalence with **rung 2**; a - divergence fails and names the differing gate. +- **Both converge** to the same enforced decisions. Rung 3 asserts, at the oracle level, that its intended + end state is **identical** to rung 2's published expectations (`RUNG3_* == RUNG2_*`), then proves the + **real plugin's decisions** match that in the tool→agent order — so onboarding order did not change what + is enforced. Rung 1 (agent only) is the exception by construction: with no tool onboarded there are no tool scopes in the universe, so the outbound user gate is **empty** (all deny). Inbound is unaffected. @@ -157,11 +196,11 @@ the universe, so the outbound user gate is **empty** (all deny). Inbound is unaf ## Expected output Verdicts are **computed from** the `scenario_uc1.py` pair-lists (these tables are the human-readable -rendering). They are **identical to policy-pipeline's** (only the scope-name strings differ). +rendering). They are **identical to policy-pipeline's** and to what the deployed OPA plugin enforces. `USERS`: `dev-user`→`developer`, `test-user`→`tester`, `devops-user`→`devops`. -**Inbound allow** (`data.authz.team1_github_agent.inbound.allow`; all rungs): +**Inbound allow** (the real plugin's inbound decision; all rungs): | Subject | Inbound | |---|---| @@ -169,11 +208,11 @@ rendering). They are **identical to policy-pipeline's** (only the scope-name str | test-user | ✅ | | devops-user | ❌ | -**Outbound allow(subject, function)** (`data.probe.outbound.allow`, per-scope two-gate AND; the agent -reaches all four tool scopes, so the user gate discriminates; suffixes shown for readability) — -**rungs 2 and 3** (with a tool onboarded): +**Outbound allow(subject, tool)** (the real plugin's outbound decision, per-scope two-gate AND over the +**bare** tool names; the agent reaches all four tool scopes, so the user gate discriminates) — **rungs 2 +and 3** (with a tool onboarded): -| | github-tool.source-read | github-tool.source-write | github-tool.issues-read | github-tool.issues-write | +| | source-read | source-write | issues-read | issues-write | |---|---|---|---|---| | dev-user | ✅ | ✅ | ✅ | ❌ | | test-user | ❌ | ❌ | ✅ | ✅ | @@ -181,43 +220,38 @@ reaches all four tool scopes, so the user gate discriminates; suffixes shown for **Rung 1 (agent only):** the outbound table is **entirely deny** (empty user gate — no tool scopes). -Each rung leaves on disk exactly `{AGENT_SLUG}.inbound.rego` + `{AGENT_SLUG}.outbound.rego`; explicitly -**no** `github_tool.*.rego` (the tool is a pure target; "no rules written for the tool alone"). -`AGENT_SLUG` is the Rego-package slug derived from the agent's clientId (`{namespace}/{name}`, -extracted from the SPIFFE URI under SPIRE) — `team1_github_agent` on the reference cluster's -`team1`/`github-agent` scenario, not a literal `github_agent` (see -[pdp-policy-writer-opa.md § Rego package structure](../components/pdp-policy-writer-opa.md#rego-package-structure) -for the slugify rule). - -### Semantic similarity, not byte-identity - -This ladder's Rego is **semantically similar** to `policy-pipeline`'s but **not byte-identical**, for two -baked-in reasons in UC-1 provisioning: +The pipeline emits an agent `AuthorizationPolicy` CR only — explicitly **no** tool CR (the tool is a pure +target; "no rules written for the tool alone"). Each rung also asserts the expected Keycloak provisioning +end state (agent roles/scopes with the expected descriptions; rung 1 additionally asserts **no** tool +scopes exist). -1. **Workload-prefixed names.** UC-1 names every scope `{workload}.{name}`, so the data maps hold - `github-tool.source-read` / `github-agent.source_operations` where `policy-pipeline` holds bare names. -2. **Capability-matched `target_ok`.** UC-1 provisions one **operator role per skill** - (`github-agent.source_operations` / `github-agent.issue_operations`), which the PRB maps to the tool - scopes by domain (capability-match), so the agent→tool gate is populated over all four tool scopes. +### Prefixed provisioned names vs. bare runtime names -The tests therefore assert **same file set + same decisions + equivalent grant sets**, not identical text. +UC-1 names every scope `{workload}.{name}`, so what it **provisions** into Keycloak (and what the oracle's +grant-set constants hold) is **workload-prefixed** — `github-tool.source-read`, +`github-agent.source_operations`. But the request AuthBridge actually sends, and the name the OPA plugin +compares against, is the **bare** runtime tool name (`source-read`) that `mcp-parser` puts in +`input.mcp.params.name`. So the live oracle keys decisions on the **bare** names +(`expected_outbound_bare` / `outbound_decision`); the two naming registers meet in `scenario_uc1.py` +(prefixed provisioned truth + a `bare()` de-prefixer). The enforced decisions are therefore identical to +`policy-pipeline`'s — both share the same harness and enforce over the same bare names. ### The agent→tool gate (capability-matched) Phase-1 states outbound access is the **per-scope intersection** of the user→tool gate and the agent→tool gate. UC-1 provisions **one operator role per skill** (`github-agent.source_operations` / `github-agent.issue_operations`), and the PRB maps those operator -roles to the tool scopes by domain (capability-match under `generic_policy.md`), so `target_ok` is -**populated over all four tool scopes**. Because the agent reaches every tool scope, the **user gate -discriminates** — the probe binds the real per-scope AND (`subject_ok AND target_ok` on the same -`input.function_name`) and, for this scenario, its verdicts equal the user-gate slice. The AND is +roles to the tool scopes by domain (capability-match under `generic_policy.md`), so the agent's capability +gate is **populated over all four tool scopes**. Because the agent reaches every tool scope, the **user +gate discriminates** — the plugin enforces the real per-scope AND (subject gate AND capability gate on the +same `input.mcp.params.name`) and, for this scenario, its verdicts equal the user-gate slice. The AND is genuine, not degenerate: if the agent reached only a subset of the tool's scopes, the request would be denied for the scopes it does not reach. ## Scenario Identical role→access facts to `policy-pipeline`, driven through real UC-1 onboarding of deployed -workloads. +workloads and enforced by the deployed OPA plugin. | Element | Value | |---------|-------| @@ -227,106 +261,120 @@ workloads. | Users | `dev-user` (`developer`), `test-user` (`tester`), `devops-user` (`devops`) | | `developer` | source read/write + issues read | | `tester` | issues read/write | -| `devops` | no access (inbound deny; denied every outbound function) — conveyed by **role description only**, absent from the `policy.md` (deny-by-default) | +| `devops` | no access (inbound deny; denied every outbound tool) — conveyed by **role description only**, absent from the `policy.md` (deny-by-default) | ## Configuration (env) +The suite reads its config from `test/integration/.env` (gitignored); source it before running +(`set -a; . test/integration/.env; set +a`). The drivers read these: + | Variable | Purpose | Default | |----------|---------|---------| | `KUBECONFIG` | Kubeconfig for the live rossoctl/Kind cluster | — (required) | -| `AIAC_DEMO_NAMESPACE` | Namespace the demo workloads are deployed in (precondition) | `team1` | | `KEYCLOAK_URL` | External Keycloak base URL | — (required) | -| `KEYCLOAK_ADMIN_REALM` | Realm the admin creds live in | `master` | | `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin creds (user/realm-role provisioning + cleanup) | — (required) | -| `AIAC_TEST_REALM` | Realm the tests resolve/provision against. **Must match the deployed AIAC stack's `KEYCLOAK_REALM`** — the in-cluster Controller resolves the onboarding trigger in *its own* realm, so a harness on a different realm resolves a client UUID the Controller can't find (404 → onboard 502). The demo namespace's clients are registered into it. | `rossoctl` | -| `AIAC_CONTROLLER_URL` | Base URL of the in-cluster AIAC Controller (via port-forward) for `POST /apply/service/{id}` | `http://127.0.0.1:7070` | -| `AIAC_OPA_POD` / `AIAC_OPA_SELECTOR` | OPA-writer pod (or label selector) to `kubectl cp` `.rego` from | — (resolved from labels) | -| `AIAC_OPA_REGO_PATH` | Writer output dir inside the pod | `/rego` | -| `REGO_OUTPUT_DIR` | Base dir the captured `.rego` is copied to (one `rung{1,2,3}` subfolder per rung) | `test/integration/rego_out/uc1/` (gitignored) | +| `KEYCLOAK_ADMIN_REALM` | Realm the admin creds live in | `master` | | `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` | PRB LLM (pinned `temperature=0`); consumed by the in-cluster AIAC pod | — (required) | -| `OPA_BIN` | Path to the standalone `opa` binary (oracle); else `PATH`, else `pytest.skip` | — (optional) | +| `AIAC_TEST_REALM` | Realm the tests resolve/provision against. **Must match the deployed AIAC stack's `KEYCLOAK_REALM`** — the in-cluster Controller resolves the onboarding trigger in *its own* realm, so a harness on a different realm resolves a client UUID the Controller can't find (404 → onboard 502) | `rossoctl` | +| `AIAC_DEMO_NAMESPACE` | Namespace the demo workloads are deployed in (precondition) | `team1` | +| `AIAC_TRUST_DOMAIN` | SPIFFE trust domain the operator registers the demo workloads under | `localtest.me` | -> Single stack — one Controller URL, one OPA pod, one policy. The two-variant env -> (`AIAC_EXPLICIT_URL`/`AIAC_ABSTRACT_URL`, `AIAC_OPA_POD_EXPLICIT`/`_ABSTRACT`) is removed with the -> two-stack topology. +> Cluster/stack knobs the harness also honors, with defaults matching the deployed stack (rarely +> overridden): the Controller target/namespace/ports (`AIAC_CONTROLLER_*`, default +> `svc/aiac-agent-service` in `aiac-system` on `7070`), the Policy Store target (`AIAC_STORE_*`, +> `svc/aiac-policy-model-store-service` on `7074`), the abstract-policy ConfigMap/mount +> (`AIAC_POLICY_CONFIGMAP` / `AIAC_POLICY_MOUNT_PATH`), the agent Deployment to restart +> (`AIAC_AGENT_DEPLOYMENT`), and the timeouts (`AIAC_ONBOARD_TIMEOUT`, `AIAC_BUNDLE_TIMEOUT`, +> `AIAC_BUNDLE_POLL_INTERVAL`). Single stack — one Controller, one policy; the two-variant env +> (`AIAC_EXPLICIT_URL`/`AIAC_ABSTRACT_URL`, per-variant OPA pods) is gone with the two-stack topology. ## Runbook -Runnable against a live rossoctl/Kind cluster (operator + Keycloak + SPIRE) with the AIAC stack running the -**OPA filesystem-stub writer**, `github-agent` + `github-tool` **already deployed and registered** into -`AIAC_TEST_REALM`, a real LLM, and an `opa` binary on `PATH` (or `$OPA_BIN`). +Runnable against a live rossoctl/Kind cluster (operator + Keycloak + SPIRE) with the AIAC stack + the +AuthBridge OPA pipeline wired into **both** legs, `github-agent` + `github-tool` **already deployed and +registered** into `AIAC_TEST_REALM`, and a real LLM in-pod. Stand the pipeline up with +`k8s/opa-kind-enable.sh`; the full prerequisites, wiring, and manual probe commands are in +`k8s/opa-kind-runbook.md`. ```bash -# env: KUBECONFIG + KEYCLOAK_URL + admin creds + LLM_* set; realm defaults to rossoctl (match the stack's KEYCLOAK_REALM); opa on PATH or $OPA_BIN +k8s/opa-kind-enable.sh # one-time: wire the OPA plugin into both legs of the Kind cluster +set -a; . test/integration/.env; set +a .venv/bin/pytest test/integration/ -m integration -k uc1_onboard -v # A failing node names the exact cell, e.g.: -# test_outbound[test-user-github-tool.source-read] — expected deny, opa allowed +# test_outbound[test-user-source-read] — expected deny, plugin allowed ``` -The suite `pytest.skip`s when no `opa` binary is found. +Without `-m integration` the suite is not collected; when the cluster/pipeline is not wired or the env is +unset it **skips cleanly** (it never false-passes). ## Testing Decisions -- **Highest seam available, verified by a real oracle.** Real deployed workloads + real operator + real - UC-1 onboarding + real PRB/PCE + real Keycloak + real LLM, driven through the production trigger - (`POST /apply/service/{id}`), verified by the standalone `opa eval` binary. Assert only **external - behavior** — the decisions the Rego makes — never internal Rego structure. -- **Rego is the artifact under test; the scenario is the oracle.** Verdicts computed from `scenario_uc1.py`. -- **Onboard + evaluate, no live traffic.** Enforcement / token-exchange / live A2A is out of scope. +- **Highest seam available, verified by the real evaluator.** Real deployed workloads + real operator + + real UC-1 onboarding + real PRB/PCE + real Keycloak + real LLM, driven through the production trigger + (`POST /apply/service/{id}`) and enforced by the **deployed AuthBridge OPA plugin**. Assert only + **external behavior** — the allow/deny decisions the plugin makes — never internal policy structure. +- **The enforced decision is the artifact under test; the scenario is the oracle.** Verdicts computed from + `scenario_uc1.py`, keyed on the bare runtime tool names — not from the policy itself. +- **Onboard, then enforce.** Live enforcement / token-exchange / real HTTP through AuthBridge is now the + whole point (not out of scope). The agent's own CrewAI reasoning flow is not triggered — the probes are + synthetic requests through AuthBridge — but the traffic is real and the deployed plugin enforces it. - **Deployment is a precondition.** The tests do not deploy or wait for registration; they cleanup → - onboard → validate → cleanup, so reruns are hermetic and cheap. -- **One stack, one policy, OPA filesystem stub.** Rungs 1–3 need only one AIAC stack; the OPA writer's - `/rego` output is what makes the pipeline observable. -- **Onboarding-order-independence is asserted, not assumed** (rungs 2 vs 3). A divergence is a bug. + onboard → enable the outbound leg → poll → validate → cleanup, so reruns are hermetic and cheap. +- **One stack, one policy, the deployed plugin.** Rungs 1–3 need only one AIAC stack; the deployed OPA + plugin + the upserted `AuthorizationPolicy` CR are what make the pipeline observable. +- **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. - **Per-scope two-gate AND.** UC-1's per-skill operator roles are mapped to the tool scopes by - capability-match, so `target_ok` is populated; the outbound probe binds the real per-scope AND - (`subject_ok AND target_ok` on the same `input.function_name`). The agent reaches all four tool - scopes, so the user gate discriminates. -- **Grant sets, semantic.** Equivalence is re-derived from the Rego data maps and compared as sets — the - semantic-similarity guarantee, not byte-identity. + 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. - **Stack's realm, leave-in-place; per-rung cleanup.** UC-1 resolves/provisions against the deployed stack's `KEYCLOAK_REALM` (default `rossoctl`) and **never deletes** the realm/users/roles; only the - provisioned agent/tool roles/scopes are cleaned up per rung so onboarding runs from a clean slate. - (Contrast `5.3 policy-pipeline`, which owns a **throwaway** realm it `delete_realm`s + recreates each - run — that suite must never point `AIAC_TEST_REALM` at `rossoctl`, or it destroys the demo clients.) -- **LLM nondeterminism, contained.** PRB LLM pinned `temperature=0`; both cell-level and grant-set + provisioned agent/tool roles/scopes (and this run's CR + policy-store SPMs) are cleaned up per rung so + onboarding runs from a clean slate. `policy-pipeline` (`5.3`) shares this same live stack and the same + leave-in-place realm. +- **LLM nondeterminism, contained.** PRB LLM pinned `temperature=0`; both cell-level and provisioning assertions; `@pytest.mark.integration`, out of default CI. -- **Prior art, shared not copied.** Reuses the `5.3` shape (`opa` discovery/skip, scenario-as-oracle, - probe query) via `launcher.py`/`scenario_uc1.py`, adapted to deploy-precondition + port-forward + - `kubectl cp`. +- **Prior art, shared not copied.** Reuses the `5.3` shape (skip gates, scenario-as-oracle, the live + decision oracle) via `uc1_onboard.py` / `launcher.py` / `scenario_uc1.py`. ## Relationship to other integration tests -- **Discovery-driven sibling of `policy-pipeline`** ([policy-pipeline.md](policy-pipeline.md), - `testing/5.3-policy-pipeline-integration-test.md`): identical scenario facts/tables, but this ladder - *infers* the entities via real UC-1 onboarding of deployed workloads. `5.3` also already asserts the - **cross-variant** (explicit vs abstract) grant-set equivalence in process, which covers the deferred - rung 4's core guarantee until an in-cluster two-policy approach is designed. -- Same `@pytest.mark.integration` + `opa eval` flavor as `testing/5.1-integration-tests.md` and - `policy-pipeline`; skips when `opa` is absent. +- **Onboarding-order sibling of `policy-pipeline`** ([policy-pipeline.md](policy-pipeline.md), + `testing/5.3-policy-pipeline-integration-test.md`): identical scenario facts/tables and the **same** + live enforcement loop (onboard through the Controller → real HTTP through AuthBridge → deployed OPA + plugin's allow/deny). `policy-pipeline` is the **full happy-path matrix + negative controls** over the + fully onboarded stack; this ladder **isolates onboarding-order properties** across three rungs. Both + share the same harness and the same live stack (the `rossoctl` realm + the `team1` workloads); the + former explicit-vs-abstract two-policy equivalence check is **deferred to rung 4** (`testing/5.4.4`), + since only one `policy.md` is mounted on the live stack. +- Same `@pytest.mark.integration` + live-enforcement flavor as `testing/5.1-integration-tests.md`; runs + outside the default unit run against live dependencies and skips cleanly when the cluster/env is not + 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). ## Out of Scope -- **Writing the rung tests + `probe_uc1.rego` + `scenario_uc1.py` edits** — this spec *describes* them; - they are written under the `5.4.x` issues. -- **The UC-1 agent, PRB, PCE, OPA writer, and demo `github-agent`** — specified/tested by their own - components/issues. UC-1's discovery naming and per-skill operator-role behavior are **fixed**; these - tests observe them. -- **Deploying / registering the workloads** — a precondition, not part of the tests. +- **Writing the rung tests + `scenario_uc1.py` / harness edits** — this spec *describes* them; they are + written under the `5.4.x` issues. +- **The UC-1 agent, PRB, PCE, OPA writer, the AuthBridge OPA plugin, and the demo `github-agent`** — + specified/tested by their own components/issues. UC-1's discovery naming and per-skill operator-role + behavior are **fixed**; these tests observe and enforce against them. +- **Deploying / registering the workloads and wiring the OPA pipeline** — preconditions + (`k8s/opa-kind-enable.sh`), not part of the tests. - **Two-policy (rung 4)** — deferred; the two-stack topology is discarded and the in-cluster approach is TBD (`testing/5.4.4-uc1-onboard-two-policies.md`). -- **Live enforcement / A2A / token exchange / K8s-CR Policy Writer** — Phase-2+; these tests target the - filesystem stub and evaluate rules. +- **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.integration`; runs on demand. ## Scenario inputs **Functional** inputs — the PRB reads the descriptions and the `policy.md` to produce the role→scope -mappings. Descriptions are **generic and keyword-free**; client `type` is set by UC-1 from the -`rossoctl.io/type` label. +mappings. Descriptions are **generic and keyword-free** and stay within Keycloak's 255-char cap (written +verbatim); client `type` is set by UC-1 from the `rossoctl.io/type` label. ### Discovered entities (what UC-1 provisions) @@ -355,7 +403,9 @@ mappings. Descriptions are **generic and keyword-free**; client `type` is set by Phase-1's intent-only prose. The PRB/LLM expands intent into the discovered scopes via the entity/role descriptions. It stays **user-intent-only** and **does not name the agent's operator roles** — the agent's capability gate comes from the generic rubric (`generic_policy.md`) matching the operator-role -descriptions to the tool-scope descriptions, not from the policy naming them. Deny by default. +descriptions to the tool-scope descriptions, not from the policy naming them. Deny by default. Phrased +**purely positively** so absences are conveyed by silence + deny-by-default (keeping the fixture +ALLOW-only; deny-extraction deferred to #142, as in `policy-pipeline`). ```markdown Grant access on a least-privilege basis: allow only what this policy states; deny by default. diff --git a/aiac/k8s/agent-deployment.yaml b/aiac/k8s/agent-deployment.yaml index 0c37ce42c..35a437a29 100644 --- a/aiac/k8s/agent-deployment.yaml +++ b/aiac/k8s/agent-deployment.yaml @@ -152,6 +152,17 @@ spec: port: 7070 initialDelaySeconds: 10 periodSeconds: 20 + # 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/aiac/pyproject.toml b/aiac/pyproject.toml index c1f3a723c..68dbcb1ab 100644 --- a/aiac/pyproject.toml +++ b/aiac/pyproject.toml @@ -46,4 +46,5 @@ testpaths = ["test"] pythonpath = ["src"] markers = [ "integration: tests that call real LLM endpoints", + "llm: tests that call the real LLM but mock descriptions/policy (no cluster)", ] diff --git a/aiac/src/aiac/agent/controller/routes.py b/aiac/src/aiac/agent/controller/routes.py index b4481c8cc..85dc4e870 100644 --- a/aiac/src/aiac/agent/controller/routes.py +++ b/aiac/src/aiac/agent/controller/routes.py @@ -11,21 +11,54 @@ code is authoritative (the accompanying default JSON error body is incidental). """ +import os + import uvicorn -from fastapi import FastAPI -from fastapi.responses import Response +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response from aiac.agent.eventbus.consumer import lifespan +from aiac.agent.policy_rules_builder.graph import ( + PolicyContradictionError, + PolicyRulesBuilderError, +) from aiac.agent.uc.offboarding.offboard import offboard_service from aiac.agent.uc.onboarding.orchestrator import onboard_service 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.policy.computation import compute_and_apply, decommission +from aiac.policy.model.models import RuleEffect app = FastAPI(lifespan=lifespan) +# The Policy Rules Builder raises on a policy-input problem, not a server fault: the auditor +# rejects the proposed rules after exhausting its retry budget (``PolicyRulesBuilderError``) or +# finds a genuine grant/deny contradiction (``PolicyContradictionError``). Both are the caller's +# policy prose failing to lift, so they surface as HTTP 422 (mirroring the contract documented in +# the PRB spec + pdp-policy-writer-opa.md) rather than escaping as an uncaught 500. The PCE is +# never reached — these fire during rule construction inside the use-case handlers. +@app.exception_handler(PolicyRulesBuilderError) +@app.exception_handler(PolicyContradictionError) +def _policy_input_error(_request: Request, exc: Exception) -> JSONResponse: + return JSONResponse(status_code=422, content={"detail": str(exc)}) + +# 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 @@ -37,8 +70,8 @@ def health() -> dict[str, str]: @app.post("/apply/service/{service_id}") def apply_service(service_id: str) -> Response: - rules, override = onboard_service(service_id) - compute_and_apply(rules, override) + rules, override, default_effect = onboard_service(service_id, _default_effect_from_env()) + compute_and_apply(rules, override, default_effect) return Response(status_code=200) diff --git a/aiac/src/aiac/agent/eventbus/consumer.py b/aiac/src/aiac/agent/eventbus/consumer.py index 1afdccb01..733f84c83 100644 --- a/aiac/src/aiac/agent/eventbus/consumer.py +++ b/aiac/src/aiac/agent/eventbus/consumer.py @@ -33,7 +33,7 @@ from aiac.agent.uc.policy_update.build import build_policy from aiac.agent.uc.role_update.role import update_role from aiac.policy.computation import compute_and_apply -from aiac.policy.model.models import PolicyRule +from aiac.policy.model.models import PolicyRule, RuleEffect logger = logging.getLogger(__name__) @@ -47,7 +47,9 @@ _POLICY_BUILD_SUBJECT = "aiac.apply.policy.build" -def _handle(subject: str) -> tuple[list[PolicyRule], bool]: +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``. if subject.startswith(_SERVICE_PREFIX): return onboard_service(subject[len(_SERVICE_PREFIX) :]) if subject.startswith(_ROLE_PREFIX): @@ -55,9 +57,11 @@ def _handle(subject: str) -> tuple[list[PolicyRule], bool]: # 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". - return update_role(unquote(subject[len(_ROLE_PREFIX) :])) + rules, override = update_role(unquote(subject[len(_ROLE_PREFIX) :])) + return rules, override, RuleEffect.DENY if subject == _POLICY_BUILD_SUBJECT: - return build_policy() + rules, override = build_policy() + return rules, override, RuleEffect.DENY raise ValueError(f"no handler for subject {subject!r}") @@ -116,8 +120,8 @@ async def stop(self) -> None: async def _dispatch(self, msg: Msg) -> None: try: - rules, override = _handle(msg.subject) - compute_and_apply(rules, override) + rules, override, default_effect = _handle(msg.subject) + compute_and_apply(rules, override, default_effect) except Exception: logger.exception("failed to process %s", msg.subject) if msg.metadata.num_delivered >= MAX_DELIVER: diff --git a/aiac/src/aiac/agent/policy_rules_builder/generic_policy.md b/aiac/src/aiac/agent/policy_rules_builder/generic_policy.md index 31370a265..7a4be2937 100644 --- a/aiac/src/aiac/agent/policy_rules_builder/generic_policy.md +++ b/aiac/src/aiac/agent/policy_rules_builder/generic_policy.md @@ -3,4 +3,4 @@ This baseline policy applies to every policy decision, on top of the scenario-specific policy that follows it. Read both together as one policy. -- The agent's internal operator roles are each confined to their own domain: grant every operator role exactly the target operations — where a target is a tool the agent calls, or another agent it calls — within the domain it is responsible for, and nothing outside that domain. +- The agent's internal operator roles are each confined to their own domain: grant every operator role the target operations — where a target is a tool the agent calls, or another agent it calls — within the domain it is responsible for. (This baseline only grants; a pair outside an operator role's domain is simply left ungranted — a silent non-grant — never an explicit prohibition.) diff --git a/aiac/src/aiac/agent/policy_rules_builder/graph.py b/aiac/src/aiac/agent/policy_rules_builder/graph.py index 7ce6f960c..6f1474e5b 100644 --- a/aiac/src/aiac/agent/policy_rules_builder/graph.py +++ b/aiac/src/aiac/agent/policy_rules_builder/graph.py @@ -18,7 +18,7 @@ from tenacity import Retrying, retry_if_exception, stop_after_attempt, wait_exponential from aiac.idp.configuration.models import Role, Scope -from aiac.policy.model.models import PolicyRule +from aiac.policy.model.models import PolicyRule, RuleEffect from aiac.shared.upstream import is_transient, max_retries from .policy_source import get_policy_source @@ -48,25 +48,56 @@ class _Selection(BaseModel): reasoning: str +# The deny name lists + exclusivity flags default to the allow-only-equivalent values +# (no prohibition, not exclusive) so allow-only producers and the pre-#123 mocks keep +# working byte-identically -- the same reason PolicyRule.effect defaults to ALLOW. class RoleSelection(_Selection): granted_scope_names: list[str] + denied_scope_names: list[str] = [] # explicit prohibitions about the focal role + grant_is_exclusive: bool = False # focal role's access is closed to exactly the granted set class ScopeSelection(_Selection): roles_with_access_names: list[str] + roles_denied_access_names: list[str] = [] # explicit prohibitions about the focal scope + access_is_exclusive: bool = False # access to the focal scope is closed to exactly the granted set + + +class Contradiction(BaseModel): + candidate_name: str + description: str # which policy statements collide; names the kind (direct conflict vs coarse-scope) class AuditVerdict(BaseModel): approved: bool reason: str | None = None + contradictions: list[Contradiction] = [] class PolicyRulesBuilderError(RuntimeError): ... +class PolicyContradictionError(Exception): + """Raised when the policy GENUINELY both grants and prohibits the same (focal, candidate) pair + (a direct conflict or a coarse-scope granularity mismatch). Carries the focal entity and ALL + genuine contradictions in a single raise; the PRB fails closed (withholds the focal entity's + whole rule set). This is a policy finding, not a builder failure -- deliberately NOT a + ``PolicyRulesBuilderError`` -- and it short-circuits past retry (retrying can't fix a real + conflict). The *treatment* of a report is a separate, deferred concern.""" + + def __init__(self, focal: str, contradictions: list[Contradiction]): + self.focal = focal + self.contradictions = contradictions + detail = "; ".join(f"{c.candidate_name}: {c.description}" for c in contradictions) + super().__init__(f"Policy contradiction for {focal}: {detail}") + + class _PRBWorking(TypedDict): policy_text: str selected_names: list[str] + denied_names: list[str] + conflict_names: list[str] + exclusive: bool reasoning: str approved: bool audit_feedback: str | None @@ -127,29 +158,62 @@ def _propose( focal: str, candidates: str, contract: str, + direction: str, schema: type[_Selection], names_field: str, + denied_names_field: str, + exclusive_field: str, ) -> dict[str, Any]: - msgs = build_proposer_messages(state["policy_text"], focal, candidates, contract, state["audit_feedback"]) + msgs = build_proposer_messages( + state["policy_text"], focal, candidates, contract, state["audit_feedback"], direction=direction + ) sel = _structured_call(schema, msgs) - return {"selected_names": list(getattr(sel, names_field)), "reasoning": sel.reasoning} + return { + "selected_names": list(getattr(sel, names_field)), + "denied_names": list(getattr(sel, denied_names_field)), + "exclusive": bool(getattr(sel, exclusive_field)), + "reasoning": sel.reasoning, + } def _precheck(state: _PRBWorking, *, candidate_names: set[str]) -> dict[str, Any]: + """Filter both name lists to the candidate set (symmetric hallucination-drop for grants + and denies).""" keep = [n for n in state["selected_names"] if n in candidate_names] dropped = [n for n in state["selected_names"] if n not in candidate_names] - if dropped: - logger.warning("PRB precheck dropped hallucinated names: %s", dropped) - return {"selected_names": keep} - - -def _audit(state: _PRBWorking, *, focal: str, candidates: str) -> dict[str, Any]: + keep_denied = [n for n in state["denied_names"] if n in candidate_names] + dropped_denied = [n for n in state["denied_names"] if n not in candidate_names] + if dropped or dropped_denied: + logger.warning("PRB precheck dropped hallucinated names: granted=%s denied=%s", dropped, dropped_denied) + # Deterministic overlap signal: a candidate in BOTH lists. The derived exclusivity complement + # is disjoint from grants by construction, so overlap can only come from an explicit denied-name + # that is also granted -- a direct conflict or coarse-scope mismatch. precheck resolves nothing; + # the auditor adjudicates each conflict name as genuine (raise) vs generation error (retry). + conflict = [n for n in keep if n in set(keep_denied)] + return {"selected_names": keep, "denied_names": keep_denied, "conflict_names": conflict} + + +def _audit(state: _PRBWorking, *, focal: str, candidates: str, direction: str) -> dict[str, Any]: verdict = _structured_call( AuditVerdict, - build_auditor_messages(state["policy_text"], focal, candidates, state["selected_names"]), + build_auditor_messages( + state["policy_text"], + focal, + candidates, + state["selected_names"], + state["denied_names"], + state["conflict_names"], + direction=direction, + ), ) + # Three-way routing. A genuine contradiction short-circuits past retry (retrying can't fix a + # real conflict) and fails closed regardless of the audit budget; the raise IS the report. + if verdict.contradictions: + raise PolicyContradictionError(focal, verdict.contradictions) if verdict.approved: return {"approved": True} + # Ordinary rejection (includes a generation-error overlap the auditor did NOT deem genuine): + # feed the reason back and re-propose on the shared budget. if state["retry_count"] >= MAX_AUDIT_RETRIES: raise PolicyRulesBuilderError(f"Auditor rejected after {MAX_AUDIT_RETRIES} retries: {verdict.reason}") return {"approved": False, "audit_feedback": verdict.reason, "retry_count": state["retry_count"] + 1} @@ -175,8 +239,49 @@ def _role_cands(rs: list[Role]) -> str: return "\n".join(_role_focal(r) for r in rs) -_ROLE_CONTRACT = "Return granted_scope_names (subset of candidate scope names) + reasoning." -_SCOPE_CONTRACT = "Return roles_with_access_names (subset of candidate role names) + reasoning." +_ROLE_CONTRACT = ( + "Return granted_scope_names (subset of candidate scope names), denied_scope_names (explicit " + "prohibitions, subset of candidates), grant_is_exclusive + reasoning." +) +_SCOPE_CONTRACT = ( + "Return roles_with_access_names (subset of candidate role names), roles_denied_access_names " + "(explicit prohibitions, subset of candidates), access_is_exclusive + reasoning." +) + +# Explicit gate-direction framing, passed to BOTH the proposer and the auditor (the auditor +# previously got NO axis hint, so a focal whose name echoes a policy domain -- e.g. an agent +# ``*.source_operations`` role -- dragged it onto the SUBJECT axis and it adjudicated the +# proposal against user roles that are not candidates at all). Each string names what the focal +# is, what the candidates are, and that entities named only in the policy prose are NOT candidates. +_ROLE_DIRECTION = ( + "GATE DIRECTION -- capability gate. The FOCAL ENTITY is a ROLE; every CANDIDATE is a SCOPE. " + "Decide which candidate SCOPES the focal role is granted (and, only if the SCENARIO policy " + "prohibits or restricts this role, which it is denied). A grant rests on the focal role's OWN " + "capability description matched to a candidate scope's description (rule 3) plus any scenario-" + "policy statement about THIS role. Every name you output MUST be one of the candidate SCOPES " + "listed below: any other entity -- a user role, a subject, anything named only in the policy " + "prose -- is NOT a candidate in this gate, must never appear in your grant or prohibition lists, " + "and is not by itself a basis to grant or deny the focal role." +) +_SCOPE_DIRECTION = ( + "GATE DIRECTION -- subject gate. The FOCAL ENTITY is a SCOPE; every CANDIDATE is a ROLE. " + "Decide which candidate ROLES are granted access to the focal scope (and, only if the SCENARIO " + "policy prohibits or restricts, which are denied). Every name you output MUST be one of the " + "candidate ROLES listed below: any other entity -- a scope, a capability, anything named only in " + "the policy prose -- is NOT a candidate in this gate and must never appear in your grant or " + "prohibition lists." +) + + +def _denied_names(explicit: list[str], exclusive: bool, candidate_order: list[str], granted: set[str]) -> set[str]: + """The set of candidate names to DENY: the explicit prohibitions, plus -- when the grant is + exclusive -- the derived complement (every candidate not granted). The complement is DERIVED + from the typed candidate set (complete by construction), never LLM-enumerated, and is disjoint + from grants by construction.""" + denied = set(explicit) + if exclusive: + denied |= {c for c in candidate_order if c not in granted} + return denied def _assemble(state_type: type, propose, precheck, audit, build): @@ -204,19 +309,35 @@ def propose(s: RoleRulesState) -> dict[str, Any]: focal=_role_focal(s["role"]), candidates=_scope_cands(s["scopes"]), contract=_ROLE_CONTRACT, + direction=_ROLE_DIRECTION, schema=RoleSelection, names_field="granted_scope_names", + denied_names_field="denied_scope_names", + exclusive_field="grant_is_exclusive", ) def precheck(s: RoleRulesState) -> dict[str, Any]: return _precheck(s, candidate_names={sc.name for sc in s["scopes"]}) def audit(s: RoleRulesState) -> dict[str, Any]: - return _audit(s, focal=_role_focal(s["role"]), candidates=_scope_cands(s["scopes"])) + return _audit( + s, focal=_role_focal(s["role"]), candidates=_scope_cands(s["scopes"]), direction=_ROLE_DIRECTION + ) def build(s: RoleRulesState) -> dict[str, Any]: + # ALLOW from granted names, DENY from explicit prohibitions -- every rule rebuilt from the + # typed scopes (never LLM string fields). Allows first, then denies, each in candidate order. + denied = _denied_names( + s["denied_names"], s["exclusive"], [sc.name for sc in s["scopes"]], set(s["selected_names"]) + ) granted = set(s["selected_names"]) - return {"rules": [PolicyRule(role=s["role"], scope=sc) for sc in s["scopes"] if sc.name in granted]} + allows = [ + PolicyRule(role=s["role"], scope=sc, effect=RuleEffect.ALLOW) for sc in s["scopes"] if sc.name in granted + ] + denies = [ + PolicyRule(role=s["role"], scope=sc, effect=RuleEffect.DENY) for sc in s["scopes"] if sc.name in denied + ] + return {"rules": allows + denies} return _assemble(RoleRulesState, propose, precheck, audit, build) @@ -228,19 +349,33 @@ def propose(s: ScopeRulesState) -> dict[str, Any]: focal=_scope_focal(s["scope"]), candidates=_role_cands(s["roles"]), contract=_SCOPE_CONTRACT, + direction=_SCOPE_DIRECTION, schema=ScopeSelection, names_field="roles_with_access_names", + denied_names_field="roles_denied_access_names", + exclusive_field="access_is_exclusive", ) def precheck(s: ScopeRulesState) -> dict[str, Any]: return _precheck(s, candidate_names={r.name for r in s["roles"]}) def audit(s: ScopeRulesState) -> dict[str, Any]: - return _audit(s, focal=_scope_focal(s["scope"]), candidates=_role_cands(s["roles"])) + return _audit( + s, focal=_scope_focal(s["scope"]), candidates=_role_cands(s["roles"]), direction=_SCOPE_DIRECTION + ) def build(s: ScopeRulesState) -> dict[str, Any]: + # ALLOW from granted names, DENY from explicit prohibitions -- every rule rebuilt from the + # typed roles (never LLM string fields). Allows first, then denies, each in candidate order. + denied = _denied_names( + s["denied_names"], s["exclusive"], [r.name for r in s["roles"]], set(s["selected_names"]) + ) granted = set(s["selected_names"]) - return {"rules": [PolicyRule(role=r, scope=s["scope"]) for r in s["roles"] if r.name in granted]} + allows = [ + PolicyRule(role=r, scope=s["scope"], effect=RuleEffect.ALLOW) for r in s["roles"] if r.name in granted + ] + denies = [PolicyRule(role=r, scope=s["scope"], effect=RuleEffect.DENY) for r in s["roles"] if r.name in denied] + return {"rules": allows + denies} return _assemble(ScopeRulesState, propose, precheck, audit, build) @@ -255,6 +390,9 @@ def build_role_rules(role: Role, scopes: list[Scope]) -> list[PolicyRule]: "scopes": scopes, "policy_text": "", "selected_names": [], + "denied_names": [], + "conflict_names": [], + "exclusive": False, "reasoning": "", "approved": False, "audit_feedback": None, @@ -270,6 +408,9 @@ def build_scope_rules(roles: list[Role], scope: Scope) -> list[PolicyRule]: "scope": scope, "policy_text": "", "selected_names": [], + "denied_names": [], + "conflict_names": [], + "exclusive": False, "reasoning": "", "approved": False, "audit_feedback": None, diff --git a/aiac/src/aiac/agent/policy_rules_builder/prompts.py b/aiac/src/aiac/agent/policy_rules_builder/prompts.py index 83155c4b1..5de55f218 100644 --- a/aiac/src/aiac/agent/policy_rules_builder/prompts.py +++ b/aiac/src/aiac/agent/policy_rules_builder/prompts.py @@ -29,9 +29,15 @@ def _policy_block(policy_text: str) -> str: - """Compose the POLICY block: least-privilege directive, then the generic baseline, then the - scenario policy.""" - return f"{_GRANT_ACCESS}\n\n{_GENERIC_POLICY}\n\n{policy_text}" + """Compose the POLICY block in three labeled layers: the least-privilege directive, then the + generic baseline (explicitly grants-only — never a source of denials), then the scenario policy. + The labels let the deny/exclusivity rules bind to the SCENARIO layer only.""" + return ( + f"{_GRANT_ACCESS}\n\n" + f"BASELINE POLICY (grants only — never a source of denials):\n{_GENERIC_POLICY}\n\n" + f"SCENARIO POLICY:\n{policy_text}" + ) + _SAFETY = ( "Rules:\n" @@ -40,8 +46,14 @@ def _policy_block(policy_text: str) -> str: "establishing that the candidate performs an operation the scope covers (see rule 3). Policy " "silence is not by itself a reason to deny a pair the descriptions already establish, nor a " "license to grant one they do not; when neither the policy nor those descriptions support the " - "pair, do not grant. A statement about any OTHER entity is never support (see rule 4).\n" - "2) Stay strictly scoped to the single focal entity described below; ignore anything else." + "pair, do not grant. Silence is a silent non-grant (no rule at all) — NOT an explicit " + "prohibition (see rule 5). A statement about any OTHER entity is never support (see rule 4).\n" + "2) Stay strictly scoped to the single focal entity described below; ignore anything else. A " + "grant requires the focal entity and the candidate to operate in the SAME domain: never pair " + "across domains — an issues-domain role/scope with a source-domain scope/role, or vice versa — " + "and a candidate whose domain does not match the focal entity's, or that the policy never " + "connects to it, earns nothing. (Cross-GRANULARITY within one domain — a fine operation earning " + "the coarse capability that covers it — is fine and is rule 3; cross-DOMAIN never is.)" ) # Shared mapping rules appended to BOTH system messages so the proposer and the auditor decide grants @@ -64,21 +76,68 @@ def _policy_block(policy_text: str) -> str: _MAPPING_RULES = ( "\n3) A scope or capability names a set of operations (see its description). Grant it to a " "candidate when the policy — or the focal entity's and the candidate's own descriptions — shows " - "that candidate performs ANY operation the scope covers; partial access (e.g. read-only) still " - "grants the scope. A candidate shown to perform no covered operation is denied (rule 1).\n" + "that candidate performs ANY operation the scope covers. Projection is UPWARD only: a shown " + "operation earns a COARSER capability scope that already INCLUDES that operation — a candidate " + "shown to read issues earns an issue-management capability (which covers reading). It NEVER " + "crosses to a SIBLING operation the candidate is not shown to perform: read access alone earns " + "no write scope (issues-read does NOT imply issues-write), and write access earns no read-only " + "scope. Grant each fine-grained scope strictly on the operation it names. A candidate shown to " + "perform no covered operation is simply not granted (rule 1); that is a non-grant, not a " + "prohibition.\n" "4) A policy may describe several different access relationships over the same entities. Judge " "each candidate independently, by what the policy or the descriptions establish for THAT candidate " "in relation to the focal entity. Base each grant only on evidence about that specific candidate " "and the focal entity; a statement about any OTHER entity — even one sharing the same domain or " "theme (e.g. a differently-named role or subject with related access) — concerns a different " "relationship and is never evidence for or against the grant, even when it names the focal entity " - "or the scope." + "or the scope. ONE SANCTIONED EXCEPTION: exclusive/restrictive scoping ABOUT THE FOCAL ENTITY " + "(rule 6) is legitimate evidence to deny the complement — that is the only cross-candidate " + "inference allowed." +) + +# Deny / exclusivity contract — appended to BOTH the proposer and auditor system messages so the +# two halves of the LLM contract cannot diverge. Deny extraction is SCENARIO-only; the baseline +# is grants-only. +_DENY_RULES = ( + "\nThe remaining rules concern PROHIBITIONS and apply to the SCENARIO policy ONLY. If the " + "scenario policy contains no prohibitive language (rule 5) and no exclusivity wording (rule 6), " + "return EMPTY denied lists and exclusivity=false — a purely permissive policy prohibits nothing; " + "never invent a prohibition to hedge.\n" + "5) EXPLICIT PROHIBITIONS -> deny. Prohibitive language in the SCENARIO policy about a " + "specific pair — 'must not', 'cannot', 'may not', 'is forbidden', 'never', 'except', 'but not', " + "'read-only' / 'may read but not write' — records that candidate as a PROHIBITION (a durable " + "DENY), not merely a non-grant. This applies to the scenario policy ONLY: the baseline policy is " + "grants-only and is NEVER a source of prohibitions. Silence about a pair, and a plain " + "non-exclusive grant, impose NOTHING on anything else — they never deny.\n" + "6) EXCLUSIVITY ('only'). Restrictive/exclusive language about the FOCAL entity — 'only', 'solely', " + "'exclusively', 'nothing else' — means the focal entity's access is closed to EXACTLY the granted " + "set. Signal this by setting the exclusivity flag true; do NOT enumerate the other candidates " + "yourself (the builder derives the complete complement from the candidate set). A non-exclusive " + "grant leaves the flag false and denies nothing.\n" + "7) The grant list and the prohibition list are MUTUALLY EXCLUSIVE, except when the scenario " + "policy genuinely establishes BOTH a grant and a prohibition for the same candidate (a direct " + "conflict, or a coarse scope partly permitted and partly forbidden) — then, and only then, list " + "that candidate in both. That overlap is the contradiction signal; never invent it to hedge." ) -_PROPOSER_SYSTEM = "You map access policy to concrete grants.\n" + _SAFETY + _MAPPING_RULES +_PROPOSER_SYSTEM = ( + "You map an access policy to concrete GRANTS. Your primary task is to select the granted " + "candidates for the focal entity under least-privilege. Only when the scenario policy explicitly " + "prohibits or restricts access do you also report the prohibited candidates and whether access " + "is exclusive; for a purely permissive policy those are empty.\n" + + _SAFETY + + _MAPPING_RULES + + _DENY_RULES +) _AUDITOR_SYSTEM = ( - "You audit a proposed set of grants. Approve only if every granted pair is " - "policy-supported and nothing unsupported slipped in.\n" + _SAFETY + _MAPPING_RULES + "You audit a proposed set of grants and prohibitions. Approve only if every granted pair is " + "policy-supported — REJECT any grant unsupported by the policy or the descriptions, any grant in " + "a domain the candidate is not shown to act in, and any grant for a candidate the policy never " + "mentions. Every prohibited pair must be a genuine explicit-prohibition or exclusivity deny, the " + "exclusivity flag must be truly asserted by the SCENARIO policy, and for a purely permissive " + "policy both denied lists must be empty. When a candidate is named in BOTH lists (a conflict), " + "adjudicate it: a genuine grant-and-prohibit collision is a contradiction (report it), a mere " + "proposer slip is an ordinary rejection.\n" + _SAFETY + _MAPPING_RULES + _DENY_RULES ) @@ -88,8 +147,16 @@ def build_proposer_messages( candidates: str, contract: str, audit_feedback: str | None, + *, + direction: str, ) -> list[BaseMessage]: - body = f"POLICY:\n{_policy_block(policy_text)}\n\nFOCAL ENTITY:\n{focal}\n\nCANDIDATES:\n{candidates}\n\n{contract}" + # ``direction`` leads the body so the gate axis (what the focal is, what the candidates are, and + # that entities named only in the policy prose are NOT candidates) frames how the policy is read + # — without it a focal whose name echoes a policy domain drags the model onto the wrong axis. + body = ( + f"{direction}\n\nPOLICY:\n{_policy_block(policy_text)}\n\n" + f"FOCAL ENTITY:\n{focal}\n\nCANDIDATES:\n{candidates}\n\n{contract}" + ) if audit_feedback: body += f"\n\nA prior proposal was REJECTED. Fix per this feedback:\n{audit_feedback}" return [SystemMessage(content=_PROPOSER_SYSTEM), HumanMessage(content=body)] @@ -100,9 +167,25 @@ def build_auditor_messages( focal: str, candidates: str, selected_names: list[str], + denied_names: list[str], + conflict_names: list[str], + *, + direction: str, ) -> list[BaseMessage]: + # Same ``direction`` framing as the proposer: the auditor previously got NO axis hint (the + # proposer alone received the contract), which let it adjudicate against the wrong candidate set. body = ( - f"POLICY:\n{_policy_block(policy_text)}\n\nFOCAL ENTITY:\n{focal}\n\nCANDIDATES:\n{candidates}\n\n" - f"PROPOSED SELECTION (names): {selected_names}" + f"{direction}\n\nPOLICY:\n{_policy_block(policy_text)}\n\n" + f"FOCAL ENTITY:\n{focal}\n\nCANDIDATES:\n{candidates}\n\n" + f"PROPOSED GRANTS (names): {selected_names}\n" + f"PROPOSED PROHIBITIONS (names): {denied_names}" ) + if conflict_names: + body += ( + f"\n\nCONFLICT (named in BOTH lists): {conflict_names}. For each, decide whether the " + "policy GENUINELY both grants and prohibits it -- a direct conflict, or a coarse scope " + "partly permitted and partly forbidden -- versus a mere proposer error. Report genuine " + "ones in `contradictions` (name the kind in each description); if it is just a proposer " + "mistake, leave `contradictions` empty and reject with a reason so it can re-propose." + ) return [SystemMessage(content=_AUDITOR_SYSTEM), HumanMessage(content=body)] diff --git a/aiac/src/aiac/agent/uc/onboarding/orchestrator.py b/aiac/src/aiac/agent/uc/onboarding/orchestrator.py index 1144082fd..66fc505b1 100644 --- a/aiac/src/aiac/agent/uc/onboarding/orchestrator.py +++ b/aiac/src/aiac/agent/uc/onboarding/orchestrator.py @@ -20,13 +20,22 @@ from aiac.agent.uc.onboarding.policy_builder.builder import ServicePolicyBuilder from aiac.agent.uc.onboarding.provision.graph import build_provision_graph from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger -from aiac.policy.model.models import PolicyRule +from aiac.policy.model.models import PolicyRule, RuleEffect -def onboard_service(service_id: str) -> tuple[list[PolicyRule], bool]: +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)``. + + ``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).""" provision = build_provision_graph().invoke( OnboardingProvisionState(trigger=Trigger(entity_id=service_id)) ) service_type = provision["service_type"] rules = ServicePolicyBuilder.build(service_id, service_type) - return rules, False + return rules, False, default_effect diff --git a/aiac/src/aiac/agent/uc/policy_update/build.py b/aiac/src/aiac/agent/uc/policy_update/build.py index 5e4e14217..542427469 100644 --- a/aiac/src/aiac/agent/uc/policy_update/build.py +++ b/aiac/src/aiac/agent/uc/policy_update/build.py @@ -2,6 +2,10 @@ Full incremental build lands in 3.7. Its ``override`` value is resolved in 6.4; until then the stub returns ``override=False`` (additive merge). + +Build is allow-only: the ``PolicyRule``s it will emit default to +``RuleEffect.ALLOW`` (deny extraction is deferred), so behavior is unchanged +under the ALLOW/DENY policy-rule model. """ from aiac.policy.model.models import PolicyRule diff --git a/aiac/src/aiac/agent/uc/policy_update/rebuild.py b/aiac/src/aiac/agent/uc/policy_update/rebuild.py index 10f4a6ae9..2977ec6f4 100644 --- a/aiac/src/aiac/agent/uc/policy_update/rebuild.py +++ b/aiac/src/aiac/agent/uc/policy_update/rebuild.py @@ -2,6 +2,10 @@ Full authoritative rebuild lands in 3.8. Rebuild is authoritative, so it returns ``override=True`` (role-keyed replace in the PCE). HTTP-only trigger. + +Like Build, Rebuild is allow-only: the ``PolicyRule``s it will emit default to +``RuleEffect.ALLOW`` (deny extraction is deferred), so behavior is unchanged +under the ALLOW/DENY policy-rule model. """ from aiac.policy.model.models import PolicyRule diff --git a/aiac/src/aiac/pdp/service/policy/opa/rego.py b/aiac/src/aiac/pdp/service/policy/opa/rego.py index 3d0fb5b5c..d19128a9f 100644 --- a/aiac/src/aiac/pdp/service/policy/opa/rego.py +++ b/aiac/src/aiac/pdp/service/policy/opa/rego.py @@ -20,12 +20,26 @@ - ``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. + +**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:: + + # 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 } + +The identity maps (``subject_roles`` / ``source_roles``) are **effect-agnostic**, +so a principal that appears only in a DENY rule still resolves and its +prohibition fires. """ import json import re -from aiac.policy.model.models import AgentPolicyModel, PolicyRule +from aiac.policy.model.models import AgentPolicyModel, PolicyRule, RuleEffect __all__ = ["identity_ref", "generate_inbound_rego", "generate_outbound_rego"] @@ -131,9 +145,10 @@ def _group_rules(rules: list[PolicyRule]) -> dict[str, list[str]]: def _group_rules_deprefixed(rules: list[PolicyRule]) -> dict[str, list[str]]: """Like ``_group_rules`` but de-prefixes each scope value (outbound only). - Groups ``{role.name: [_deprefix(scope), ...]}`` — used for - ``subject_role_scopes`` / ``agent_role_scopes``, whose values must match the - bare ``input.mcp.params.name``.""" + Groups ``{role.name: [_deprefix(scope), ...]}`` — used for the outbound + ``subject_role_allow_scopes`` / ``subject_role_deny_scopes`` / + ``agent_role_scopes`` maps, whose values must match the bare + ``input.mcp.params.name``.""" grouped: dict[str, list[str]] = {} for rule in rules: scopes = grouped.setdefault(rule.role.name, []) @@ -154,7 +169,7 @@ def _name_map(mapping) -> dict[str, list[str]]: def _name_map_deprefixed(mapping) -> dict[str, list[str]]: - """Like ``_name_map`` but de-prefixes each value (outbound ``target_scopes``). + """Like ``_name_map`` but de-prefixes each value (outbound ``target_*_scopes``). Keys stay the **full** target service id (they match ``input.identity.service_id``, a full SPIFFE ID); only the scope *values* @@ -165,78 +180,182 @@ def _name_map_deprefixed(mapping) -> dict[str, list[str]]: } -# The inbound subject gate: the subject holds a role that grants at least one of -# the agent's own scopes (compared internally against agent_scopes, using FULL -# scope names — never against input.mcp.params.name). -_INBOUND_SUBJECT_OK = ( - "subject_ok if {\n" - " some role in subject_roles[input.identity.subject]\n" - " some scope in role_scopes[role]\n" - " scope in agent_scopes\n" - "}" -) - -# The outbound subject gate: the delegated user's role admits the invoked tool -# (bare input.mcp.params.name is in that role's de-prefixed subject_role_scopes). -_OUTBOUND_SUBJECT_OK = ( - "subject_ok if {\n" - " some role in subject_roles[input.identity.subject]\n" - " input.mcp.params.name in subject_role_scopes[role]\n" - "}" -) - -# The outbound capability gate: the target service (keyed by its full SPIFFE id) -# admits the invoked tool. This — not agent_role_scopes — is the capability gate. -_OUTBOUND_TARGET_OK = ( - "target_ok if {\n" - " input.mcp.params.name in target_scopes[input.identity.service_id]\n" - "}" -) +# --- inbound gate templates ------------------------------------------------- +# +# The inbound subject gate is emitted twice against the SAME shape: an +# ``*_allow_ok`` gate reads the ALLOW scope map, a symmetric ``*_deny_ok`` gate +# reads the DENY scope map. Both require the matched scope to be one of the +# agent's own ``agent_scopes`` (the inbound audience), compared internally with +# FULL scope names — never against ``input.mcp.params.name``. A subject/source +# that only appears in a DENY rule still resolves because the identity maps +# (``subject_roles`` / ``source_roles``) are effect-agnostic. -def generate_inbound_rego( - model: AgentPolicyModel, platform_clients: tuple[str, ...] = ("rossoctl",) -) -> str: - """Render the fixed ``authbridge.client.inbound.request`` Rego package. +def _inbound_subject_gate(gate: str, scope_map: str) -> str: + return ( + f"{gate} if {{\n" + " some role in subject_roles[input.identity.subject]\n" + f" some scope in {scope_map}[role]\n" + " scope in agent_scopes\n" + "}" + ) - Gates a caller reaching the agent. ``allow`` requires ``subject_ok`` (the - subject holds a role granting >=1 of ``agent_scopes``) AND ``source_ok``. - ``source_ok`` passes when there is no calling ``client_id`` (end-user - traffic), when the ``client_id`` is one of ``platform_clients`` (the - mandatory bypass — one ``source_ok if { input.identity.client_id == "" }`` - rule per client; without it end-user traffic, which carries the platform - client, would be denied), or when that client holds a role granting an agent - scope. Inbound values are **not** de-prefixed — the gate compares scopes - internally, never against ``input.mcp.params.name``. +def _inbound_source_allow_gate(platform_clients: tuple[str, ...]) -> str: + """The inbound source ALLOW gate. + + Passes when there is no calling ``client_id`` (end-user traffic), when the + ``client_id`` is one of ``platform_clients`` (the mandatory bypass — one rule + per client; without it end-user traffic, which carries the platform client, + would be denied), or when that client holds a role granting an agent scope. """ - # This first rule is inbound-only in practice: it fires for unauthenticated - # callers (no validated JWT, so input.identity.client_id is unset). It never - # fires on the outbound leg, where buildOutboundIdentity always populates - # client_id (as "" when agent_id is unset), so `not ...` is never true there. - source_ok_rules = ["source_ok if { not input.identity.client_id }"] + rules = ["source_allow_ok if { not input.identity.client_id }"] for client in platform_clients: - source_ok_rules.append( - f"source_ok if {{ input.identity.client_id == {json.dumps(client)} }}" + rules.append( + f"source_allow_ok if {{ input.identity.client_id == {json.dumps(client)} }}" ) - source_ok_rules.append( - "source_ok if {\n" + rules.append( + "source_allow_ok if {\n" " some role in source_roles[input.identity.client_id]\n" - " some scope in role_scopes[role]\n" + " some scope in source_role_allow_scopes[role]\n" " scope in agent_scopes\n" "}" ) + return "\n".join(rules) + + +def _inbound_source_deny_gate() -> str: + """The inbound source DENY gate. + + An absent client_id (or a platform client) has no roles here, so this gate + simply never fires for it — the ALLOW-side bypass is not undone by a deny. + """ + return ( + "source_deny_ok if {\n" + " some role in source_roles[input.identity.client_id]\n" + " some scope in source_role_deny_scopes[role]\n" + " scope in agent_scopes\n" + "}" + ) + + +# --- 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. + + +def _outbound_subject_gate(gate: str, scope_map: str) -> str: + return ( + f"{gate} if {{\n" + " some role in subject_roles[input.identity.subject]\n" + f" input.mcp.params.name in {scope_map}[role]\n" + "}" + ) + + +def _outbound_target_gate(gate: str, scope_map: str) -> str: + return ( + f"{gate} if {{\n" + f" input.mcp.params.name in {scope_map}[input.identity.service_id]\n" + "}" + ) + + +# --- trailing decision block (the only thing default_effect changes) -------- +# +# 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. + + ``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 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: + ``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. + + ``source_allow_ok`` passes when there is no calling ``client_id`` (end-user + traffic), when the ``client_id`` is one of ``platform_clients`` (the + mandatory bypass), or when that client holds a role granting an agent scope. + Inbound values are **not** de-prefixed — the gates compare scopes internally + against ``agent_scopes``, never against ``input.mcp.params.name``. + """ declarations = "\n".join( [ _render_map("subject_roles", _name_map(model.subject_roles)), _render_map("source_roles", _name_map(model.source_roles)), - _render_map("role_scopes", _group_rules(model.inbound_rules)), + _render_map( + "subject_role_allow_scopes", + _group_rules(model.inbound_subject_allow_rules), + ), + _render_map( + "subject_role_deny_scopes", + _group_rules(model.inbound_subject_deny_rules), + ), + _render_map( + "source_role_allow_scopes", + _group_rules(model.inbound_source_allow_rules), + ), + _render_map( + "source_role_deny_scopes", + _group_rules(model.inbound_source_deny_rules), + ), ] ) rules = "\n".join( - [_INBOUND_SUBJECT_OK] - + source_ok_rules - + ["default allow := false\nallow if { subject_ok; source_ok }"] + [ + _inbound_subject_gate("subject_allow_ok", "subject_role_allow_scopes"), + _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"), + ), + ] ) parts = [ "package authbridge.client.inbound.request\nimport rego.v1", @@ -251,40 +370,64 @@ 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. ``allow`` is an AND on the **same** ``input.mcp.params.name``: - ``subject_ok`` (the delegated user's role admits the tool, via de-prefixed - ``subject_role_scopes``) AND ``target_ok`` (the target service — keyed by the - full ``input.identity.service_id`` SPIFFE id — admits the tool, via - de-prefixed ``target_scopes`` values). + tool. The decision is deny-overrides 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 + id — admits the tool, via de-prefixed ``target_allow_scopes``), and fires only + when neither ``subject_deny_ok`` nor ``target_deny_ok`` matches. ``agent_roles`` / ``agent_role_scopes`` are emitted for debugging but are - **not** referenced by ``allow`` — ``target_scopes[input.identity.service_id]`` + **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 ``role_scopes`` gate. + nor the inbound scope gates. """ declarations = "\n".join( [ _render_list("agent_roles", _names(model.agent_roles)), _render_map("subject_roles", _name_map(model.subject_roles)), _render_map( - "subject_role_scopes", - _group_rules_deprefixed(model.outbound_subject_rules), + "subject_role_allow_scopes", + _group_rules_deprefixed(model.outbound_subject_allow_rules), + ), + _render_map( + "subject_role_deny_scopes", + _group_rules_deprefixed(model.outbound_subject_deny_rules), ), # agent_role_scopes is emitted for debugging/observability only; the - # allow decision never references it (target_scopes is the capability - # gate). The leading Rego comment says so in the rendered bundle. + # allow decision never references it (target_allow_scopes is the + # capability gate). The leading Rego comment says so in the bundle. "# informational/debugging only — not referenced by allow\n" + _render_map( - "agent_role_scopes", _group_rules_deprefixed(model.outbound_rules) + "agent_role_scopes", + _group_rules_deprefixed(model.outbound_target_allow_rules), + ), + _render_map( + "target_allow_scopes", _name_map_deprefixed(model.target_allow_scopes) + ), + _render_map( + "target_deny_scopes", _name_map_deprefixed(model.target_deny_scopes) ), - _render_map("target_scopes", _name_map_deprefixed(model.target_scopes)), ] ) rules = "\n".join( [ - _OUTBOUND_SUBJECT_OK, - _OUTBOUND_TARGET_OK, - "default allow := false\nallow if { subject_ok; target_ok }", + _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. + _decision_block( + model.default_effect, + "subject_allow_ok; target_allow_ok; " + "not subject_deny_ok; not target_deny_ok", + ("subject_deny_ok", "target_deny_ok"), + ), ] ) parts = [ diff --git a/aiac/src/aiac/policy/computation/engine.py b/aiac/src/aiac/policy/computation/engine.py index 9055227de..abc92434e 100644 --- a/aiac/src/aiac/policy/computation/engine.py +++ b/aiac/src/aiac/policy/computation/engine.py @@ -46,6 +46,7 @@ AgentPolicyModel, PolicyModel, PolicyRule, + RuleEffect, ServicePolicyModel, ) from aiac.policy.model_store.library.api import ( @@ -61,8 +62,14 @@ def _add_rule(rules: list[PolicyRule], rule: PolicyRule) -> None: - """Append ``rule`` unless one with the same ``role.id`` + ``scope.id`` is present.""" - if any(r.role.id == rule.role.id and r.scope.id == rule.scope.id for r in rules): + """Append ``rule`` unless one with the same dedup identity ``(role.id, scope.id, effect)`` is + present. Each list this is called on is single-effect (routing splits by ``effect`` first), so + within a list the check reduces to ``(role.id, scope.id)``; carrying ``effect`` keeps the + identity aligned with the model's canonical dedup key.""" + if any( + r.role.id == rule.role.id and r.scope.id == rule.scope.id and r.effect == rule.effect + for r in rules + ): return rules.append(rule) @@ -74,6 +81,38 @@ def _add_by_id(items: list[_Entity], item: _Entity) -> None: items.append(item) +def _inbound_list(model: ServicePolicyModel, effect: RuleEffect) -> list[PolicyRule]: + """The inbound list on ``model`` matching ``effect`` — the deny list for ``Deny``, else allow.""" + return model.inbound_deny_rules if effect == RuleEffect.DENY else model.inbound_allow_rules + + +def _all_inbound(model: ServicePolicyModel) -> list[PolicyRule]: + """A read-only concatenation of both inbound lists — every edge touching ``model``'s scopes, + ``Allow`` and ``Deny`` alike. For scanning (classification, targeter discovery); never mutate.""" + return model.inbound_allow_rules + model.inbound_deny_rules + + +def _route(model: ServicePolicyModel, rule: PolicyRule) -> bool: + """Append ``rule`` to ``model``'s effect-matching inbound list (append-dedup). True iff added.""" + target = _inbound_list(model, rule.effect) + before = len(target) + _add_rule(target, rule) + return len(target) != before + + +def _purge_role(model: ServicePolicyModel, role_id: str) -> bool: + """Drop every inbound edge whose role is ``role_id`` from **both** lists (allow and deny) — the + role-level revocation / footprint-purge primitive. Returns ``True`` iff any edge was removed.""" + removed = False + for attr in ("inbound_allow_rules", "inbound_deny_rules"): + rules: list[PolicyRule] = getattr(model, attr) + kept = [r for r in rules if r.role.id != role_id] + if len(kept) != len(rules): + setattr(model, attr, kept) + removed = True + return removed + + def _reconcile( model: ServicePolicyModel, catalog: dict[str, Service], @@ -100,43 +139,52 @@ def _reconcile( this batch carries a *different* id for that same ``(scope, name)`` — the fresh batch's current-generation id supersedes the old one. - Skips pruning entirely when ``X`` is absent from the catalog (a transient miss must never wipe an - SPM). Returns ``True`` iff it removed at least one edge. + Runs over **both** inbound lists (allow and deny) independently: the churn collapse is computed + per list, so a live ``Deny`` edge is never dropped because an unrelated ``Allow`` edge in the + batch shares its ``(scope, name)``. Skips pruning entirely when ``X`` is absent from the catalog + (a transient miss must never wipe an SPM). Returns ``True`` iff it removed at least one edge. """ if catalog.get(model.service_id) is None: return False owner_scope_ids = {s.id for s in model.owned_scopes} - # (1)+(2) existence prune. - survivors = [ - edge - for edge in model.inbound_rules - if edge.scope.id in owner_scope_ids - and not (edge.role.kind == RoleKind.AGENT and edge.role.id not in catalog_agent_role_ids) - ] - - # (3) user-role churn collapse: a stale generation is dropped only when this batch carries a - # different id for the same (scope, name). - batch_ids_by_key: dict[tuple[str, str], set[str]] = {} - for edge in survivors: - if edge.role.kind == RoleKind.USER and edge.role.id in batch_user_role_ids: - batch_ids_by_key.setdefault((edge.scope.id, edge.role.name), set()).add(edge.role.id) - - kept = [ - edge - for edge in survivors - if not ( - edge.role.kind == RoleKind.USER - and edge.role.id not in batch_ids_by_key.get((edge.scope.id, edge.role.name), set()) - and batch_ids_by_key.get((edge.scope.id, edge.role.name)) - ) - ] - - if len(kept) != len(model.inbound_rules): - model.inbound_rules = kept - return True - return False + def _prune(edges: list[PolicyRule]) -> list[PolicyRule]: + # (1)+(2) existence prune. + survivors = [ + edge + for edge in edges + if edge.scope.id in owner_scope_ids + and not ( + edge.role.kind == RoleKind.AGENT and edge.role.id not in catalog_agent_role_ids + ) + ] + + # (3) user-role churn collapse: a stale generation is dropped only when this batch carries a + # different id for the same (scope, name). + batch_ids_by_key: dict[tuple[str, str], set[str]] = {} + for edge in survivors: + if edge.role.kind == RoleKind.USER and edge.role.id in batch_user_role_ids: + batch_ids_by_key.setdefault((edge.scope.id, edge.role.name), set()).add(edge.role.id) + + return [ + edge + for edge in survivors + if not ( + edge.role.kind == RoleKind.USER + and edge.role.id not in batch_ids_by_key.get((edge.scope.id, edge.role.name), set()) + and batch_ids_by_key.get((edge.scope.id, edge.role.name)) + ) + ] + + changed = False + for attr in ("inbound_allow_rules", "inbound_deny_rules"): + edges: list[PolicyRule] = getattr(model, attr) + kept = _prune(edges) + if len(kept) != len(edges): + setattr(model, attr, kept) + changed = True + return changed def _spm_cache(catalog: dict[str, Service]): @@ -174,35 +222,56 @@ def is_agent(service_id: str) -> bool: return spms, spm, is_agent -def _fresh_apm(agent_id: str) -> AgentPolicyModel: +def _fresh_apm( + agent_id: str, default_effect: RuleEffect = RuleEffect.DENY +) -> 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. return AgentPolicyModel( agent_id=agent_id, + default_effect=default_effect, agent_roles=[], agent_scopes=[], source_roles={}, subject_roles={}, - target_scopes={}, - inbound_rules=[], - outbound_rules=[], - outbound_subject_rules=[], ) -def compute_and_apply(rules: list[PolicyRule], override: bool = False) -> None: +def compute_and_apply( + rules: list[PolicyRule], + override: bool = False, + default_effect: RuleEffect = RuleEffect.DENY, +) -> None: """Route, persist, derive, and apply ``rules`` — fire-and-forget. ``override`` selects the merge mode at the SPM layer. ``False`` (default) appends each rule - additively to ``SPM(scope.serviceId).inbound_rules`` (dedup by ``role.id`` + ``scope.id``). - ``True`` authoritatively replaces every input role's mappings: the distinct input-role set is - purged from **every** SPM containing it, once, up-front, before the fresh rules are appended - (role-level revocation). + additively to the effect-matching inbound list on ``SPM(scope.serviceId)`` (``Deny`` → + ``inbound_deny_rules``, else ``inbound_allow_rules``; dedup by ``role.id`` + ``scope.id`` + + ``effect``). ``True`` authoritatively replaces every input role's mappings: the distinct + 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). + + 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. 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) + _run(rules, override, default_effect) except Exception: logger.exception("compute_and_apply failed for %d rule(s)", len(rules)) raise @@ -234,7 +303,9 @@ def decommission(service_id: str) -> None: raise -def _run(rules: list[PolicyRule], override: bool) -> None: +def _run( + rules: list[PolicyRule], override: bool, default_effect: RuleEffect = RuleEffect.DENY +) -> None: config = Configuration.for_default_realm() # (1) Catalog once — the only runtime IdP read. Carries each service's type (agent vs tool, @@ -259,18 +330,15 @@ def _run(rules: list[PolicyRule], override: bool) -> None: for role in distinct_roles.values(): for stored in get_service_policies_by_role(role): model = spm(stored.service_id) - kept = [r for r in model.inbound_rules if r.role.id != role.id] - if len(kept) != len(model.inbound_rules): - model.inbound_rules = kept + if _purge_role(model, role.id): changed.add(model.service_id) - # (2) Route each rule to the SPM of the service that owns its scope. Append-dedup by - # role.id + scope.id. No write-time classification — kind only matters at derive time. + # (2) Route each rule to the SPM of the service that owns its scope, into the inbound list + # matching its ``effect`` (Deny → inbound_deny_rules, else inbound_allow_rules). Append-dedup by + # role.id + scope.id + effect. No role-kind classification here — kind only matters at derive. for rule in rules: model = spm(rule.scope.serviceId) - before = len(model.inbound_rules) - _add_rule(model.inbound_rules, rule) - if len(model.inbound_rules) != before or override: + if _route(model, rule) or override: changed.add(model.service_id) # (3.5) Reconcile touched SPMs against current IdP truth (get_services()-only — no extra IdP @@ -303,14 +371,19 @@ def _run(rules: list[PolicyRule], override: bool) -> None: if is_agent(owner): affected.add(owner) # the touched owner is an agent — its inbound changed # every agent targeting a scope on this touched SPM: owners of its Agent-kind inbound - # rules (a superset of the exact-scope match — re-deriving is idempotent, so safe). - for edge in spm(owner).inbound_rules: + # edges (allow AND deny — a deny edge also changed that agent's outbound), a superset of + # the exact-scope match (re-deriving is idempotent, so safe). + for edge in _all_inbound(spm(owner)): if edge.role.kind == RoleKind.AGENT: 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) for agent_id in sorted(affected) if is_agent(agent_id)] + 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)) @@ -327,29 +400,30 @@ def _decommission(service_id: str) -> None: # carries the roles/scopes X owned when it was onboarded. Content guard: a 404 fresh-empty SPM # (never onboarded / already removed) is a no-op — no spurious PDP delete. spm_x = spm(service_id) - if not (spm_x.owned_roles or spm_x.owned_scopes or spm_x.inbound_rules): + 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) Targeters — agents whose outbound loses X: they hold an Agent-kind inbound edge on SPM(X) - # (their_role → X_scope), which vanishes when SPM(X) is deleted in step 5. + # (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] = { actor - for edge in spm_x.inbound_rules + for edge in _all_inbound(spm_x) if edge.role.kind == RoleKind.AGENT for actor in edge.role.actorIds } changed: set[str] = set() - # (4) Purge X's outbound footprint — X_role → other_scope edges stored on OTHER services' SPMs. + # (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: for stored in get_service_policies_by_role(role): if stored.service_id == service_id: continue model = spm(stored.service_id) - kept = [e for e in model.inbound_rules if e.role.id != role.id] - if len(kept) != len(model.inbound_rules): - model.inbound_rules = kept + if _purge_role(model, role.id): changed.add(model.service_id) if is_agent(model.service_id): affected.add(model.service_id) # its inbound source_roles[X] vanished @@ -371,47 +445,92 @@ def _decommission(service_id: str) -> None: # (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. derived = [_derive(agent_id, spm) for agent_id in sorted(affected) if is_agent(agent_id)] if derived: apply_policy(PolicyModel(agents=derived)) -def _derive(agent_id, spm) -> AgentPolicyModel: - """Build ``APM(agent_id)`` entirely from the persisted SPMs (zero IdP).""" +def _register_identity(apm: AgentPolicyModel, edge: PolicyRule) -> None: + """Register an inbound edge's role into the **effect-agnostic** identity maps — ``subject_roles`` + for a User role, ``source_roles`` for an Agent role. Called for allow AND deny edges alike: a + role/subject that appears **only** in a DENY edge must still land here, or the generated deny + lookup cannot resolve the role at request time and the prohibition silently never fires.""" + target = apm.subject_roles if edge.role.kind == RoleKind.USER else apm.source_roles + for actor in edge.role.actorIds: + _add_by_id(target.setdefault(actor, []), edge.role) + + +def _derive(agent_id, spm, default_effect: RuleEffect = RuleEffect.DENY) -> 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``).""" sa = spm(agent_id) - apm = _fresh_apm(agent_id) + apm = _fresh_apm(agent_id, default_effect) # Identity (P2) — the agent's own aiac.managed roles/scopes, seeded from the catalog. apm.agent_roles = list(sa.owned_roles) apm.agent_scopes = list(sa.owned_scopes) - # Inbound — every edge on SPM(A), split by role.kind. - for edge in sa.inbound_rules: - _add_rule(apm.inbound_rules, edge) - if edge.role.kind == RoleKind.USER: - for username in edge.role.actorIds: - _add_by_id(apm.subject_roles.setdefault(username, []), edge.role) - else: # Agent - for source_id in edge.role.actorIds: - _add_by_id(apm.source_roles.setdefault(source_id, []), edge.role) - - # Outbound — for each of A's own roles, the edges on other services' SPMs that reference it. - # Relevance is directional: only A's *agent* roles confer an outbound edge, so a merely - # shared user role never creates a false edge to a service A does not target. + # Inbound — every edge on SPM(A), split by (role.kind, effect). Identity maps effect-agnostic. + for effect, subject_bucket, source_bucket in ( + (RuleEffect.ALLOW, apm.inbound_subject_allow_rules, apm.inbound_source_allow_rules), + (RuleEffect.DENY, apm.inbound_subject_deny_rules, apm.inbound_source_deny_rules), + ): + for edge in _inbound_list(sa, effect): + bucket = subject_bucket if edge.role.kind == RoleKind.USER else source_bucket + _add_rule(bucket, edge) + _register_identity(apm, edge) + + # Outbound — for each of A's own roles, the edges on other services' SPMs that reference it, + # split by effect. Relevance is directional: only A's *agent* roles confer an outbound edge, so + # a merely shared user role never creates a false edge to a service A does not target. for role in sa.owned_roles: for stored in get_service_policies_by_role(role): - for edge in stored.inbound_rules: - if edge.role.id != role.id: - continue - scope = edge.scope - _add_rule(apm.outbound_rules, edge) - _add_by_id(apm.target_scopes.setdefault(scope.serviceId, []), scope) - # Outbound subject gate — the User-kind inbound rules on the SAME owning SPM - # whose scope is this target scope (the users allowed to reach it through A). - for user_edge in stored.inbound_rules: - if user_edge.scope.id == scope.id and user_edge.role.kind == RoleKind.USER: - _add_rule(apm.outbound_subject_rules, user_edge) - for username in user_edge.role.actorIds: - _add_by_id(apm.subject_roles.setdefault(username, []), user_edge.role) + _derive_outbound(apm, role, stored) return apm + + +def _derive_outbound(apm: AgentPolicyModel, role: Role, stored: ServicePolicyModel) -> None: + """Project A's own ``role`` edges on ``stored`` into the outbound target + subject buckets, + split by effect: an ``Allow`` target edge grows ``outbound_target_allow_rules`` / + ``target_allow_scopes``; a ``Deny`` one grows the deny counterparts.""" + for effect, target_rules, target_scopes in ( + (RuleEffect.ALLOW, apm.outbound_target_allow_rules, apm.target_allow_scopes), + (RuleEffect.DENY, apm.outbound_target_deny_rules, apm.target_deny_scopes), + ): + for edge in _inbound_list(stored, effect): + if edge.role.id != role.id: + continue + scope = edge.scope + _add_rule(target_rules, edge) + _add_by_id(target_scopes.setdefault(scope.serviceId, []), scope) + # Outbound subject gate — the User-kind edges on the SAME owning SPM whose scope is this + # target scope (which users may / must not reach it through A). + _derive_outbound_subject(apm, stored, scope) + + +def _derive_outbound_subject( + apm: AgentPolicyModel, stored: ServicePolicyModel, scope: Scope +) -> None: + """Gather ``stored``'s User-kind edges for ``scope`` into the outbound subject buckets (allow / + deny), and register each such user into the effect-agnostic ``subject_roles`` map.""" + for effect, subject_rules in ( + (RuleEffect.ALLOW, apm.outbound_subject_allow_rules), + (RuleEffect.DENY, apm.outbound_subject_deny_rules), + ): + for user_edge in _inbound_list(stored, effect): + if user_edge.scope.id == scope.id and user_edge.role.kind == RoleKind.USER: + _add_rule(subject_rules, user_edge) + for username in user_edge.role.actorIds: + _add_by_id(apm.subject_roles.setdefault(username, []), user_edge.role) diff --git a/aiac/src/aiac/policy/model/models.py b/aiac/src/aiac/policy/model/models.py index 8fbd25f4d..25eb1f6fe 100644 --- a/aiac/src/aiac/policy/model/models.py +++ b/aiac/src/aiac/policy/model/models.py @@ -1,19 +1,36 @@ +from enum import Enum + from pydantic import BaseModel, ConfigDict from aiac.idp.configuration.models import Role, Scope, ServiceType +class RuleEffect(str, Enum): + """Tags a :class:`PolicyRule` as a grant (``Allow``) or a prohibition (``Deny``). + + A string enum mirroring ``ServiceType`` / ``RoleKind`` style, so ``RuleEffect.ALLOW == + "Allow"`` holds and it serializes as the string ``"Allow"`` / ``"Deny"``. A ``Deny`` rule is + a durable prohibition that subtracts from what the ``Allow`` rules grant (deny-overrides).""" + + ALLOW = "Allow" + DENY = "Deny" + + class PolicyRule(BaseModel): model_config = ConfigDict(extra="ignore") role: Role scope: Scope + # ``effect`` participates in dedup identity ``(role.id, scope.id, effect)`` so the same + # ``(role, scope)`` can coexist once as ``Allow`` and once as ``Deny``. Defaulting to + # ``Allow`` keeps existing allow-only producers working unchanged. + effect: RuleEffect = RuleEffect.ALLOW class ServicePolicyModel(BaseModel): """The persistent source of truth — one per service (agent *and* tool), keyed by ``service_id``. Holds the service's own identity (owned roles/scopes) plus every inbound - edge that grants access to its ``owned_scopes``. + edge (``Allow`` and ``Deny``, in separate parallel lists) touching its ``owned_scopes``. Canonical form: *every rule is an inbound edge on the SPM of the service that owns the rule's scope.* An agent's outbound edge is the target's inbound edge (``AR→TS`` is stored on @@ -31,7 +48,12 @@ class ServicePolicyModel(BaseModel): service_type: ServiceType # Agent | Tool — only Agents get a derived APM owned_roles: list[Role] # this service's own client roles (aiac.managed only) owned_scopes: list[Scope] # this service's exposed scopes (aiac.managed only) - inbound_rules: list[PolicyRule] # canonical: every edge granting access to owned_scopes + # Canonical inbound edges, split into two explicitly separated parallel lists (never one + # intermixed list filtered by ``effect``): every ``Allow`` edge granting access to + # ``owned_scopes``, and every ``Deny`` edge prohibiting it. A ``Deny`` edge subtracts from + # what the ``Allow`` edges grant (deny-overrides). + inbound_allow_rules: list[PolicyRule] = [] + inbound_deny_rules: list[PolicyRule] = [] class AgentPolicyModel(BaseModel): @@ -45,19 +67,41 @@ 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 + # 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 + # by the referenced entity's string id, so they serialize to JSON natively. agent_roles: list[Role] agent_scopes: list[Scope] - # Relationship maps are keyed by the referenced entity's string id, so they - # serialize to JSON natively (no custom key handling needed). - source_roles: dict[str, list[Role]] # source service id -> roles granted - subject_roles: dict[str, list[Role]] # subject id -> roles held on behalf of - target_scopes: dict[str, list[Scope]] # target service id -> scopes permitted - inbound_rules: list[PolicyRule] - outbound_rules: list[PolicyRule] - # (user role, tool scope) pairs — the outbound subject gate; a user holding - # ``role`` may reach a tool exposing ``scope``. Outbound counterpart of - # ``inbound_rules`` (which pairs a user role with an *agent* scope). - outbound_subject_rules: list[PolicyRule] = [] + source_roles: dict[str, list[Role]] # source service id -> roles held (effect-agnostic) + subject_roles: dict[str, list[Role]] # subject id -> roles held (effect-agnostic) + + # Outbound target maps — split by effect. target service id -> scopes this agent may / + # must not request on it. + target_allow_scopes: dict[str, list[Scope]] = {} + target_deny_scopes: dict[str, list[Scope]] = {} + + # 8 entity×effect rule lists — {inbound subject, inbound source, outbound target, outbound + # subject} × {allow, deny}. Split explicitly (never one intermixed list filtered by effect); + # a request is permitted iff some ALLOW gate passes and no DENY gate matches (deny-overrides). + inbound_subject_allow_rules: list[PolicyRule] = [] # who may call this agent + inbound_subject_deny_rules: list[PolicyRule] = [] # which subjects are barred + inbound_source_allow_rules: list[PolicyRule] = [] # which calling services may call + inbound_source_deny_rules: list[PolicyRule] = [] # which calling services are barred + outbound_target_allow_rules: list[PolicyRule] = [] # what this agent may call + outbound_target_deny_rules: list[PolicyRule] = [] # what this agent must not call + # (user role, tool scope) pairs — the outbound subject gate: which users may / must not reach + # the agent's targets. Outbound counterpart of the inbound subject rules (user role + agent + # scope). + outbound_subject_allow_rules: list[PolicyRule] = [] + outbound_subject_deny_rules: list[PolicyRule] = [] class PolicyModel(BaseModel): diff --git a/aiac/src/aiac/policy/model_store/library/api.py b/aiac/src/aiac/policy/model_store/library/api.py index 76e147af7..9cd3aaded 100644 --- a/aiac/src/aiac/policy/model_store/library/api.py +++ b/aiac/src/aiac/policy/model_store/library/api.py @@ -35,7 +35,8 @@ def _fresh_empty(service_id: str) -> ServicePolicyModel: service_type=ServiceType.AGENT, owned_roles=[], owned_scopes=[], - inbound_rules=[], + inbound_allow_rules=[], + inbound_deny_rules=[], ) diff --git a/aiac/src/aiac/policy/model_store/service/main.py b/aiac/src/aiac/policy/model_store/service/main.py index dc702b953..f434f0a8d 100644 --- a/aiac/src/aiac/policy/model_store/service/main.py +++ b/aiac/src/aiac/policy/model_store/service/main.py @@ -66,11 +66,16 @@ async def lifespan(app: FastAPI): @app.get("/policy/services", response_model=None) def list_service_policies_by_role(role: str) -> list[ServicePolicyModel]: - # Return every cached SPM whose inbound_rules reference the given role id. This must + # Return every cached SPM referencing the given role id across BOTH inbound rule lists + # (allow and deny) — a role that appears only in a deny edge must still surface. This must # be answered from the store (not the IdP): the SPM is the source of truth, so stale # role->service mappings the live IdP no longer reflects still show up here — which is # exactly what override-purge needs. Never 404s; empty list on no match. - return [spm for spm in _cache.values() if any(rule.role.id == role for rule in spm.inbound_rules)] + return [ + spm + for spm in _cache.values() + if any(rule.role.id == role for rule in (*spm.inbound_allow_rules, *spm.inbound_deny_rules)) + ] @app.delete("/policy/services", response_model=None) diff --git a/aiac/test/agent/controller/test_routes.py b/aiac/test/agent/controller/test_routes.py index cc02c1f61..2c4bb7fc4 100644 --- a/aiac/test/agent/controller/test_routes.py +++ b/aiac/test/agent/controller/test_routes.py @@ -4,14 +4,19 @@ 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.routes import app +from aiac.agent.policy_rules_builder.graph import ( + PolicyContradictionError, + PolicyRulesBuilderError, +) from aiac.idp.configuration.models import Role, Scope -from aiac.policy.model.models import PolicyRule +from aiac.policy.model.models import PolicyRule, RuleEffect client = TestClient(app) @@ -39,15 +44,58 @@ 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)) as orch, + 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.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") - pce.assert_called_once_with([], False) + 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) + + +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. + with ( + patch( + "aiac.agent.controller.routes.onboard_service", + return_value=([], False, RuleEffect.ALLOW), + ) as orch, + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + patch.dict("os.environ", {"AIAC_DEFAULT_EFFECT": "Allow"}, clear=False), + ): + 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.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) def test_apply_policy_build_dispatches_to_build_subagent(): @@ -118,17 +166,21 @@ def test_apply_offboard_carries_slash_bearing_spiffe_client_id(): def test_controller_forwards_handler_rules_and_override_verbatim(): rules = [_rule("r-a"), _rule("r-b")] with ( - patch("aiac.agent.controller.routes.onboard_service", return_value=(rules, False)), + patch( + "aiac.agent.controller.routes.onboard_service", + return_value=(rules, False, RuleEffect.DENY), + ), patch("aiac.agent.controller.routes.compute_and_apply") as pce, ): resp = client.post("/apply/service/svc-9") 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) - forwarded_rules, forwarded_override = pce.call_args.args + pce.assert_called_once_with(rules, False, RuleEffect.DENY) + forwarded_rules, forwarded_override, forwarded_default_effect = 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(): @@ -145,6 +197,38 @@ def test_handler_upstream_error_surfaces_status_and_skips_pce(): pce.assert_not_called() +def test_policy_rules_builder_error_surfaces_422_and_skips_pce(): + # The PRB auditor rejecting the proposed rules after its retry budget is a policy-input + # problem, not a server fault — the Controller maps it to 422, not an uncaught 500, and the + # PCE is never reached (the raise fires during rule construction inside the handler). + with ( + patch( + "aiac.agent.controller.routes.onboard_service", + side_effect=PolicyRulesBuilderError("Auditor rejected after 3 retries: no grants"), + ), + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/service/svc-reject") + + assert resp.status_code == 422 + pce.assert_not_called() + + +def test_policy_contradiction_error_surfaces_422_and_skips_pce(): + # A genuine grant/deny contradiction is likewise a policy finding surfaced as 422. + with ( + patch( + "aiac.agent.controller.routes.onboard_service", + side_effect=PolicyContradictionError("focal-svc", []), + ), + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/service/svc-conflict") + + assert resp.status_code == 422 + pce.assert_not_called() + + # --------------------------------------------------------------------------- # # Stub contract: the per-route override each handler returns (no mocks). # # --------------------------------------------------------------------------- # diff --git a/aiac/test/agent/eventbus/test_consumer.py b/aiac/test/agent/eventbus/test_consumer.py index 1f8740a29..00f98ad7f 100644 --- a/aiac/test/agent/eventbus/test_consumer.py +++ b/aiac/test/agent/eventbus/test_consumer.py @@ -20,6 +20,7 @@ MAX_DELIVER, STREAM_NAME, ) +from aiac.policy.model.models import RuleEffect def _fake_msg(subject: str, num_delivered: int = 1) -> MagicMock: @@ -41,19 +42,26 @@ def _fake_nc() -> MagicMock: def test_handle_routes_service_subject_to_onboard_service(): - with patch("aiac.agent.eventbus.consumer.onboard_service", return_value=([], False)) as onboard: + # 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: result = _handle("aiac.apply.service.svc-1") onboard.assert_called_once_with("svc-1") - assert result == ([], False) + assert result == ([], False, RuleEffect.ALLOW) 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) + assert result == ([], True, RuleEffect.DENY) def test_handle_decodes_percent_encoded_dotted_role_name(): @@ -63,15 +71,17 @@ 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) + assert result == ([], True, RuleEffect.DENY) 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) + assert result == ([], False, RuleEffect.DENY) def test_handle_raises_for_unknown_subject(): @@ -85,12 +95,16 @@ def test_dispatch_acks_on_success(): msg = _fake_msg("aiac.apply.service.svc-1") with ( - patch("aiac.agent.eventbus.consumer.onboard_service", return_value=([], False)), + patch( + "aiac.agent.eventbus.consumer.onboard_service", + return_value=([], False, RuleEffect.DENY), + ), patch("aiac.agent.eventbus.consumer.compute_and_apply") as pce, ): asyncio.run(consumer._dispatch(msg)) - pce.assert_called_once_with([], False) + # _dispatch forwards the normalized (rules, override, default_effect) triple to the PCE. + pce.assert_called_once_with([], False, RuleEffect.DENY) msg.ack.assert_called_once() msg.term.assert_not_called() diff --git a/aiac/test/agent/policy_rules_builder/test_auditor_dimension_integration.py b/aiac/test/agent/policy_rules_builder/test_auditor_dimension_integration.py index ba1f10643..b7e4dcf6a 100644 --- a/aiac/test/agent/policy_rules_builder/test_auditor_dimension_integration.py +++ b/aiac/test/agent/policy_rules_builder/test_auditor_dimension_integration.py @@ -80,8 +80,7 @@ def test_auditor_admits_single_subject_grant_despite_competing_relation(_bad_nam from aiac.agent.policy_rules_builder.graph import build_scope_rules user_roles = [ - Role(id=f"role-{name}", name=name, description=desc, composite=False) - for name, desc in _USER_ROLES.items() + Role(id=f"role-{name}", name=name, description=desc, composite=False) for name, desc in _USER_ROLES.items() ] issues_write = Scope(id="scope-issues-write", name="issues-write", description=_ISSUES_WRITE_DESC) diff --git a/aiac/test/agent/policy_rules_builder/test_graph.py b/aiac/test/agent/policy_rules_builder/test_graph.py index 2393d41d4..6d521069a 100644 --- a/aiac/test/agent/policy_rules_builder/test_graph.py +++ b/aiac/test/agent/policy_rules_builder/test_graph.py @@ -14,15 +14,18 @@ from aiac.agent.policy_rules_builder.graph import ( AuditVerdict, + Contradiction, + PolicyContradictionError, PolicyRulesBuilderError, RoleSelection, ScopeSelection, _build_llm, + _build_llm, build_role_rules, build_scope_rules, ) from aiac.idp.configuration.models import Role, Scope -from aiac.policy.model.models import PolicyRule +from aiac.policy.model.models import PolicyRule, RuleEffect from aiac.shared.upstream import is_transient @@ -290,3 +293,457 @@ def test_build_llm_defaults_timeout_on_bad_env(monkeypatch): _build_llm() assert mk.call_args.kwargs["timeout"] == 120 + + +# =========================================================================== # +# #123 — deny extraction from natural-language policy text. # +# The proposer now returns explicit prohibitions (denied_* name lists) and an # +# exclusivity flag alongside its grants; build emits ALLOW rules for grants # +# and DENY rules for prohibitions (+ the derived exclusivity complement), # +# allows-first then denies, each in candidate order. All cases drive the # +# existing _structured_call seam — proposer + auditor turns interleaved. # +# =========================================================================== # + + +# --------------------------------------------------------------------------- # +# Slice A (tracer) — direct prohibition -> DENY, role direction. "developers # +# may read but must not touch issues": the proposer grants `read` and denies # +# `issues`; the auditor approves; an ALLOW(read) + DENY(issues) pair comes back.# +# --------------------------------------------------------------------------- # +def test_direct_prohibition_yields_deny_role_direction(): + role = _role("r-dev", "developer") + read = _scope("s-read", "read") + issues = _scope("s-issues", "issues") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=["read"], + denied_scope_names=["issues"], + grant_is_exclusive=False, + reasoning="may read but must not touch issues", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [read, issues]) + + assert rules == [ + PolicyRule(role=role, scope=read, effect=RuleEffect.ALLOW), + PolicyRule(role=role, scope=issues, effect=RuleEffect.DENY), + ] + + +# --------------------------------------------------------------------------- # +# Slice B — symmetric direct prohibition -> DENY, scope direction. The scope is # +# focal, roles are candidates: one role is granted access, another is denied. # +# --------------------------------------------------------------------------- # +def test_direct_prohibition_yields_deny_scope_direction(): + scope = _scope("s-audit", "audit-log") + security = _role("r-sec", "security") + intern = _role("r-int", "intern") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + ScopeSelection( + roles_with_access_names=["security"], + roles_denied_access_names=["intern"], + access_is_exclusive=False, + reasoning="security may reach the audit log; interns must not", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_scope_rules([security, intern], scope) + + assert rules == [ + PolicyRule(role=security, scope=scope, effect=RuleEffect.ALLOW), + PolicyRule(role=intern, scope=scope, effect=RuleEffect.DENY), + ] + + +# --------------------------------------------------------------------------- # +# Slice C — exclusivity ("developers can ONLY access source") -> the derived # +# complement. grant_is_exclusive=True with granted=[source] over {source, # +# issues,deploy} yields ALLOW(source) + DENY(issues) + DENY(deploy). The # +# complement is DERIVED from the candidate set, not enumerated by the proposer # +# (denied_scope_names is empty). # +# --------------------------------------------------------------------------- # +def test_exclusivity_derives_complement_role_direction(): + role = _role("r-dev", "developer") + source = _scope("s-src", "source") + issues = _scope("s-iss", "issues") + deploy = _scope("s-dep", "deploy") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=["source"], + denied_scope_names=[], + grant_is_exclusive=True, + reasoning="developers can only access source", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [source, issues, deploy]) + + assert rules == [ + PolicyRule(role=role, scope=source, effect=RuleEffect.ALLOW), + PolicyRule(role=role, scope=issues, effect=RuleEffect.DENY), + PolicyRule(role=role, scope=deploy, effect=RuleEffect.DENY), + ] + + +# --------------------------------------------------------------------------- # +# Slice D — exclusivity symmetric, scope direction ("ONLY developers may access # +# source"). access_is_exclusive=True with granted=[developer] over the role # +# candidate set denies every OTHER candidate role for the focal scope. # +# --------------------------------------------------------------------------- # +def test_exclusivity_derives_complement_scope_direction(): + scope = _scope("s-src", "source") + dev = _role("r-dev", "developer") + tester = _role("r-tst", "tester") + ops = _role("r-ops", "ops") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + ScopeSelection( + roles_with_access_names=["developer"], + roles_denied_access_names=[], + access_is_exclusive=True, + reasoning="only developers may access source", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_scope_rules([dev, tester, ops], scope) + + assert rules == [ + PolicyRule(role=dev, scope=scope, effect=RuleEffect.ALLOW), + PolicyRule(role=tester, scope=scope, effect=RuleEffect.DENY), + PolicyRule(role=ops, scope=scope, effect=RuleEffect.DENY), + ] + + +# --------------------------------------------------------------------------- # +# Slice E — a NON-exclusive grant imposes nothing on the complement. "developers # +# may access source" (grant_is_exclusive=False, no explicit deny) grants source # +# and leaves issues a silent non-grant -- no DENY(issues). # +# --------------------------------------------------------------------------- # +def test_non_exclusive_grant_imposes_no_complement_deny(): + role = _role("r-dev", "developer") + source = _scope("s-src", "source") + issues = _scope("s-iss", "issues") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=["source"], + denied_scope_names=[], + grant_is_exclusive=False, + reasoning="developers may access source", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [source, issues]) + + assert rules == [PolicyRule(role=role, scope=source, effect=RuleEffect.ALLOW)] + + +# --------------------------------------------------------------------------- # +# Slice F — a genuine grant/deny overlap on the same candidate (a coarse scope # +# "may read issues but must not modify them", where `issues` covers read+write) # +# is a contradiction. precheck flags issues in BOTH lists; the auditor # +# adjudicates it genuine, so the builder RAISES PolicyContradictionError # +# carrying the focal entity and the contradiction (with its description), # +# fail-closed -- no rule set is returned. # +# --------------------------------------------------------------------------- # +def test_genuine_overlap_raises_policy_contradiction_error(): + role = _role("r-dev", "developer") + issues = _scope("s-iss", "issues") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=["issues"], + denied_scope_names=["issues"], + grant_is_exclusive=False, + reasoning="may read issues but must not modify them", + ), + AuditVerdict( + approved=False, + contradictions=[ + Contradiction( + candidate_name="issues", + description="coarse-scope granularity mismatch: issues covers read and write", + ) + ], + ), + ], + ) + ) + with pytest.raises(PolicyContradictionError) as exc: + build_role_rules(role, [issues]) + + # The raise carries the focal identity (its name appears) and all genuine contradictions, + # each with its description -- the report IS the raise; no rule set comes back. + assert role.name in exc.value.focal + assert [c.candidate_name for c in exc.value.contradictions] == ["issues"] + assert "coarse-scope" in exc.value.contradictions[0].description + + +# --------------------------------------------------------------------------- # +# Slice G — multiple genuine contradictions are reported in a SINGLE raise, so # +# the author can fix them all in one pass (not discover them one at a time). # +# --------------------------------------------------------------------------- # +def test_multiple_contradictions_reported_in_one_raise(): + role = _role("r-dev", "developer") + issues = _scope("s-iss", "issues") + deploy = _scope("s-dep", "deploy") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=["issues", "deploy"], + denied_scope_names=["issues", "deploy"], + grant_is_exclusive=False, + reasoning="both coarse scopes are partly permitted and partly forbidden", + ), + AuditVerdict( + approved=False, + contradictions=[ + Contradiction(candidate_name="issues", description="direct policy conflict"), + Contradiction(candidate_name="deploy", description="coarse-scope granularity mismatch"), + ], + ), + ], + ) + ) + with pytest.raises(PolicyContradictionError) as exc: + build_role_rules(role, [issues, deploy]) + + assert {c.candidate_name for c in exc.value.contradictions} == {"issues", "deploy"} + + +# --------------------------------------------------------------------------- # +# Slice H — a generation-error overlap is NOT a policy finding. The auditor # +# rejects the first proposal with contradictions=[] (ordinary rejection); the # +# builder threads the reason back, re-proposes cleanly, and the auditor # +# approves. Rules come back, no PolicyContradictionError is raised. # +# --------------------------------------------------------------------------- # +def test_generation_error_overlap_retries_then_approves(): + role = _role("r-dev", "developer") + issues = _scope("s-iss", "issues") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + sc = stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=["issues"], + denied_scope_names=["issues"], + grant_is_exclusive=False, + reasoning="accidentally listed issues in both", + ), + AuditVerdict(approved=False, reason="you listed issues as both granted and denied; pick one"), + RoleSelection( + granted_scope_names=["issues"], + denied_scope_names=[], + grant_is_exclusive=False, + reasoning="issues is granted only", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [issues]) + + assert rules == [PolicyRule(role=role, scope=issues, effect=RuleEffect.ALLOW)] + # The re-proposal (3rd structured call) must carry the auditor's rejection reason. + reproposal_msg = sc.call_args_list[2].args[1][1].content + assert "pick one" in reproposal_msg + + +# --------------------------------------------------------------------------- # +# Slice I — an all-deny result (a prohibition with no current grant) is a valid, # +# first-class output, NOT collapsed to []. It blocks a future broad grant under # +# deny-overrides. # +# --------------------------------------------------------------------------- # +def test_all_deny_result_is_first_class(): + role = _role("r-dev", "developer") + issues = _scope("s-iss", "issues") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=[], + denied_scope_names=["issues"], + grant_is_exclusive=False, + reasoning="developers must never touch issues", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [issues]) + + assert rules == [PolicyRule(role=role, scope=issues, effect=RuleEffect.DENY)] + + +# --------------------------------------------------------------------------- # +# Slice J — precheck drops a hallucinated DENIED name before the auditor sees it # +# (symmetric with the existing granted-name hallucination-drop slice). "ghost" # +# is not a candidate, so the auditor audits only the real "issues" prohibition. # +# --------------------------------------------------------------------------- # +def test_precheck_drops_hallucinated_denied_name(): + role = _role("r-dev", "developer") + issues = _scope("s-iss", "issues") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + sc = stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=[], + denied_scope_names=["issues", "ghost"], + grant_is_exclusive=False, + reasoning="must not touch issues", + ), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [issues]) + + assert rules == [PolicyRule(role=role, scope=issues, effect=RuleEffect.DENY)] + auditor_msg = sc.call_args_list[1].args[1][1].content + assert "issues" in auditor_msg and "ghost" not in auditor_msg + + +# --------------------------------------------------------------------------- # +# Slice K — a single call mixing grants and prohibitions returns BOTH, ordered # +# deterministically: all ALLOWs first, then all DENYs, each in candidate order # +# (stable + diffable across runs). # +# --------------------------------------------------------------------------- # +def test_mixed_allow_and_deny_ordered_allows_then_denies_candidate_order(): + role = _role("r-dev", "developer") + source = _scope("s-src", "source") + issues = _scope("s-iss", "issues") + deploy = _scope("s-dep", "deploy") + audit = _scope("s-aud", "audit") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection( + granted_scope_names=["source", "deploy"], + denied_scope_names=["issues", "audit"], + grant_is_exclusive=False, + reasoning="may access source and deploy; must not touch issues or audit", + ), + AuditVerdict(approved=True), + ], + ) + ) + # Candidate order: source, issues, deploy, audit. + rules = build_role_rules(role, [source, issues, deploy, audit]) + + assert rules == [ + PolicyRule(role=role, scope=source, effect=RuleEffect.ALLOW), + PolicyRule(role=role, scope=deploy, effect=RuleEffect.ALLOW), + PolicyRule(role=role, scope=issues, effect=RuleEffect.DENY), + PolicyRule(role=role, scope=audit, effect=RuleEffect.DENY), + ] + + +# --------------------------------------------------------------------------- # +# Slice L — prompt content. (a) The proposer AND the auditor are told the # +# deny/exclusivity contract (a one-sided rule would let them diverge): explicit # +# prohibitions -> deny, and restrictive "only" closes the set. (b) The POLICY # +# block labels the baseline as grants-only and the scenario separately, so # +# deny/exclusivity binds to the scenario layer only. Asserted on the captured # +# message content at the _structured_call seam. # +# --------------------------------------------------------------------------- # +def _capture_first_two_messages(): + """Run one happy build_role_rules and return (proposer_msgs, auditor_msgs) as captured at the + seam. Each is [SystemMessage, HumanMessage].""" + role = _role("r-dev", "developer") + write = _scope("s-write", "write") + with ExitStack() as stack: + stack.enter_context( + patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source("SCEN-TEXT")) + ) + sc = stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection(granted_scope_names=["write"], reasoning="r"), + AuditVerdict(approved=True), + ], + ) + ) + build_role_rules(role, [write]) + return sc.call_args_list[0].args[1], sc.call_args_list[1].args[1] + + +def test_proposer_and_auditor_share_deny_and_exclusivity_contract(): + proposer_msgs, auditor_msgs = _capture_first_two_messages() + for msgs in (proposer_msgs, auditor_msgs): + system = msgs[0].content.lower() + assert "prohibition" in system or "must not" in system # explicit-prohibition -> deny + assert "only" in system and "exclusiv" in system # restrictive "only" closes the set + + +def test_policy_block_labels_baseline_grants_only_and_scenario(): + proposer_msgs, _ = _capture_first_two_messages() + human = proposer_msgs[1].content + assert "BASELINE POLICY" in human and "grants only" in human + assert "SCENARIO POLICY" in human + # The scenario text sits under the SCENARIO label, after the baseline. + assert human.index("BASELINE POLICY") < human.index("SCENARIO POLICY") < human.index("SCEN-TEXT") diff --git a/aiac/test/agent/policy_rules_builder/test_graph_live_llm.py b/aiac/test/agent/policy_rules_builder/test_graph_live_llm.py new file mode 100644 index 000000000..980fae582 --- /dev/null +++ b/aiac/test/agent/policy_rules_builder/test_graph_live_llm.py @@ -0,0 +1,209 @@ +"""Live-LLM acceptance tests for aiac.agent.policy_rules_builder.graph. + +Unlike test_graph.py (which mocks the LLM at the graph._structured_call seam), this +suite runs the **real** LLM end-to-end through the Policy Rules Builder and asserts that +the emitted ``list[PolicyRule]`` matches the policy text. Only the *inputs* are mocked — +role/scope **descriptions** are set inline and the **policy source** is stubbed at +graph.get_policy_source — so the suite needs **no Kubernetes and no Keycloak**, only an +LLM endpoint. The ``_structured_call`` seam is deliberately **left live**: the whole +point is to exercise the real proposer/auditor prompts, so a prompt-engineering +regression (an over-grant, a missed deny, a hallucinated deny, a wrong effect) fails a +fixture here where the mocked suite cannot see it. + +Gating: the module is marked **both** ``integration`` and ``llm``. It is ``integration`` +because it calls a real LLM endpoint — that is exactly what the ``integration`` marker +means — so the routine unit run (``-m "not integration"``) deselects it and its collected +count is unchanged by this suite. It is additionally ``llm`` so it can be selected on its +own (``-m llm``) without a cluster or Keycloak, unlike the cluster-bound integration +suite. Every test also first calls +``require_env_or_skip("LLM_BASE_URL", "LLM_MODEL", "LLM_API_KEY")`` so it **skips cleanly** +(never crashes, never false-passes) when the endpoint is not configured. Run it opt-in +with ``-m llm`` (env sourced). + +Each fixture asserts **exact set equality** of the emitted ``{(counterpart.name, effect)}`` +pairs against a hand-verified expected set — a subset check would let over-/under-grants +pass. Expected sets are derived by hand from the SCENARIO-layer deny triggers documented +in ``docs/handoffs/03/04-*.md`` and ``prompts.py`` (_DENY_RULES): explicit prohibition +("must not" / "read-only") -> DENY; a prohibition stated only in a description -> DENY; +"only …" -> derived DENY complement over the rest of the candidate set. +""" + +from unittest.mock import patch + +import pytest + +from aiac.agent.policy_rules_builder.graph import build_role_rules, build_scope_rules +from aiac.idp.configuration.models import Role, Scope +from aiac.policy.model.models import PolicyRule, RuleEffect +from test.integration.launcher import require_env_or_skip + +pytestmark = [pytest.mark.integration, pytest.mark.llm] + +ALLOW = RuleEffect.ALLOW +DENY = RuleEffect.DENY + + +# --------------------------------------------------------------------------- # +# Harness # +# --------------------------------------------------------------------------- # +@pytest.fixture(autouse=True) +def _require_llm_env(): + """Skip the whole suite cleanly unless a real LLM endpoint is configured. Autouse so + every fixture is gated without repeating the call; runs before the patched build.""" + require_env_or_skip("LLM_BASE_URL", "LLM_MODEL", "LLM_API_KEY") + + +class _Source: + """Stub PolicySource whose fetch() returns a fixed scenario policy string (mirrors the + _Source in test_graph.py). Patched in at graph.get_policy_source per fixture.""" + + def __init__(self, text: str): + self.text = text + + def fetch(self) -> str: + return self.text + + +def _role(id: str, name: str, description: str) -> Role: + return Role(id=id, name=name, description=description, composite=False, childRoles=[]) + + +def _scope(id: str, name: str, description: str) -> Scope: + return Scope(id=id, name=name, description=description) + + +def _role_rules(policy: str, role: Role, scopes: list[Scope]) -> list[PolicyRule]: + """Run build_role_rules against the real LLM with `policy` as the scenario text.""" + with patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source(policy)): + return build_role_rules(role, scopes) + + +def _scope_rules(policy: str, roles: list[Role], scope: Scope) -> list[PolicyRule]: + """Run build_scope_rules against the real LLM with `policy` as the scenario text.""" + with patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source(policy)): + return build_scope_rules(roles, scope) + + +def _scope_effects(rules: list[PolicyRule]) -> set[tuple[str, RuleEffect]]: + """Emitted (scope.name, effect) pairs — the counterpart for the role direction.""" + return {(r.scope.name, r.effect) for r in rules} + + +def _role_effects(rules: list[PolicyRule]) -> set[tuple[str, RuleEffect]]: + """Emitted (role.name, effect) pairs — the counterpart for the scope direction.""" + return {(r.role.name, r.effect) for r in rules} + + +# --------------------------------------------------------------------------- # +# Slice 1 (tracer) — allow-only, build_role_rules. A purely permissive policy # +# grants both source scopes to the developer and prohibits nothing: exactly two # +# ALLOWs, no DENY. Proves the seam wiring + marker gating before any deny logic. # +# --------------------------------------------------------------------------- # +def test_allow_only_role_direction(): + developer = _role( + "r-dev", "developer", "A software developer who works on the source code repository." + ) + source_read = _scope("s-read", "source-read", "Read source code from the repository.") + source_write = _scope("s-write", "source-write", "Write and modify source code in the repository.") + + policy = "Developers may read and write the source code repository." + + rules = _role_rules(policy, developer, [source_read, source_write]) + + assert _scope_effects(rules) == { + ("source-read", ALLOW), + ("source-write", ALLOW), + } + + +# --------------------------------------------------------------------------- # +# Slice 2 — allow-only, build_scope_rules (symmetric, opposite direction). The # +# scope is focal, roles are candidates. The policy connects only `operator` to # +# deploy; `developer` is a silent non-grant (neither the policy nor its # +# description connects it to deploying) — a non-grant, NOT a DENY. Locks the # +# symmetric path: exactly one ALLOW, no DENY. # +# --------------------------------------------------------------------------- # +def test_allow_only_scope_direction(): + deploy = _scope("s-deploy", "deploy", "Deploy the application to production.") + operator = _role( + "r-ops", + "operator", + "An operations engineer responsible for deploying and running the application in production.", + ) + developer = _role("r-dev", "developer", "A software developer who writes source code.") + + policy = "Operators may deploy the application to production." + + rules = _scope_rules(policy, [operator, developer], deploy) + + assert _role_effects(rules) == {("operator", ALLOW)} + + +# --------------------------------------------------------------------------- # +# Slice 3 — direct-prohibition deny (role direction). "read the source but must # +# not write to it": the read-only prohibition in the SCENARIO policy records an # +# explicit DENY on the write scope (rule 5), alongside the read ALLOW. # +# --------------------------------------------------------------------------- # +def test_direct_prohibition_deny(): + developer = _role( + "r-dev", "developer", "A software developer who works on the source code repository." + ) + source_read = _scope("s-read", "source-read", "Read source code from the repository.") + source_write = _scope("s-write", "source-write", "Write and modify source code in the repository.") + + policy = "Developers may read the source code repository, but must not write to it." + + rules = _role_rules(policy, developer, [source_read, source_write]) + + assert _scope_effects(rules) == { + ("source-read", ALLOW), + ("source-write", DENY), + } + + +# --------------------------------------------------------------------------- # +# Slice 4 — description-driven deny. The prohibition lives ONLY in the focal # +# role's description ("does not manage the issue tracker"); the scenario policy # +# is silent on issues. The DENY must still appear — descriptions are read # +# symmetrically for grants and prohibitions — alongside the source ALLOW. # +# --------------------------------------------------------------------------- # +def test_description_driven_deny(): + source_agent = _role( + "r-agent", + "source-agent", + "An agent that manages the source code repository. It does not manage the issue tracker.", + ) + source_manage = _scope("s-src", "source-manage", "Manage the source code repository (read and write).") + issues_manage = _scope("s-iss", "issues-manage", "Manage the issue tracker.") + + policy = "The source-agent manages the source code repository." + + rules = _role_rules(policy, source_agent, [source_manage, issues_manage]) + + assert _scope_effects(rules) == { + ("source-manage", ALLOW), + ("issues-manage", DENY), + } + + +# --------------------------------------------------------------------------- # +# Slice 5 — exclusivity complement (highest-signal). "may ONLY access source" # +# closes the set: source is granted, and the builder derives a DENY on EVERY # +# other candidate (issues, deploy). A missed complement is exactly the durable- # +# prohibition hole DENY exists to close. # +# --------------------------------------------------------------------------- # +def test_exclusivity_derives_complement(): + developer = _role("r-dev", "developer", "A software developer.") + source = _scope("s-src", "source", "Access the source code repository.") + issues = _scope("s-iss", "issues", "Access the issue tracker.") + deploy = _scope("s-dep", "deploy", "Deploy the application to production.") + + policy = "Developers may only access the source code repository." + + rules = _role_rules(policy, developer, [source, issues, deploy]) + + assert _scope_effects(rules) == { + ("source", ALLOW), + ("issues", DENY), + ("deploy", DENY), + } diff --git a/aiac/test/agent/uc/onboarding/test_orchestrator.py b/aiac/test/agent/uc/onboarding/test_orchestrator.py index 7d30bf9b2..8d6c053fe 100644 --- a/aiac/test/agent/uc/onboarding/test_orchestrator.py +++ b/aiac/test/agent/uc/onboarding/test_orchestrator.py @@ -15,6 +15,7 @@ from aiac.agent.uc.onboarding import orchestrator from aiac.idp.configuration.models import ServiceType +from aiac.policy.model.models import RuleEffect SERVICE_ID = "svc-1" @@ -36,8 +37,29 @@ def test_provision_result_fed_to_builder_and_rules_returned_with_override_false( # 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 - assert result == (rules, False) + # 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) + + +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 = MagicMock() + graph.invoke.return_value = {"service_type": ServiceType.AGENT} + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=graph), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + ): + 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 def test_provision_graph_invoked_with_service_id_in_trigger(self): # The service_id must reach Provision as the trigger's entity_id (Keycloak diff --git a/aiac/test/agent/uc/policy_update/__init__.py b/aiac/test/agent/uc/policy_update/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/uc/policy_update/test_build_rebuild.py b/aiac/test/agent/uc/policy_update/test_build_rebuild.py new file mode 100644 index 000000000..9aaaebf74 --- /dev/null +++ b/aiac/test/agent/uc/policy_update/test_build_rebuild.py @@ -0,0 +1,89 @@ +"""Unit tests for the Policy Update Build + Rebuild sub-agents. + +These sub-agents are keep-green under the ALLOW/DENY policy-rule model (#127): +Build (role-closure flatten -> PRB -> merge) and Rebuild (authoritative, delegates +to Build with ``override=True``) construct/return ``list[PolicyRule]`` and hand +``(rules, override)`` to the Controller, which makes the single PCE +``compute_and_apply`` call. The sub-agents themselves perform no PDP / PCE / store +write. ``PolicyRule.effect`` defaults to ``RuleEffect.ALLOW``, so the sub-agents +stay allow-only and behavior is unchanged; deny extraction remains deferred. + +Build and Rebuild are stubs today (full build lands in 3.7 / 3.8), so they return +empty rule lists. The tests lock the return/override contract, the ALLOW-only +shape of any rule they emit, and the no-direct-write invariant. +""" + +from unittest.mock import patch + +from aiac.agent.uc.policy_update.build import build_policy +from aiac.agent.uc.policy_update.rebuild import rebuild_policy +from aiac.idp.configuration.models import Role, Scope +from aiac.policy.model.models import PolicyRule, RuleEffect + + +def test_build_rule_shape_defaults_to_allow_effect(): + # The rule shape Build/Rebuild produce — a PolicyRule built from a Role + Scope with + # no explicit effect — defaults to ALLOW and serializes as "Allow". This locks the + # allow-only, behavior-unchanged contract the sub-agents rely on. + rule = PolicyRule( + role=Role(id="r-1", name="editor", composite=False), + scope=Scope(id="s-1", name="write"), + ) + assert rule.effect is RuleEffect.ALLOW + assert rule.effect == "Allow" + + +def test_build_policy_returns_allow_only_rules_with_additive_override(): + rules, override = build_policy() + + # Behavior unchanged: Build is an additive/incremental merge, so override is False. + assert isinstance(rules, list) + assert (rules, override) == ([], False) + # Any rule Build emits is an Allow grant — never a Deny (deny extraction stays deferred). + # Vacuously true for today's empty stub; guards against a Deny slipping in once 3.7 lands. + assert all(rule.effect is RuleEffect.ALLOW for rule in rules) + + +def test_rebuild_policy_returns_allow_only_rules_with_authoritative_override(): + rules, override = rebuild_policy() + + # Rebuild is authoritative (role-keyed replace in the PCE), so override is True. + assert isinstance(rules, list) + assert (rules, override) == ([], True) + assert all(rule.effect is RuleEffect.ALLOW for rule in rules) + + +def test_build_and_rebuild_write_nothing_to_pce_or_store(): + # The sub-agents only compute and return (rules, override); the single PCE call and any + # store write live in the Controller. Invoking them must touch neither the PCE nor the store. + with ( + patch("aiac.policy.computation.compute_and_apply") as compute_and_apply, + patch("aiac.policy.computation.decommission") as decommission, + patch("aiac.policy.model_store.library.api.apply_service_policy") as apply_spm, + patch("aiac.policy.model_store.library.api.delete_service_policy") as delete_spm, + patch("aiac.policy.model_store.library.api.clear_service_policies") as clear_spm, + ): + build_policy() + rebuild_policy() + + for spy in (compute_and_apply, decommission, apply_spm, delete_spm, clear_spm): + spy.assert_not_called() + + +def test_build_and_rebuild_modules_do_not_import_a_write_surface(): + # Complements the spy above for the top-level ``from ... import `` case: no PCE or + # store write symbol may be bound into either sub-agent's module namespace. + import aiac.agent.uc.policy_update.build as build_module + import aiac.agent.uc.policy_update.rebuild as rebuild_module + + forbidden = { + "compute_and_apply", + "decommission", + "apply_service_policy", + "delete_service_policy", + "clear_service_policies", + } + for module in (build_module, rebuild_module): + assert forbidden.isdisjoint(vars(module)), ( + f"{module.__name__} must not import a PDP/PCE/store write surface" + ) diff --git a/aiac/test/integration/policy.abstract.md b/aiac/test/integration/policy.abstract.md index 4fc56a667..cb44f23e2 100644 --- a/aiac/test/integration/policy.abstract.md +++ b/aiac/test/integration/policy.abstract.md @@ -1,2 +1,2 @@ -- Developers work primarily in source — writing and maintaining code — and consult the issue tracker only to follow defect reports; grant them full read and write access to source contents, and read-only access to issues. -- Testers work exclusively in the issue tracker — filing, triaging, and updating defect reports — and do not work in source; grant them full read and write access to issues, and no access to source. +- Developers work primarily in source — writing and maintaining code — and consult the issue tracker to follow defect reports; grant them full read and write access to source contents, and read access to issues. +- Testers work in the issue tracker — filing, triaging, and updating defect reports; grant them full read and write access to issues. diff --git a/aiac/test/integration/policy.explicit.md b/aiac/test/integration/policy.explicit.md index 42526133d..5a1c24a1b 100644 --- a/aiac/test/integration/policy.explicit.md +++ b/aiac/test/integration/policy.explicit.md @@ -3,10 +3,6 @@ Grant access on a least-privilege basis. Only grant a (role, scope) pair when this policy supports it; deny by default. -## Users → agent capabilities (inbound; user may call the agent) -- developer may use source-access and issues-access. -- tester may use issues-access. - ## Users → tool operations (outbound subject; user may reach the tool) - developer may perform source-read, source-write, and issues-read. - tester may perform issues-read and issues-write. diff --git a/aiac/test/integration/scenario_uc1_denyworld.py b/aiac/test/integration/scenario_uc1_denyworld.py new file mode 100644 index 000000000..08c669520 --- /dev/null +++ b/aiac/test/integration/scenario_uc1_denyworld.py @@ -0,0 +1,177 @@ +"""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 "consults issues", 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 works "in the issue tracker, not in 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 "does not author source code"). 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.integration 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 consults issues, 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/aiac/test/integration/test_policy_pipeline_denyworld.py b/aiac/test/integration/test_policy_pipeline_denyworld.py new file mode 100644 index 000000000..692a11f65 --- /dev/null +++ b/aiac/test/integration/test_policy_pipeline_denyworld.py @@ -0,0 +1,187 @@ +"""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 +``test/integration/.env`` sourced): + + .venv/bin/pytest test/integration/test_policy_pipeline_denyworld.py -m integration -v + +Without ``-m integration`` 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.integration + +HERE = Path(__file__).resolve().parent # test/integration/ +REPO_ROOT = HERE.parents[1] # -> aiac/ +sys.path.insert(0, str(REPO_ROOT)) # so ``import test.integration.*`` resolves + +from test.integration import scenario_uc1 as scn # noqa: E402 +from test.integration import scenario_uc1_denyworld as scn_b # noqa: E402 +from test.integration 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/aiac/test/integration/test_scenario_uc1_denyworld.py b/aiac/test/integration/test_scenario_uc1_denyworld.py new file mode 100644 index 000000000..24262390b --- /dev/null +++ b/aiac/test/integration/test_scenario_uc1_denyworld.py @@ -0,0 +1,138 @@ +"""Fixture-independent oracle-contract tests for the Policy-B (denyworld) oracle. + +Not an integration test (no ``pytest.mark.integration``): these need **no** cluster, Keycloak, or +LLM and run in the routine ``-m "not integration"`` unit lane. 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[2] # -> aiac/ +sys.path.insert(0, str(REPO_ROOT)) + +from test.integration import scenario_uc1 as scn # noqa: E402 +from test.integration import scenario_uc1_denyworld as scn_b # noqa: E402 + + +# ====================================================================================== +# §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 consults issues, 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/aiac/test/integration/test_uc1_onboard_policy_agnostic.py b/aiac/test/integration/test_uc1_onboard_policy_agnostic.py new file mode 100644 index 000000000..bf99bfc94 --- /dev/null +++ b/aiac/test/integration/test_uc1_onboard_policy_agnostic.py @@ -0,0 +1,111 @@ +"""Unit tests for the policy-agnostic parametrization of the ``uc1_onboard`` harness (issue #149). + +Not an integration test (no ``pytest.mark.integration``): it exercises the *pure* seams the #149 +change adds — the ``ReadySignal`` convergence-probe descriptor, the Policy-A default signal set, and +the behavior-preserving defaults of ``ensure_agent_policy`` / ``onboarded_stack`` — with no cluster, +no Keycloak, and no LLM. It runs in the normal ``-m "not integration"`` suite. + +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. +""" + +from __future__ import annotations + +import inspect +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] # -> aiac/ +sys.path.insert(0, str(REPO_ROOT)) + +from test.integration import scenario_uc1 as scn # noqa: E402 +from test.integration import uc1_onboard as uc1 # noqa: E402 + +# --- ReadySignal: the parametrized convergence probe -------------------------------------------- + + +def test_ready_signal_inbound_dispatches_to_inbound_decision(monkeypatch) -> None: + """An ``inbound`` signal routes through ``inbound_decision(ctx, subject)`` and returns its verdict.""" + calls: list[tuple] = [] + monkeypatch.setattr(uc1, "inbound_decision", lambda ctx, user: calls.append(("in", user)) or "allow") + sig = uc1.ReadySignal("inbound", "dev-user", "allow") + assert sig.decide({"marker": 1}) == "allow" + assert calls == [("in", "dev-user")] + + +def test_ready_signal_outbound_dispatches_with_bare_tool(monkeypatch) -> None: + """An ``outbound`` signal routes through ``outbound_decision(ctx, subject, tool_bare)``.""" + calls: list[tuple] = [] + monkeypatch.setattr( + uc1, "outbound_decision", lambda ctx, user, tool: calls.append(("out", user, tool)) or "deny" + ) + sig = uc1.ReadySignal("outbound", "tester-user", "deny", tool_bare="source-read") + assert sig.decide({}) == "deny" + assert calls == [("out", "tester-user", "source-read")] + + +def test_ready_signal_labels_are_human_readable() -> None: + """Labels feed the raw-diagnostics message, so they must name the probe unambiguously.""" + assert uc1.ReadySignal("inbound", "dev-user", "allow").label() == "inbound(dev-user)" + assert ( + uc1.ReadySignal("outbound", "devops-user", "allow", tool_bare="issues-read").label() + == "outbound(devops-user,issues-read)" + ) + + +def test_outbound_signal_requires_a_bare_tool() -> None: + """An outbound signal with no ``tool_bare`` is a programming error — fail loudly at construction.""" + import pytest + + with pytest.raises(ValueError): + uc1.ReadySignal("outbound", "dev-user", "allow") + + +# --- The Policy-A default signal set (preserved behavior) --------------------------------------- + + +def test_default_ready_signals_match_todays_policy_a_probe_tool_onboarded() -> None: + """With a tool onboarded (rungs 2 & 3), the default signals are exactly today's hardcoded probe: + dev-user inbound allow, devops-user inbound deny, dev-user outbound source-read allow.""" + signals = uc1._default_ready_signals(tool_onboarded=True) + assert [(s.kind, s.subject, s.expected, s.tool_bare) for s in signals] == [ + ("inbound", "dev-user", "allow", None), + ("inbound", "devops-user", "deny", None), + ("outbound", "dev-user", "allow", "source-read"), + ] + + +def test_default_ready_signals_flip_source_read_for_the_agent_only_rung() -> None: + """Rung 1 (agent only, empty outbound gate): dev-user outbound source-read converges to ``deny``, + exactly as today's ``expected_source_read = "allow" if tool_onboarded else "deny"``.""" + signals = uc1._default_ready_signals(tool_onboarded=False) + outbound = [s for s in signals if s.kind == "outbound"] + assert len(outbound) == 1 + assert outbound[0].expected == "deny" + assert outbound[0].tool_bare == "source-read" + + +# --- Behavior-preserving defaults on the parametrized entry points ------------------------------ + + +def test_ensure_agent_policy_defaults_to_policy_a_abstract() -> None: + """``ensure_agent_policy`` gains ``policy_md`` but defaults to Policy A's abstract, so existing + (positional-namespace-only) callers mount the same policy as before.""" + params = inspect.signature(uc1.ensure_agent_policy).parameters + assert "policy_md" in params + assert params["policy_md"].default == scn.POLICY_ABSTRACT + + +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.""" + 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" diff --git a/aiac/test/integration/uc1_onboard.py b/aiac/test/integration/uc1_onboard.py index 39bcd82dd..aeb9a80e9 100644 --- a/aiac/test/integration/uc1_onboard.py +++ b/aiac/test/integration/uc1_onboard.py @@ -47,8 +47,9 @@ import subprocess import sys from contextlib import contextmanager +from dataclasses import dataclass from pathlib import Path -from typing import Iterator +from typing import Iterator, Sequence import requests @@ -126,6 +127,20 @@ BUNDLE_TIMEOUT = float(os.environ.get("AIAC_BUNDLE_TIMEOUT", "300")) BUNDLE_POLL_INTERVAL = float(os.environ.get("AIAC_BUNDLE_POLL_INTERVAL", "10")) +# --- 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) @@ -304,21 +319,28 @@ def clear_policy_store() -> None: # ====================================================================================== -def ensure_agent_policy(namespace: str) -> None: +def ensure_agent_policy(namespace: str, policy_md: str = scn.POLICY_ABSTRACT) -> None: """Ensure the PRB's ``policy.md`` is mounted in the Controller pod — the one mutable stack precondition the ladder owns, so a fresh AIAC stack needs no manual patching. Phase-1's PRB reads the single abstract policy from ``AIAC_POLICY_FILE`` (default ``/etc/aiac/policy.md``). This idempotently provisions that file as a ConfigMap and mounts it on - the Controller Deployment, rolling out **only** when the mount is absent. The policy text is - ``scenario_uc1.POLICY_ABSTRACT`` — the same abstract policy the scenario's verdicts assume. It is - never written into a committed deployment manifest (that stays free of test config) and is left - in place on teardown (benign, and keeps reruns fast).""" + the Controller Deployment, rolling out **only** when the mount is absent. The mounted text is + ``policy_md`` — defaulting to ``scenario_uc1.POLICY_ABSTRACT`` (Policy A) so existing callers mount + the same abstract policy the scenario's verdicts assume; a second policy (e.g. the denyworld + ``POLICY_DENYWORLD``) is driven through the same harness by passing its prose here. It is never + written into a committed deployment manifest (that stays free of test config) and is left in place + on teardown (benign, and keeps reruns fast). + + The content-diff below (``kubectl apply`` "unchanged" vs "configured") already forces a Controller + rollout whenever ``policy.md``'s prose changes, so **switching between policies reloads correctly**: + a run that swaps Policy A for Policy B (or back) sees "configured", rolls the Controller, and waits, + so the PRB reads the new prose before onboarding.""" cm = { "apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": POLICY_CONFIGMAP, "namespace": namespace}, - "data": {"policy.md": scn.POLICY_ABSTRACT}, + "data": {"policy.md": policy_md}, } apply_out = kubectl("apply", "-f", "-", input_text=json.dumps(cm)) # ``kubectl apply`` reports "created"/"configured" when the ConfigMap's content differs from the @@ -358,6 +380,31 @@ def ensure_agent_policy(namespace: str) -> None: 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) + + def onboard(base_url: str, service_id: str) -> None: """``POST /apply/service/{service_id}`` against the Controller; assert 200. This upserts the ``AuthorizationPolicy`` CR on the live Kubernetes API (bundle-service picks it up).""" @@ -496,6 +543,18 @@ def inbound_decision(ctx: dict, user: str) -> str: return inbound_outcome(code) +def resolve_controller_pod() -> str: + """Resolve the **current** live Controller pod (newest Running+Ready, non-terminating — see + ``resolve_pod``). The onboard leg port-forwards to this resolved pod rather than + ``svc/aiac-agent-service`` because both ``_set_controller_default_effect`` and + ``ensure_agent_policy`` may roll the Controller Deployment right before onboarding: with + ``replicas=1``/``maxUnavailable=0`` the old pod lingers ``Terminating`` (up to its grace period) + and the Service can still route a fresh connection to it. Its ``/health`` answers 200 right up + until it drops the long onboard POST mid-flight — the ``RemoteDisconnected`` race, the onboard-leg + analogue of issue #139. Binding the resolved live pod avoids the doomed endpoint.""" + return resolve_pod(f"app={CONTROLLER_DEPLOYMENT}", namespace=CONTROLLER_NAMESPACE) + + def resolve_agent_pod() -> str: """Resolve the **current** live agent pod (newest Running+Ready, non-terminating — see ``resolve_pod``). Re-resolved per outbound probe rather than pinned once at fixture setup: the @@ -516,13 +575,77 @@ def outbound_decision(ctx: dict, user: str, tool_bare: str) -> str: return outbound_outcome(code, body) +# ====================================================================================== +# Convergence probe — the parametrized readiness signal each policy supplies +# ====================================================================================== + + +@dataclass(frozen=True) +class ReadySignal: + """One convergence probe: a live decision this run must reach a **definitive** verdict on before + any assertion. ``onboarded_stack`` polls the whole signal set until every one matches, so each + signal must be deterministic for its scenario regardless of a stale CR the run replaced (a live + ``deny`` that the permissive default would flip to ``allow``, or vice-versa, is the tracer that + proves *this* run's bundle is in force). Polling each to a definitive ``allow``/``deny`` (never + ``error``) also waits out the post-restart token-exchange 503 window. + + ``kind`` selects the probe: ``"inbound"`` → ``inbound_decision(ctx, subject)``; ``"outbound"`` → + ``outbound_decision(ctx, subject, tool_bare)`` (``tool_bare`` required). ``expected`` is the + terminal ``"allow"``/``"deny"`` the signal converges to.""" + + kind: str + subject: str + expected: str + tool_bare: str | None = None + + def __post_init__(self) -> None: + if self.kind not in ("inbound", "outbound"): + raise ValueError(f"ReadySignal.kind must be 'inbound' or 'outbound', got {self.kind!r}") + if self.kind == "outbound" and self.tool_bare is None: + raise ValueError("an outbound ReadySignal needs a bare tool name (tool_bare=...)") + + def decide(self, ctx: dict) -> str: + """Send this signal's live probe through AuthBridge and return the plugin's classified verdict.""" + if self.kind == "inbound": + return inbound_decision(ctx, self.subject) + return outbound_decision(ctx, self.subject, self.tool_bare) + + def label(self) -> str: + """Short human-readable probe name for the raw-diagnostics message.""" + if self.kind == "inbound": + return f"inbound({self.subject})" + return f"outbound({self.subject},{self.tool_bare})" + + +def _default_ready_signals(tool_onboarded: bool) -> list[ReadySignal]: + """The Policy-A convergence signal set — the exact probe the harness has always polled, preserved + as the default so existing rung callers are unchanged: + + * ``dev-user`` reaches the agent (inbound allow), + * ``devops-user`` is blocked (inbound deny — proves the restrictive client-scoped gate is live, + not the allow-all baseline), + * ``dev-user``'s outbound ``source-read`` has reached its terminal verdict — ``allow`` once a tool + is onboarded (rungs 2 & 3), ``deny`` for the empty-gate agent-only rung (rung 1).""" + return [ + ReadySignal("inbound", "dev-user", "allow"), + ReadySignal("inbound", "devops-user", "deny"), + ReadySignal("outbound", "dev-user", "allow" if tool_onboarded else "deny", tool_bare="source-read"), + ] + + # ====================================================================================== # Per-rung fixture flow — cleanup → onboard (in order) → Part B → poll bundle → yield → cleanup # ====================================================================================== @contextmanager -def onboarded_stack(workloads: list[str]) -> Iterator[dict]: +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. ``ctx`` = ``{"admin", "namespace", "agent_pod", "keycloak_url", "realm", "tool_onboarded"}``. @@ -535,7 +658,24 @@ def onboarded_stack(workloads: list[str]) -> Iterator[dict]: before yielding. Keycloak cleanup + CR delete run before and after; the clients are left registered as before (spec § Per-rung flow). The workload order is the rung's identity — e.g. rung 2 passes ``[agent, tool]`` so tool onboarding retroactively completes the agent's outbound - gate; rung 3 passes ``[tool, agent]`` and must converge to the same live decisions.""" + gate; rung 3 passes ``[tool, agent]`` and must converge to the same live decisions. + + **Policy-agnostic parametrization (#149).** Every keyword defaults to today's Policy-A behavior, + so the rung callers (which pass only a positional ``workloads``) are byte-for-byte unchanged, while + a second policy can drive the very same flow: + + * ``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 + converges on the right decisions; the raw-diagnostics message is recomputed against them.""" # Skip gates first — before any cluster mutation (acceptance #4: skip, never false-pass). require_pipeline(namespace=NAMESPACE, workloads=[scn.AGENT_WORKLOAD, scn.TOOL_WORKLOAD]) creds = require_env_or_skip("KEYCLOAK_URL", "KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD") @@ -551,15 +691,29 @@ def onboarded_stack(workloads: list[str]) -> Iterator[dict]: verify_subject_mapper( keycloak_url=keycloak_url, realm=TEST_REALM, user="dev-user", password=scn.USER_PASSWORD ) - ensure_agent_policy(CONTROLLER_NAMESPACE) # mount the PRB's policy.md if the stack lacks it 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 onboarding + ensure_agent_policy(CONTROLLER_NAMESPACE, policy_md=policy_md) # mount this run's policy.md service_ids = [ resolve_service_id(admin, TEST_REALM, f"{NAMESPACE}/{workload}") for workload in workloads ] + # Bind the onboard port-forward to the resolved **live** Controller pod, not the Service: + # the rollouts above can leave an old pod ``Terminating`` that the Service still routes to, + # dropping the long onboard POST mid-flight (see ``resolve_controller_pod``). An explicit + # ``AIAC_CONTROLLER_TARGET`` override is still honored verbatim for non-default topologies. + controller_target = ( + CONTROLLER_TARGET if os.environ.get("AIAC_CONTROLLER_TARGET") else f"pod/{resolve_controller_pod()}" + ) with port_forward( - CONTROLLER_TARGET, + controller_target, namespace=CONTROLLER_NAMESPACE, local_port=CONTROLLER_LOCAL_PORT, remote_port=CONTROLLER_REMOTE_PORT, @@ -588,21 +742,12 @@ def onboarded_stack(workloads: list[str]) -> Iterator[dict]: } # Wait for bundle-service + OPA to reflect THIS run's CR (and token-exchange to settle) before - # any assertion. Readiness signals, all deterministic for this scenario regardless of a stale - # CR (which the run replaced): dev-user reaches the agent (inbound allow), devops-user is - # blocked (inbound deny — proves the restrictive client-scoped gate is live, not the allow-all - # baseline), and dev-user's outbound source-read has reached its terminal verdict — - # ``allow`` once a tool is onboarded, ``deny`` for the empty-gate agent-only rung. Polling the - # outbound signal to a *definitive* allow/deny (not ``error``) also waits out the post-restart - # token-exchange 503 window. - expected_source_read = "allow" if tool_onboarded else "deny" - + # any assertion. The ``signals`` set (this policy's ``ready_signals``, or the Policy-A default) + # is polled until every probe reaches its terminal verdict — each deterministic for its + # scenario regardless of a stale CR (which the run replaced), and each polled to a *definitive* + # allow/deny (not ``error``) so we also wait out the post-restart token-exchange 503 window. def _ready() -> bool: - return ( - inbound_decision(ctx, "dev-user") == "allow" - and inbound_decision(ctx, "devops-user") == "deny" - and outbound_decision(ctx, "dev-user", "source-read") == expected_source_read - ) + return all(sig.decide(ctx) == sig.expected for sig in signals) if not poll_until(_ready, timeout=BUNDLE_TIMEOUT, interval=BUNDLE_POLL_INTERVAL): # Surface the RAW outbound (code, body) — not just the classified outcome — so a stalled @@ -614,28 +759,35 @@ def _ready() -> bool: # * ``code=503`` — the ``token-exchange`` leg failed upstream (audience refused, IdP # unreachable), so OPA was never consulted. # * a genuine OPA policy stall can't show as ``"error"`` at all: it surfaces as ``"deny"`` - # (HTTP 200 + an OPA error frame), because the generated Rego always carries - # ``default allow := false``. + # under deny-by-default (HTTP 200 + an OPA error frame), because the generated Rego + # carries ``default allow := false``. + # The observed-vs-expected line is recomputed against *this run's* signals so a denyworld + # (or any parametrized) run diagnoses itself, not a hardcoded Policy-A probe. + observed = "; ".join(f"{sig.label()}={sig.decide(ctx)!r}(want {sig.expected!r})" for sig in signals) # Re-resolve the pod fresh here (not the possibly-stale ``ctx["agent_pod"]``) so the raw - # line reflects the *current* live pod. - ob_token = mint_token( - "dev-user", scn.USER_PASSWORD, keycloak_url=ctx["keycloak_url"], realm=ctx["realm"] - ) - ob_code, ob_body = outbound_probe( - ob_token, "source-read", namespace=ctx["namespace"], agent_pod=resolve_agent_pod() - ) + # line reflects the *current* live pod. Dump the raw (code, body) for the first outbound + # signal — the leg where the #139 stale-pod / 503 failures actually show up. + raw_line = "" + ob_sig = next((s for s in signals if s.kind == "outbound"), None) + if ob_sig is not None: + ob_token = mint_token( + ob_sig.subject, scn.USER_PASSWORD, keycloak_url=ctx["keycloak_url"], realm=ctx["realm"] + ) + ob_code, ob_body = outbound_probe( + ob_token, ob_sig.tool_bare, namespace=ctx["namespace"], agent_pod=resolve_agent_pod() + ) + raw_line = f" [raw {ob_sig.label()}: HTTP {ob_code}, body={ob_body[:300]!r}]" raise RuntimeError( f"live pipeline did not converge within {BUNDLE_TIMEOUT:.0f}s after onboarding " - f"{workloads} + Part B: inbound(dev-user)={inbound_decision(ctx, 'dev-user')!r} " - f"inbound(devops-user)={inbound_decision(ctx, 'devops-user')!r} " - f"outbound(dev-user,source-read)={outbound_outcome(ob_code, ob_body)!r} " - f"[raw: HTTP {ob_code}, body={ob_body[:300]!r}] " - f"(expected allow / deny / {expected_source_read}). code=None + 'exec failed' body = a " + f"{workloads} + Part B: {observed}.{raw_line} code=None + 'exec failed' body = a " "stale/gone agent pod (harness); 503 = token-exchange never came up (OPA not reached); " - "a real policy stall would read 'deny', never 'error' — see k8s/opa-kind-runbook.md " - "and issue #139." + "under deny-by-default a real policy stall reads 'deny', never 'error' — see " + "k8s/opa-kind-runbook.md and issue #139." ) yield ctx finally: + 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) delete_agent_cr() # after — drop this run's CR cleanup_provisioned(admin, TEST_REALM) # after — restore the pre-run Keycloak state diff --git a/aiac/test/pdp/policy/generate_rego.py b/aiac/test/pdp/policy/generate_rego.py index f82c36e86..c64c7e14e 100644 --- a/aiac/test/pdp/policy/generate_rego.py +++ b/aiac/test/pdp/policy/generate_rego.py @@ -62,10 +62,10 @@ def rules(pairs: list[tuple[str, str]]) -> list[PolicyRule]: agent_scopes=[scope[name] for name in scn.AGENT_SCOPES], source_roles={}, subject_roles={user: [role[role_name]] for user, role_name in scn.USERS.items()}, - target_scopes={scn.TOOL_ID: [scope[name] for name in scn.TOOL_SCOPES]}, - inbound_rules=rules(scn.INBOUND_PAIRS), - outbound_rules=rules(scn.OUTBOUND_PAIRS), - outbound_subject_rules=rules(scn.OUTBOUND_SUBJECT_PAIRS), + target_allow_scopes={scn.TOOL_ID: [scope[name] for name in scn.TOOL_SCOPES]}, + inbound_subject_allow_rules=rules(scn.INBOUND_PAIRS), + outbound_target_allow_rules=rules(scn.OUTBOUND_PAIRS), + outbound_subject_allow_rules=rules(scn.OUTBOUND_SUBJECT_PAIRS), ) return PolicyModel(agents=[agent]) diff --git a/aiac/test/pdp/policy/library/test_api.py b/aiac/test/pdp/policy/library/test_api.py index eb804957a..fc0e544c7 100644 --- a/aiac/test/pdp/policy/library/test_api.py +++ b/aiac/test/pdp/policy/library/test_api.py @@ -1,27 +1,83 @@ """Unit tests for aiac.pdp.policy.library.api. The PDP Policy Writer HTTP boundary is mocked; no live service is required. + +``aiac.pdp.policy.library`` is a *pass-through* over the canonical policy models: +``api.py`` imports ``AgentPolicyModel`` / ``PolicyModel`` straight from +``aiac.policy.model.models`` (there is no separate library ``models`` module). So the +model-shape + round-trip assertions here exercise the same canonical ALLOW/DENY models the +library serializes over the wire, and the HTTP-client transport stays a pass-through +``model_dump()`` (no ``?realm=``). """ from unittest.mock import MagicMock, patch import pytest -from aiac.policy.model.models import AgentPolicyModel, PolicyModel +from aiac.idp.configuration.models import Role, RoleKind, Scope +from aiac.policy.model.models import ( + AgentPolicyModel, + PolicyModel, + PolicyRule, + RuleEffect, +) BASE = "http://127.0.0.1:7072" -# Minimal valid model fixtures (all eight AgentPolicyModel fields present). -_AGENT_POLICY_DICT = { - "agent_id": "weather-agent", - "agent_roles": [], - "agent_scopes": [], - "subject_roles": {}, - "source_roles": {}, - "target_scopes": {}, - "inbound_rules": [], - "outbound_rules": [], -} + +# --------------------------------------------------------------------------- +# New-shape fixtures (ALLOW/DENY model) +# +# Built from real Role/Scope objects so the fixture dicts are exactly what the +# canonical models serialize to — the HTTP-client body assertions then compare a +# genuine ``model_dump()`` round-trip, and a DENY tuple is present in the fixture. +# --------------------------------------------------------------------------- + + +def _role(id: str = "role-1", name: str = "reader") -> Role: + return Role(id=id, name=name, composite=False, kind=RoleKind.AGENT, actorIds=["weather-agent"]) + + +def _scope(id: str = "scope-1", name: str = "read") -> Scope: + return Scope(id=id, name=name, serviceId="weather-tool") + + +def _agent_policy_model() -> AgentPolicyModel: + """A representative agent policy exercising the new ALLOW/DENY shape. + + Populates identity maps, both split target-scope maps, and at least one ALLOW rule and one + DENY rule across the 8 entity×effect rule lists, so a ``model_dump()`` round-trip is lossless + for both effects. + """ + role = _role() + deny_role = _role(id="role-2", name="blocked") + scope = _scope() + allow = PolicyRule(role=role, scope=scope, effect=RuleEffect.ALLOW) + deny = PolicyRule(role=deny_role, scope=scope, effect=RuleEffect.DENY) + return AgentPolicyModel( + agent_id="weather-agent", + agent_roles=[role], + agent_scopes=[scope], + # Effect-agnostic identity maps must include the deny-only role. + source_roles={"caller-agent": [role]}, + subject_roles={"alice": [role, deny_role]}, + # Split outbound target maps. + target_allow_scopes={"weather-tool": [scope]}, + target_deny_scopes={"secret-tool": [scope]}, + # 8 entity×effect rule lists (ALLOW + DENY populated). + inbound_subject_allow_rules=[allow], + inbound_subject_deny_rules=[deny], + inbound_source_allow_rules=[allow], + inbound_source_deny_rules=[deny], + outbound_target_allow_rules=[allow], + outbound_target_deny_rules=[deny], + outbound_subject_allow_rules=[allow], + outbound_subject_deny_rules=[deny], + ) + + +_AGENT_MODEL = _agent_policy_model() +_AGENT_POLICY_DICT = _AGENT_MODEL.model_dump() _POLICY_DICT = {"agents": [_AGENT_POLICY_DICT]} @@ -40,6 +96,76 @@ def _err(status: int = 500) -> MagicMock: return resp +# --------------------------------------------------------------------------- +# Model shape — ALLOW/DENY (field-set assertions) +# --------------------------------------------------------------------------- + + +class TestModelShape: + def test_policy_rule_carries_role_scope_effect(self): + fields = set(PolicyRule.model_fields) + assert {"role", "scope", "effect"} <= fields + + def test_policy_rule_effect_defaults_to_allow(self): + rule = PolicyRule(role=_role(), scope=_scope()) + assert rule.effect == RuleEffect.ALLOW + + def test_agent_policy_model_has_split_target_maps(self): + fields = set(AgentPolicyModel.model_fields) + assert {"target_allow_scopes", "target_deny_scopes"} <= fields + # The pre-ALLOW/DENY single map is gone. + assert "target_scopes" not in fields + + def test_agent_policy_model_has_eight_split_rule_lists(self): + fields = set(AgentPolicyModel.model_fields) + assert { + "inbound_subject_allow_rules", + "inbound_subject_deny_rules", + "inbound_source_allow_rules", + "inbound_source_deny_rules", + "outbound_target_allow_rules", + "outbound_target_deny_rules", + "outbound_subject_allow_rules", + "outbound_subject_deny_rules", + } <= fields + # The pre-ALLOW/DENY intermixed lists are gone. + assert "inbound_rules" not in fields + assert "outbound_rules" not in fields + + def test_agent_policy_model_keeps_effect_agnostic_identity_maps(self): + fields = set(AgentPolicyModel.model_fields) + assert {"agent_roles", "agent_scopes", "source_roles", "subject_roles"} <= fields + + +# --------------------------------------------------------------------------- +# Round-trip — lossless, including a DENY tuple +# --------------------------------------------------------------------------- + + +class TestRoundTrip: + def test_agent_policy_model_round_trips_losslessly(self): + restored = AgentPolicyModel.model_validate(_AGENT_MODEL.model_dump(mode="json")) + assert restored == _AGENT_MODEL + + def test_deny_tuple_survives_round_trip(self): + restored = AgentPolicyModel.model_validate(_AGENT_MODEL.model_dump(mode="json")) + deny_rule = restored.outbound_target_deny_rules[0] + assert deny_rule.effect == RuleEffect.DENY + assert deny_rule.role.name == "blocked" + assert deny_rule.scope.name == "read" + # DENY-only role still resolvable via the effect-agnostic subject map. + assert any(r.name == "blocked" for r in restored.subject_roles["alice"]) + # Split target maps survive with the right sides. + assert "weather-tool" in restored.target_allow_scopes + assert "secret-tool" in restored.target_deny_scopes + + def test_policy_model_round_trips_losslessly(self): + model = PolicyModel(agents=[_agent_policy_model()]) + restored = PolicyModel.model_validate(model.model_dump(mode="json")) + assert restored == model + assert restored.agents[0].outbound_subject_deny_rules[0].effect == RuleEffect.DENY + + # --------------------------------------------------------------------------- # apply_policy # --------------------------------------------------------------------------- diff --git a/aiac/test/pdp/service/policy/opa/test_main.py b/aiac/test/pdp/service/policy/opa/test_main.py index a1371a339..229a03788 100644 --- a/aiac/test/pdp/service/policy/opa/test_main.py +++ b/aiac/test/pdp/service/policy/opa/test_main.py @@ -19,6 +19,28 @@ import json from unittest.mock import MagicMock +import pytest +"""Unit tests for aiac.pdp.service.policy.opa.main. + +Targets the always-on Custom Resource writer. The module builds a +``CustomObjectsApi`` at import (kube-config load is guarded, so import needs no +cluster); every test patches that module-level ``_api`` with a ``MagicMock`` so +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``. +""" + +import json +from unittest.mock import MagicMock + import pytest from fastapi.testclient import TestClient from kubernetes.client import ApiException @@ -48,10 +70,16 @@ def _agent(agent_id: str) -> dict: "agent_scopes": [], "subject_roles": {}, "source_roles": {}, - "target_scopes": {}, - "inbound_rules": [], - "outbound_rules": [], - "outbound_subject_rules": [], + "target_allow_scopes": {}, + "target_deny_scopes": {}, + "inbound_subject_allow_rules": [], + "inbound_subject_deny_rules": [], + "inbound_source_allow_rules": [], + "inbound_source_deny_rules": [], + "outbound_target_allow_rules": [], + "outbound_target_deny_rules": [], + "outbound_subject_allow_rules": [], + "outbound_subject_deny_rules": [], } diff --git a/aiac/test/pdp/service/policy/opa/test_rego.py b/aiac/test/pdp/service/policy/opa/test_rego.py index 3a961a084..64eb4b124 100644 --- a/aiac/test/pdp/service/policy/opa/test_rego.py +++ b/aiac/test/pdp/service/policy/opa/test_rego.py @@ -1,10 +1,16 @@ -"""Unit tests for aiac.pdp.service.policy.opa.rego. +"""Unit tests for aiac.pdp.service.policy.opa.rego (fixed packages, ALLOW/DENY). -Targets the post-rework generator: fixed package names +Targets the synthesized generator: fixed package names (``authbridge.client.{inbound,outbound}.request`` + ``import rego.v1``), the nested ``input.identity`` / ``input.mcp`` shape, the ``rossoctl`` platform bypass, and outbound de-prefixing (provisioned ``.`` scope names -collapse to the bare ``input.mcp.params.name`` the live plugin sends). +collapse to the bare ``input.mcp.params.name`` the live plugin sends) — now with +the deny-overrides ALLOW/DENY split. Each gate is emitted twice +(``*_allow_ok`` / ``*_deny_ok``) and ``allow`` requires every ALLOW gate and no +DENY gate. Scope maps are split symmetrically +(``subject_role_allow_scopes`` / ``_deny_scopes``, ``source_role_allow_scopes`` / +``_deny_scopes``, ``target_allow_scopes`` / ``target_deny_scopes``); the identity +maps (``subject_roles`` / ``source_roles`` / ``agent_roles``) keep their names. """ import json @@ -21,7 +27,7 @@ generate_outbound_rego, identity_ref, ) -from aiac.policy.model.models import AgentPolicyModel, PolicyRule +from aiac.policy.model.models import AgentPolicyModel, PolicyRule, RuleEffect # Full SPIFFE id of the github-tool workload that owns the outbound scopes. GH_TOOL = "spiffe://localtest.me/ns/team1/sa/github-tool" @@ -37,16 +43,26 @@ def _scope(name: str = "read", service_id: str = "") -> Scope: return Scope(id=f"scope-{name}", name=name, serviceId=service_id) +def _rule(role: Role, scope: Scope, effect: RuleEffect = RuleEffect.ALLOW) -> PolicyRule: + return PolicyRule(role=role, scope=scope, effect=effect) + + def _model( agent_id: str = "team1/weather-agent", agent_roles: list[Role] | None = None, agent_scopes: list[Scope] | None = None, subject_roles: dict[str, list[Role]] | None = None, source_roles: dict[str, list[Role]] | None = None, - target_scopes: dict[str, list[Scope]] | None = None, - inbound_rules: list[PolicyRule] | None = None, - outbound_rules: list[PolicyRule] | None = None, - outbound_subject_rules: list[PolicyRule] | None = None, + target_allow_scopes: dict[str, list[Scope]] | None = None, + target_deny_scopes: dict[str, list[Scope]] | None = None, + inbound_subject_allow_rules: list[PolicyRule] | None = None, + inbound_subject_deny_rules: list[PolicyRule] | None = None, + inbound_source_allow_rules: list[PolicyRule] | None = None, + inbound_source_deny_rules: list[PolicyRule] | None = None, + outbound_target_allow_rules: list[PolicyRule] | None = None, + outbound_target_deny_rules: list[PolicyRule] | None = None, + outbound_subject_allow_rules: list[PolicyRule] | None = None, + outbound_subject_deny_rules: list[PolicyRule] | None = None, ) -> AgentPolicyModel: return AgentPolicyModel( agent_id=agent_id, @@ -54,15 +70,21 @@ def _model( agent_scopes=agent_scopes or [], subject_roles=subject_roles or {}, source_roles=source_roles or {}, - target_scopes=target_scopes or {}, - inbound_rules=inbound_rules or [], - outbound_rules=outbound_rules or [], - outbound_subject_rules=outbound_subject_rules or [], + target_allow_scopes=target_allow_scopes or {}, + target_deny_scopes=target_deny_scopes or {}, + inbound_subject_allow_rules=inbound_subject_allow_rules or [], + inbound_subject_deny_rules=inbound_subject_deny_rules or [], + inbound_source_allow_rules=inbound_source_allow_rules or [], + inbound_source_deny_rules=inbound_source_deny_rules or [], + outbound_target_allow_rules=outbound_target_allow_rules or [], + outbound_target_deny_rules=outbound_target_deny_rules or [], + outbound_subject_allow_rules=outbound_subject_allow_rules or [], + outbound_subject_deny_rules=outbound_subject_deny_rules or [], ) def _github_agent() -> AgentPolicyModel: - """The worked example. + """The worked example (allow-only). Inbound agent scopes are prefixed by the *agent* (``github-agent.*``) and are **not** de-prefixed — inbound compares scopes internally, never against the @@ -88,27 +110,27 @@ def _github_agent() -> AgentPolicyModel: agent_scopes=[source_access, issues_access], subject_roles={"dev-user": [developer], "test-user": [tester]}, source_roles={"github-tool": [_role("reader")]}, - # target_scopes keyed by the FULL tool service id. - target_scopes={ + # target_allow_scopes keyed by the FULL tool service id. + target_allow_scopes={ GH_TOOL: [source_read, source_write, issues_read, issues_write] }, - inbound_rules=[ - PolicyRule(role=developer, scope=source_access), - PolicyRule(role=developer, scope=issues_access), - PolicyRule(role=tester, scope=issues_access), + inbound_subject_allow_rules=[ + _rule(developer, source_access), + _rule(developer, issues_access), + _rule(tester, issues_access), ], - outbound_rules=[ - PolicyRule(role=source_helper, scope=source_read), - PolicyRule(role=source_helper, scope=source_write), - PolicyRule(role=issues_helper, scope=issues_read), - PolicyRule(role=issues_helper, scope=issues_write), + outbound_target_allow_rules=[ + _rule(source_helper, source_read), + _rule(source_helper, source_write), + _rule(issues_helper, issues_read), + _rule(issues_helper, issues_write), ], - outbound_subject_rules=[ - PolicyRule(role=developer, scope=source_read), - PolicyRule(role=developer, scope=source_write), - PolicyRule(role=developer, scope=issues_read), - PolicyRule(role=tester, scope=issues_read), - PolicyRule(role=tester, scope=issues_write), + outbound_subject_allow_rules=[ + _rule(developer, source_read), + _rule(developer, source_write), + _rule(developer, issues_read), + _rule(tester, issues_read), + _rule(tester, issues_write), ], ) @@ -155,7 +177,7 @@ def test_inbound_has_fixed_package_header(): def test_inbound_embeds_agent_scopes_list_full_names(): # Inbound audience scopes stay FULL (prefixed) — they are compared internally - # against role_scopes, never against the bare invoked tool name. + # against the scope maps, never against the bare invoked tool name. model = _model( agent_scopes=[ _scope("github-agent.source_operations"), @@ -183,9 +205,9 @@ def test_inbound_embeds_source_roles_map(): assert '"github-tool": ["reader"]' in rego -def test_inbound_role_scopes_grouped_from_inbound_rules_full_names(): +def test_inbound_subject_role_allow_scopes_grouped_full_names(): rego = generate_inbound_rego(_github_agent()) - assert "role_scopes := {" in rego + assert "subject_role_allow_scopes := {" in rego assert ( '"developer": ["github-agent.source_operations", ' '"github-agent.issues_operations"]' in rego @@ -193,33 +215,66 @@ def test_inbound_role_scopes_grouped_from_inbound_rules_full_names(): assert '"tester": ["github-agent.issues_operations"]' in rego -def test_inbound_subject_gate_uses_identity_fields(): +def test_inbound_split_scope_maps_from_split_rule_lists(): + """subject/source allow/deny scope maps each come from their own rule list.""" + dev = _role("developer") + banned = _role("banned") + src_ok = _role("src-ok") + src_bad = _role("src-bad") + access = _scope("access") + model = _model( + agent_scopes=[access], + inbound_subject_allow_rules=[_rule(dev, access)], + inbound_subject_deny_rules=[_rule(banned, access, RuleEffect.DENY)], + inbound_source_allow_rules=[_rule(src_ok, access)], + inbound_source_deny_rules=[_rule(src_bad, access, RuleEffect.DENY)], + ) + rego = generate_inbound_rego(model) + assert 'subject_role_allow_scopes := {\n "developer": ["access"],' in rego + assert 'subject_role_deny_scopes := {\n "banned": ["access"],' in rego + assert 'source_role_allow_scopes := {\n "src-ok": ["access"],' in rego + assert 'source_role_deny_scopes := {\n "src-bad": ["access"],' in rego + + +def test_inbound_subject_gates_use_identity_fields(): rego = generate_inbound_rego(_github_agent()) - assert "subject_ok if {" in rego + 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 role_scopes[role]" in rego + assert "some scope in subject_role_allow_scopes[role]" in rego + assert "some scope in subject_role_deny_scopes[role]" in rego assert "scope in agent_scopes" in rego def test_inbound_platform_bypass_default_rossoctl(): rego = generate_inbound_rego(_github_agent()) - assert "source_ok if { not input.identity.client_id }" in rego - assert 'source_ok if { input.identity.client_id == "rossoctl" }' in rego + assert "source_allow_ok if { not input.identity.client_id }" in rego + assert 'source_allow_ok if { input.identity.client_id == "rossoctl" }' in rego assert "some role in source_roles[input.identity.client_id]" in rego + assert "some scope in source_role_allow_scopes[role]" in rego def test_inbound_platform_bypass_multiple_clients(): rego = generate_inbound_rego( _github_agent(), platform_clients=("rossoctl", "argocd") ) - assert 'source_ok if { input.identity.client_id == "rossoctl" }' in rego - assert 'source_ok if { input.identity.client_id == "argocd" }' in rego + assert 'source_allow_ok if { input.identity.client_id == "rossoctl" }' in rego + assert 'source_allow_ok if { input.identity.client_id == "argocd" }' in rego + + +def test_inbound_source_deny_gate_present(): + rego = generate_inbound_rego(_github_agent()) + assert "source_deny_ok if {" in rego + assert "some scope in source_role_deny_scopes[role]" in rego -def test_inbound_has_default_deny_and_allow(): +def test_inbound_has_default_deny_and_deny_overrides_allow(): rego = generate_inbound_rego(_github_agent()) assert "default allow := false" in rego - assert "allow if { subject_ok; source_ok }" in rego + assert ( + "allow if { subject_allow_ok; source_allow_ok; " + "not subject_deny_ok; not source_deny_ok }" in rego + ) def test_inbound_uses_only_nested_identity_input(): @@ -227,6 +282,16 @@ def test_inbound_uses_only_nested_identity_input(): # Only the nested identity fields appear (no flat legacy input keys). assert "input.identity.subject" in rego assert "input.identity.client_id" in rego + assert "input.subject" not in rego + assert "input.source" not in rego + + +def test_inbound_has_no_legacy_single_effect_identifiers(): + rego = generate_inbound_rego(_github_agent()) + # The pre-split single-effect names are gone (no alias / no back-compat). + assert "\nrole_scopes :=" not in rego + assert "subject_ok if" not in rego + assert "source_ok if" not in rego def test_inbound_empty_model_renders_valid_empty_literals(): @@ -234,9 +299,15 @@ def test_inbound_empty_model_renders_valid_empty_literals(): assert "agent_scopes := []" in rego assert "subject_roles := {}" in rego assert "source_roles := {}" in rego - assert "role_scopes := {}" in rego + assert "subject_role_allow_scopes := {}" in rego + assert "subject_role_deny_scopes := {}" in rego + assert "source_role_allow_scopes := {}" in rego + assert "source_role_deny_scopes := {}" in rego assert "default allow := false" in rego - assert "allow if { subject_ok; source_ok }" in rego + assert ( + "allow if { subject_allow_ok; source_allow_ok; " + "not subject_deny_ok; not source_deny_ok }" in rego + ) # --- generate_outbound_rego --- @@ -255,9 +326,9 @@ def test_outbound_embeds_agent_roles_list(): assert "agent_scopes :=" not in rego -def test_outbound_subject_role_scopes_are_deprefixed(): +def test_outbound_subject_role_allow_scopes_are_deprefixed(): rego = generate_outbound_rego(_github_agent()) - assert "subject_role_scopes := {" in rego + assert "subject_role_allow_scopes := {" in rego # bare tool names, owner prefix stripped assert '"developer": ["source-read", "source-write", "issues-read"]' in rego assert '"tester": ["issues-read", "issues-write"]' in rego @@ -270,13 +341,27 @@ def test_outbound_agent_role_scopes_are_deprefixed(): assert '"issues-helper": ["issues-read", "issues-write"]' in rego -def test_outbound_target_scopes_full_key_bare_values(): - rego = generate_outbound_rego(_github_agent()) - assert "target_scopes := {" in rego +def test_outbound_target_allow_and_deny_scopes_full_key_bare_values(): + dev = _role("developer") + read = _scope("github-tool.source-read", GH_TOOL) + secret = _scope("github-tool.source-delete", GH_TOOL) + model = _model( + agent_id=GH_AGENT, + subject_roles={"dev-user": [dev]}, + target_allow_scopes={GH_TOOL: [read]}, + target_deny_scopes={GH_TOOL: [secret]}, + outbound_subject_allow_rules=[_rule(dev, read)], + outbound_subject_deny_rules=[_rule(dev, secret, RuleEffect.DENY)], + ) + rego = generate_outbound_rego(model) # key stays the FULL service id; values de-prefix to bare tool names. assert ( - '"spiffe://localtest.me/ns/team1/sa/github-tool": ' - '["source-read", "source-write", "issues-read", "issues-write"]' in rego + 'target_allow_scopes := {\n' + ' "spiffe://localtest.me/ns/team1/sa/github-tool": ["source-read"],' in rego + ) + assert ( + 'target_deny_scopes := {\n' + ' "spiffe://localtest.me/ns/team1/sa/github-tool": ["source-delete"],' in rego ) @@ -289,24 +374,35 @@ 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_ok if {" in rego + 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_scopes[role]" in rego - assert "target_ok if {" in rego - assert "input.mcp.params.name in target_scopes[input.identity.service_id]" 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 "default allow := false" in rego - assert "allow if { subject_ok; target_ok }" in rego + assert ( + "allow if { 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 -def test_outbound_does_not_embed_inbound_subject_gate(): +def test_outbound_does_not_embed_inbound_source_scope_maps(): rego = generate_outbound_rego(_github_agent()) - # The inbound-flavoured subject gate must NOT appear in the outbound package. - assert "some scope in role_scopes[role]" not in rego - assert "scope in agent_scopes" not in rego - # The inbound ``role_scopes`` map must not leak (line-anchored so it does not - # match subject_role_scopes / agent_role_scopes). - assert "\nrole_scopes :=" not in rego - assert not rego.startswith("role_scopes :=") + # The inbound source scope maps must not leak into the outbound package. + assert "source_role_allow_scopes" not in rego + assert "source_role_deny_scopes" not in rego def test_outbound_deprefix_fallbacks_survive_unchanged(): @@ -317,7 +413,7 @@ def test_outbound_deprefix_fallbacks_survive_unchanged(): prefixed = _scope("github-tool.source-read", GH_TOOL) # -> source-read model = _model( agent_id="team1/github-agent", - target_scopes={GH_TOOL: [prefixed, already_bare, orphan]}, + target_allow_scopes={GH_TOOL: [prefixed, already_bare, orphan]}, ) rego = generate_outbound_rego(model) assert ( @@ -330,106 +426,587 @@ def test_outbound_empty_model_renders_valid_empty_literals(): rego = generate_outbound_rego(_model()) assert "agent_roles := []" in rego assert "subject_roles := {}" in rego - assert "subject_role_scopes := {}" in rego + assert "subject_role_allow_scopes := {}" in rego + assert "subject_role_deny_scopes := {}" in rego assert "agent_role_scopes := {}" in rego - assert "target_scopes := {}" in rego + assert "target_allow_scopes := {}" in rego + assert "target_deny_scopes := {}" in rego assert "default allow := false" in rego - assert "allow if { subject_ok; target_ok }" in rego + assert ( + "allow if { subject_allow_ok; target_allow_ok; " + "not subject_deny_ok; not target_deny_ok }" in rego + ) -# --- per-scope AND intersection semantics --- +# --- per-scope AND intersection + deny-overrides semantics --- def _outbound_and_model() -> AgentPolicyModel: - """Pins the per-scope-AND intersection with de-prefixing in play. + """Pins per-scope-AND + deny-overrides with de-prefixing in play. - The target (keyed by its full SPIFFE id) admits bare {scope-b, scope-c} - (capability gate); the user's role admits bare {scope-a, scope-c} (subject - gate). Only scope-c is in both, so only scope-c is allowed — scope-a - (user-only) and scope-b (target-only) are denied. All provisioned scope names - are ``github-tool.*``-prefixed to also exercise de-prefixing. + The user (subject gate) reaches bare {A, C, D}; the agent reaches bare + {B, C, D} on target T (capability gate); and D is denied for the user + (subject deny). So only C is allowed — A (user-only), B (agent-only) fail the + AND, and D is deny-overridden. All provisioned scope names are + ``github-tool.*``-prefixed to also exercise de-prefixing. """ user = _role("u-role") operator = _role("op-role") a = _scope("github-tool.scope-a", GH_TOOL) b = _scope("github-tool.scope-b", GH_TOOL) c = _scope("github-tool.scope-c", GH_TOOL) + d = _scope("github-tool.scope-d", GH_TOOL) return _model( agent_id="team1/github-agent", agent_roles=[operator], subject_roles={"user1": [user]}, - # capability gate: the target admits bare {scope-b, scope-c}. - target_scopes={GH_TOOL: [b, c]}, - # subject gate: the user's role admits bare {scope-a, scope-c}. - outbound_subject_rules=[ - PolicyRule(role=user, scope=a), - PolicyRule(role=user, scope=c), + # target_allow_scopes IS the capability gate: the agent reaches {B, C, D} on T. + target_allow_scopes={GH_TOOL: [b, c, d]}, + # user (subject allow gate) reaches {A, C, D}. + outbound_subject_allow_rules=[ + _rule(user, a), + _rule(user, c), + _rule(user, d), ], - # informational agent_role_scopes (not referenced by allow). - outbound_rules=[ - PolicyRule(role=operator, scope=b), - PolicyRule(role=operator, scope=c), + # user is barred from D (deny-overrides even though both allow gates grant it). + outbound_subject_deny_rules=[_rule(user, d, RuleEffect.DENY)], + # informational agent_role_scopes (not referenced by allow): operator reaches {B, C, D}. + outbound_target_allow_rules=[ + _rule(operator, b), + _rule(operator, c), + _rule(operator, d), ], ) def test_outbound_per_scope_and_structural(): - """Structural: the two gates read the same ``input.mcp.params.name`` from - disjoint maps — the subject gate grants {scope-a, scope-c}, the capability - gate grants {scope-b, scope-c} — so allow is their per-scope intersection.""" + """Structural: the gates read the same ``input.mcp.params.name`` from disjoint + maps — subject allow grants {A, C, D}, capability allow grants {B, C, D} on T, + subject deny bars {D} — so allow is their per-scope intersection minus deny.""" rego = generate_outbound_rego(_outbound_and_model()) - assert '"u-role": ["scope-a", "scope-c"]' in rego # subject gate + assert '"u-role": ["scope-a", "scope-c", "scope-d"]' in rego # subject allow gate + 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 ( - '"spiffe://localtest.me/ns/team1/sa/github-tool": ["scope-b", "scope-c"]' + "input.mcp.params.name in target_allow_scopes[input.identity.service_id]" in rego - ) # capability gate - assert "input.mcp.params.name in subject_role_scopes[role]" in rego - assert "input.mcp.params.name in target_scopes[input.identity.service_id]" in rego - assert "allow if { subject_ok; target_ok }" in rego + ) + assert ( + "allow if { 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") @pytest.mark.parametrize( "tool_name, allowed", [ - ("scope-c", True), # in BOTH gates -> allowed - ("scope-a", False), # user-only (not in the target's capability gate) -> denied - ("scope-b", False), # target-only (not in the user's subject gate) -> denied + ("scope-c", True), # in BOTH allow gates, not denied -> allowed + ("scope-a", False), # user-only (not in the agent's capability gate) -> denied + ("scope-b", False), # agent-only (not in the user's subject gate) -> denied + ("scope-d", False), # in both allow gates BUT subject-denied -> deny-overrides ], ) def test_outbound_per_scope_and_denies_mismatch(tool_name: str, allowed: bool): - """Generator-sanity: evaluate the generated ``allow`` with ``opa eval`` against - the nested ``input.identity`` / ``input.mcp`` doc the live plugin sends. Only - the scope in both gates (scope-c) is allowed; the user-only (scope-a) and - target-only (scope-b) scopes are denied. - - This is a generator-sanity check only (valid Rego + plausible allow/deny) — - the authoritative allow/deny lives in the e2e integration suite (handoff 08). - """ + """Behavioural: evaluate the generated ``allow`` with ``opa eval`` against the + nested ``input.identity`` / ``input.mcp`` doc the live plugin sends. Only the + scope in both allow gates and not denied (C) is allowed; user-only (A), + agent-only (B), and the deny-overridden (D) are denied — pinning the per-scope + intersection AND deny-overrides.""" rego = generate_outbound_rego(_outbound_and_model()) + _assert_opa_allow( + rego, + "data.authbridge.client.outbound.request.allow", + { + "identity": {"subject": "user1", "service_id": GH_TOOL}, + "mcp": {"params": {"name": tool_name}}, + }, + allowed, + ) + + +def _inbound_deny_model() -> AgentPolicyModel: + """A subject that both allows and denies the audience scope — deny-overrides must bar it.""" + good = _role("good") + banned = _role("banned") + access = _scope("github-agent.access") + return _model( + agent_id=GH_AGENT, + agent_scopes=[access], + subject_roles={"ok-user": [good], "bad-user": [good, banned]}, + inbound_subject_allow_rules=[_rule(good, access)], + inbound_subject_deny_rules=[_rule(banned, access, RuleEffect.DENY)], + ) + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize( + "subject, allowed", + [ + ("ok-user", True), # holds only the allow role + ("bad-user", False), # holds a deny role -> deny-overrides + ], +) +def test_inbound_deny_overrides_behavioural(subject: str, allowed: bool): + rego = generate_inbound_rego(_inbound_deny_model()) + _assert_opa_allow( + rego, + "data.authbridge.client.inbound.request.allow", + {"identity": {"subject": subject}}, + allowed, + ) + + +def _inbound_source_deny_model() -> AgentPolicyModel: + """A fully-allowed subject paired with a source that both allows and denies the audience scope. + The colliding source ALLOW+DENY must resolve deny-overrides via the ``source_deny_ok`` gate, + barring the request even though the subject and the source's allow role both pass.""" + good = _role("good") + src_ok = _role("src-ok") + src_bad = _role("src-bad") + access = _scope("access") + return _model( + agent_id="github-agent", + agent_scopes=[access], + subject_roles={"user1": [good]}, + source_roles={"clean-src": [src_ok], "tainted-src": [src_ok, src_bad]}, + inbound_subject_allow_rules=[_rule(good, access)], + inbound_source_allow_rules=[_rule(src_ok, access)], + inbound_source_deny_rules=[_rule(src_bad, access, RuleEffect.DENY)], + ) + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize( + "source, allowed", + [ + ("clean-src", True), # source holds only the allow role -> passes + ("tainted-src", False), # source holds a colliding deny role -> source deny-overrides + ], +) +def test_inbound_source_deny_overrides_behavioural(source: str, allowed: bool): + """Behavioural: a denied SOURCE wins over a colliding source ALLOW (and an allowed subject), + exercising the ``source_allow_ok`` / ``source_deny_ok`` split on the source dimension — a path + the other behavioural deny tests (subject inbound / subject outbound) do not cover.""" + rego = generate_inbound_rego(_inbound_source_deny_model()) + _assert_opa_allow( + rego, + "data.authbridge.client.inbound.request.allow", + {"identity": {"subject": "user1", "client_id": source}}, + allowed, + ) + + +# --- per-policy default_effect (issue #145) --------------------------------- +# +# 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 ---------------- + + +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.""" + lonely = _role("lonely") + access = _scope("github-agent.access") + return _model( + agent_id=GH_AGENT, + agent_scopes=[access], + subject_roles={"some-user": [lonely]}, + ) + + +@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) + _assert_opa_allow( + rego, + "data.authbridge.client.inbound.request.allow", + {"identity": {"subject": "some-user"}}, + allowed, + ) + + +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.""" + lonely = _role("lonely") + return _model( + agent_id=GH_AGENT, + subject_roles={"some-user": [lonely]}, + ) + + +@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) + _assert_opa_allow( + rego, + "data.authbridge.client.outbound.request.allow", + { + "identity": {"subject": "some-user", "service_id": GH_TOOL}, + "mcp": {"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) / "outbound.rego" + path = Path(tmp) / "policy.rego" path.write_text(rego) cmd = [ - shutil.which("opa"), - "eval", - "-f", - "json", - "-d", - str(path), - "--stdin-input", - "data.authbridge.client.outbound.request.allow", + shutil.which("opa"), "eval", "-f", "json", "-d", str(path), + "--stdin-input", query, ] - doc = { - "identity": {"subject": "user1", "service_id": GH_TOOL}, - "mcp": {"params": {"name": tool_name}}, - } out = subprocess.run( cmd, - input=json.dumps(doc), - capture_output=True, - text=True, - check=True, + input=json.dumps(input_doc), + capture_output=True, text=True, check=True, ).stdout result = json.loads(out)["result"][0]["expressions"][0]["value"] - assert result is allowed, f"tool_name={tool_name!r}" + assert result is expected, f"input={input_doc!r}" + + +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.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "policy.rego" + path.write_text(rego) + cmd = [ + shutil.which("opa"), "eval", "-f", "json", "-d", str(path), + "--stdin-input", query, + ] + out = subprocess.run( + cmd, + input=json.dumps(input_doc), + capture_output=True, text=True, check=True, + ).stdout + return json.loads(out)["result"][0]["expressions"][0]["value"] + + +# --- default_effect toggle isolation (issue #150) --------------------------- +# +# The clean controlled experiment the cross-policy live full-deployment test +# (test/integration/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". +# +# 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. + +_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] = { + ("developer", "source-read"): True, # explicit ALLOW + capability gate open + ("developer", "source-write"): True, # explicit ALLOW + ("developer", "issues-read"): False, # explicit DENY + ("developer", "issues-write"): False, # explicit DENY + ("tester", "source-read"): False, # explicit DENY + ("tester", "source-write"): False, # explicit DENY + ("tester", "issues-read"): True, # explicit ALLOW + ("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) +} + + +def _policy_b_outbound_model() -> AgentPolicyModel: + """One hand-built Policy-B outbound APM (see the section header).""" + developer = _role("developer") + tester = _role("tester") + devops = _role("devops") + source_read = _scope("github-tool.source-read", GH_TOOL) + source_write = _scope("github-tool.source-write", GH_TOOL) + issues_read = _scope("github-tool.issues-read", GH_TOOL) + issues_write = _scope("github-tool.issues-write", GH_TOOL) + return _model( + agent_id=GH_AGENT, + subject_roles={ + "developer": [developer], + "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), + _rule(developer, source_write), + _rule(tester, issues_read), + _rule(tester, issues_write), + ], + outbound_subject_deny_rules=[ + _rule(developer, issues_read, RuleEffect.DENY), + _rule(developer, issues_write, RuleEffect.DENY), + _rule(tester, source_read, RuleEffect.DENY), + _rule(tester, source_write, RuleEffect.DENY), + _rule(devops, source_read, RuleEffect.DENY), + _rule(devops, source_write, RuleEffect.DENY), + ], + ) + + +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}) + ) + 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}}, + } + 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 diff --git a/aiac/test/policy/computation/test_engine.py b/aiac/test/policy/computation/test_engine.py index 404f2e5b8..60e1c77c7 100644 --- a/aiac/test/policy/computation/test_engine.py +++ b/aiac/test/policy/computation/test_engine.py @@ -22,7 +22,7 @@ from aiac.idp.configuration.api import Configuration from aiac.idp.configuration.models import Role, RoleKind, Scope, Service, ServiceType -from aiac.policy.model.models import PolicyModel, PolicyRule, ServicePolicyModel +from aiac.policy.model.models import PolicyModel, PolicyRule, RuleEffect, ServicePolicyModel # --------------------------------------------------------------------------- # @@ -65,19 +65,32 @@ def _tool(service_id, *, roles=None, scopes=None) -> Service: return _service(service_id, type=ServiceType.TOOL, roles=roles, scopes=scopes) -def _rule(role, scope) -> PolicyRule: - return PolicyRule(role=role, scope=scope) +def _rule(role, scope, effect=RuleEffect.ALLOW) -> PolicyRule: + return PolicyRule(role=role, scope=scope, effect=effect) + + +def _deny(role, scope) -> PolicyRule: + return PolicyRule(role=role, scope=scope, effect=RuleEffect.DENY) def _spm(service_id, *, type=ServiceType.AGENT, owned_roles=None, owned_scopes=None, inbound=None) -> ServicePolicyModel: + # ``inbound`` accepts a mixed list of rules; each is filed into the allow/deny list by its + # ``effect`` (so existing all-allow call sites keep working and deny edges route correctly). + rules = inbound or [] return ServicePolicyModel( service_id=service_id, service_type=type, owned_roles=owned_roles or [], owned_scopes=owned_scopes or [], - inbound_rules=inbound or [], + inbound_allow_rules=[r for r in rules if r.effect == RuleEffect.ALLOW], + inbound_deny_rules=[r for r in rules if r.effect == RuleEffect.DENY], ) +def _inbound(spm) -> list[PolicyRule]: + """Both inbound lists of an SPM concatenated — a combined view for assertions.""" + return spm.inbound_allow_rules + spm.inbound_deny_rules + + # --------------------------------------------------------------------------- # # harness — an in-memory Policy Store behaving like the real library # # --------------------------------------------------------------------------- # @@ -100,7 +113,7 @@ def get_service_policies_by_role(self, role): return [ m.model_copy(deep=True) for m in self.data.values() - if any(r.role.id == role.id for r in m.inbound_rules) + if any(r.role.id == role.id for r in (m.inbound_allow_rules + m.inbound_deny_rules)) ] def apply_service_policy(self, service_id, spm): @@ -161,10 +174,12 @@ def engine_env(catalog, store): yield compute_and_apply -def run_engine(rules, *, catalog=None, store_initial=None, override=False) -> FakeStore: +def run_engine( + rules, *, catalog=None, store_initial=None, override=False, default_effect=RuleEffect.DENY +) -> FakeStore: store = FakeStore(store_initial) with engine_env(catalog or [], store) as compute_and_apply: - compute_and_apply(rules, override=override) + compute_and_apply(rules, override=override, default_effect=default_effect) return store @@ -176,16 +191,22 @@ def _pairs(rules): def _norm(apm): - """Order-independent view of an APM for equality assertions.""" + """Order-independent view of an APM for equality assertions — every split bucket.""" return { "agent_roles": sorted(r.id for r in apm.agent_roles), "agent_scopes": sorted(s.id for s in apm.agent_scopes), - "inbound": _pairs(apm.inbound_rules), - "outbound": _pairs(apm.outbound_rules), - "outbound_subject": _pairs(apm.outbound_subject_rules), + "inbound_subject_allow": _pairs(apm.inbound_subject_allow_rules), + "inbound_subject_deny": _pairs(apm.inbound_subject_deny_rules), + "inbound_source_allow": _pairs(apm.inbound_source_allow_rules), + "inbound_source_deny": _pairs(apm.inbound_source_deny_rules), + "outbound_target_allow": _pairs(apm.outbound_target_allow_rules), + "outbound_target_deny": _pairs(apm.outbound_target_deny_rules), + "outbound_subject_allow": _pairs(apm.outbound_subject_allow_rules), + "outbound_subject_deny": _pairs(apm.outbound_subject_deny_rules), "source_roles": {k: sorted(r.id for r in v) for k, v in apm.source_roles.items()}, "subject_roles": {k: sorted(r.id for r in v) for k, v in apm.subject_roles.items()}, - "target_scopes": {k: sorted(s.id for s in v) for k, v in apm.target_scopes.items()}, + "target_allow_scopes": {k: sorted(s.id for s in v) for k, v in apm.target_allow_scopes.items()}, + "target_deny_scopes": {k: sorted(s.id for s in v) for k, v in apm.target_deny_scopes.items()}, } @@ -214,12 +235,13 @@ def test_user_role_agent_scope_lands_inbound_and_pushes_once(): AR, UR, AS, TS, catalog = _repro() store = run_engine([_rule(UR, AS)], catalog=catalog) - # persisted on SPM(github-agent) + # persisted on SPM(github-agent) — an Allow (user role, agent scope) edge assert store.service_writes[0][0] == "github-agent" - assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] - # derived onto the agent's APM + assert _pairs(store.data["github-agent"].inbound_allow_rules) == [("r-user-dev", "s-agent-inbound")] + assert store.data["github-agent"].inbound_deny_rules == [] + # derived onto the agent's APM — a User role lands in the inbound SUBJECT allow bucket apm = store.pushed_agent("github-agent") - assert _pairs(apm.inbound_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(apm.inbound_subject_allow_rules) == [("r-user-dev", "s-agent-inbound")] assert apm.subject_roles == {"dev-user": [UR]} assert store.apply_policy_count == 1 @@ -232,10 +254,10 @@ def test_agent_role_tool_scope_derives_outbound_and_target_scopes(): AR, UR, AS, TS, catalog = _repro() store = run_engine([_rule(AR, TS)], catalog=catalog) - assert _pairs(store.data["github-tool"].inbound_rules) == [("r-agent-src", "s-tool-read")] + assert _pairs(store.data["github-tool"].inbound_allow_rules) == [("r-agent-src", "s-tool-read")] apm = store.pushed_agent("github-agent") - assert _pairs(apm.outbound_rules) == [("r-agent-src", "s-tool-read")] - assert {k: [s.id for s in v] for k, v in apm.target_scopes.items()} == {"github-tool": ["s-tool-read"]} + assert _pairs(apm.outbound_target_allow_rules) == [("r-agent-src", "s-tool-read")] + assert {k: [s.id for s in v] for k, v in apm.target_allow_scopes.items()} == {"github-tool": ["s-tool-read"]} # --------------------------------------------------------------------------- # @@ -247,7 +269,7 @@ def test_user_role_tool_scope_becomes_outbound_subject_gate(): store = run_engine([_rule(AR, TS), _rule(UR, TS)], catalog=catalog) apm = store.pushed_agent("github-agent") - assert _pairs(apm.outbound_subject_rules) == [("r-user-dev", "s-tool-read")] + assert _pairs(apm.outbound_subject_allow_rules) == [("r-user-dev", "s-tool-read")] assert apm.subject_roles == {"dev-user": [UR]} @@ -274,9 +296,9 @@ def test_both_orders_yield_identical_agent_policy(): assert _norm(apm_at) == _norm(apm_ta) # and it is the expected policy: inbound {UR->AS}, outbound {AR->TS} + subject gate {UR->TS} - assert _norm(apm_at)["inbound"] == [("r-user-dev", "s-agent-inbound")] - assert _norm(apm_at)["outbound"] == [("r-agent-src", "s-tool-read")] - assert _norm(apm_at)["outbound_subject"] == [("r-user-dev", "s-tool-read")] + assert _norm(apm_at)["inbound_subject_allow"] == [("r-user-dev", "s-agent-inbound")] + assert _norm(apm_at)["outbound_target_allow"] == [("r-agent-src", "s-tool-read")] + assert _norm(apm_at)["outbound_subject_allow"] == [("r-user-dev", "s-tool-read")] # --------------------------------------------------------------------------- # @@ -293,7 +315,7 @@ def test_late_user_role_on_tool_rederives_affected_agent_subject_gate(): compute([_rule(UR2, TS)]) # late UC3 user role on the tool apm = store.pushed_agent("github-agent") - subject_pairs = _pairs(apm.outbound_subject_rules) + subject_pairs = _pairs(apm.outbound_subject_allow_rules) assert ("r-user-ops", "s-tool-read") in subject_pairs assert "ops-user" in apm.subject_roles @@ -312,8 +334,8 @@ def test_agent_to_agent_edge_projects_into_both_policies(): store = run_engine([_rule(AR, BS)], catalog=catalog) apm_a = store.pushed_agent("agent-a") - assert _pairs(apm_a.outbound_rules) == [("r-a-caller", "s-b-inbound")] - assert {k: [s.id for s in v] for k, v in apm_a.target_scopes.items()} == {"agent-b": ["s-b-inbound"]} + assert _pairs(apm_a.outbound_target_allow_rules) == [("r-a-caller", "s-b-inbound")] + assert {k: [s.id for s in v] for k, v in apm_a.target_allow_scopes.items()} == {"agent-b": ["s-b-inbound"]} apm_b = store.pushed_agent("agent-b") assert {k: [r.id for r in v] for k, v in apm_b.source_roles.items()} == {"agent-a": ["r-a-caller"]} @@ -336,8 +358,8 @@ def test_override_purges_input_role_from_every_spm(): store = run_engine([_rule(shared, s1)], catalog=catalog, store_initial=initial, override=True) # svc-two's stale mapping for the shared role is gone; svc-one keeps the fresh one - assert _pairs(store.data["svc-two"].inbound_rules) == [] - assert _pairs(store.data["svc-one"].inbound_rules) == [("r-shared", "s-one")] + assert _pairs(_inbound(store.data["svc-two"])) == [] + assert _pairs(_inbound(store.data["svc-one"])) == [("r-shared", "s-one")] # purge scanned by role, once for the single distinct input role assert [r.id for r in store.by_role_calls].count("r-shared") == 1 @@ -360,8 +382,8 @@ def test_override_shared_role_purged_once_second_mapping_survives(): catalog=catalog, store_initial=initial, override=True, ) - assert _pairs(store.data["svc-one"].inbound_rules) == [("r-shared", "s-one")] - assert _pairs(store.data["svc-two"].inbound_rules) == [("r-shared", "s-two")] # not wiped + assert _pairs(_inbound(store.data["svc-one"])) == [("r-shared", "s-one")] + assert _pairs(_inbound(store.data["svc-two"])) == [("r-shared", "s-two")] # not wiped # --------------------------------------------------------------------------- # @@ -373,7 +395,7 @@ def test_duplicate_rule_not_appended_twice(): initial = {"github-agent": _spm("github-agent", owned_scopes=[AS], inbound=[_rule(UR, AS)])} store = run_engine([_rule(UR, AS)], catalog=catalog, store_initial=initial) - assert len(store.data["github-agent"].inbound_rules) == 1 + assert len(_inbound(store.data["github-agent"])) == 1 # --------------------------------------------------------------------------- # @@ -402,10 +424,10 @@ def test_tool_gets_spm_but_no_apm(): AR, UR, AS, TS, catalog = _repro() store = run_engine([_rule(AR, TS)], catalog=catalog) - assert _pairs(store.data["github-tool"].inbound_rules) == [("r-agent-src", "s-tool-read")] + assert _pairs(store.data["github-tool"].inbound_allow_rules) == [("r-agent-src", "s-tool-read")] assert "github-tool" not in store.pushed_agent_ids # no tool APM apm = store.pushed_agent("github-agent") - assert "github-tool" in apm.target_scopes + assert "github-tool" in apm.target_allow_scopes # --------------------------------------------------------------------------- # @@ -449,9 +471,12 @@ def test_shared_user_role_creates_no_false_outbound_edge(): store = run_engine([_rule(UR, AS), _rule(UR, TS)], catalog=catalog) apm = store.pushed_agent("github-agent") - assert apm.outbound_rules == [] - assert apm.target_scopes == {} - assert apm.outbound_subject_rules == [] # A does not target T, so no gate + assert apm.outbound_target_allow_rules == [] + assert apm.outbound_target_deny_rules == [] + assert apm.target_allow_scopes == {} + assert apm.target_deny_scopes == {} + assert apm.outbound_subject_allow_rules == [] # A does not target T, so no gate + assert apm.outbound_subject_deny_rules == [] # --------------------------------------------------------------------------- # @@ -485,16 +510,16 @@ def test_multi_role_capability_match_populates_both_outbound_gates(): store = run_engine(rules, catalog=catalog) apm = store.pushed_agent("github-agent") - # capability gate: all four agent->tool edges + target_scopes covering all four scopes - assert _pairs(apm.outbound_rules) == sorted([ + # capability gate: all four agent->tool edges + target_allow_scopes covering all four scopes + assert _pairs(apm.outbound_target_allow_rules) == sorted([ ("r-src-op", "s-source-read"), ("r-src-op", "s-source-write"), ("r-issue-op", "s-issues-read"), ("r-issue-op", "s-issues-write"), ]) - assert {k: sorted(s.id for s in v) for k, v in apm.target_scopes.items()} == { + assert {k: sorted(s.id for s in v) for k, v in apm.target_allow_scopes.items()} == { "github-tool": ["s-issues-read", "s-issues-write", "s-source-read", "s-source-write"], } # subject gate: the user->tool grant set (developer: source rw + issues read; tester: issues rw) - assert _pairs(apm.outbound_subject_rules) == sorted([ + assert _pairs(apm.outbound_subject_allow_rules) == sorted([ ("r-developer", "s-source-read"), ("r-developer", "s-source-write"), ("r-developer", "s-issues-read"), ("r-tester", "s-issues-read"), ("r-tester", "s-issues-write"), @@ -536,7 +561,7 @@ def test_absent_service_is_seeded_and_persisted(): spm = store.data["github-agent"] assert spm.service_type == ServiceType.AGENT assert [r.id for r in spm.owned_roles] == ["r-agent-src"] # seeded from catalog - assert _pairs(spm.inbound_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(spm.inbound_allow_rules) == [("r-user-dev", "s-agent-inbound")] # --------------------------------------------------------------------------- # @@ -589,7 +614,7 @@ def test_reconcile_drops_retired_scope_edge(): } store = run_engine([_rule(UR, AS)], catalog=catalog, store_initial=initial) - assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(_inbound(store.data["github-agent"])) == [("r-user-dev", "s-agent-inbound")] def test_reconcile_drops_churned_scope_uuid_same_name(): @@ -605,7 +630,7 @@ def test_reconcile_drops_churned_scope_uuid_same_name(): } store = run_engine([_rule(UR, as_v2)], catalog=catalog, store_initial=initial) - assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-as-v2")] + assert _pairs(_inbound(store.data["github-agent"])) == [("r-user-dev", "s-as-v2")] def test_reconcile_collapses_churned_duplicate_user_role(): @@ -623,7 +648,7 @@ def test_reconcile_collapses_churned_duplicate_user_role(): } store = run_engine([_rule(dev_new, AS)], catalog=catalog, store_initial=initial) - assert _pairs(store.data["github-agent"].inbound_rules) == [("r-dev-v2", "s-agent-inbound")] + assert _pairs(_inbound(store.data["github-agent"])) == [("r-dev-v2", "s-agent-inbound")] apm = store.pushed_agent("github-agent") assert apm.subject_roles == {"dev-user": [dev_new]} @@ -643,7 +668,7 @@ def test_reconcile_drops_retired_agent_role_self_reference(): } store = run_engine([_rule(UR, AS)], catalog=catalog, store_initial=initial) - assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(_inbound(store.data["github-agent"])) == [("r-user-dev", "s-agent-inbound")] def test_reconcile_preserves_live_edges_and_is_idempotent(): @@ -661,8 +686,8 @@ def test_reconcile_preserves_live_edges_and_is_idempotent(): [_rule(UR, AS), _rule(AR, TS), _rule(UR, TS)], catalog=catalog, store_initial=initial ) - assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] - assert _pairs(store.data["github-tool"].inbound_rules) == sorted( + assert _pairs(_inbound(store.data["github-agent"])) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(_inbound(store.data["github-tool"])) == sorted( [("r-agent-src", "s-tool-read"), ("r-user-dev", "s-tool-read")] ) @@ -677,7 +702,7 @@ def test_reconcile_skips_when_service_absent_from_catalog(): } store = run_engine([_rule(UR, orphan_scope)], catalog=[], store_initial=initial) - assert _pairs(store.data["orphan"].inbound_rules) == [("r-user-dev", "s-orphan")] + assert _pairs(_inbound(store.data["orphan"])) == [("r-user-dev", "s-orphan")] # --------------------------------------------------------------------------- # @@ -710,7 +735,7 @@ def test_decommission_tool_strands_no_edges_and_rederives_agent(): store = FakeStore() _onboard_repro(store) # sanity: onboarding gave A an outbound edge to the tool. - assert _pairs(store.pushed_agent("github-agent").outbound_rules) == [("r-agent-src", "s-tool-read")] + assert _pairs(store.pushed_agent("github-agent").outbound_target_allow_rules) == [("r-agent-src", "s-tool-read")] # Phase 2: the tool is gone from the catalog (its Keycloak client was deleted). run_decommission("github-tool", catalog=[_agent("github-agent", scopes=[])], store=store) @@ -722,10 +747,10 @@ def test_decommission_tool_strands_no_edges_and_rederives_agent(): # A re-derived: outbound to the tool is stranded (edge lived on SPM(T)); inbound UR→AS survives. apm = store.pushed_agent("github-agent") - assert apm.outbound_rules == [] - assert apm.target_scopes == {} - assert apm.outbound_subject_rules == [] - assert _pairs(apm.inbound_rules) == [("r-user-dev", "s-agent-inbound")] + assert apm.outbound_target_allow_rules == [] + assert apm.target_allow_scopes == {} + assert apm.outbound_subject_allow_rules == [] + assert _pairs(apm.inbound_subject_allow_rules) == [("r-user-dev", "s-agent-inbound")] def test_decommission_agent_deletes_apm_and_purges_outbound_footprint(): @@ -744,7 +769,305 @@ def test_decommission_agent_deletes_apm_and_purges_outbound_footprint(): assert store.agent_deletes == ["github-agent"] # A's outbound footprint purged from the tool; the tool keeps its user→TS grant. - assert _pairs(store.data["github-tool"].inbound_rules) == [("r-user-dev", "s-tool-read")] + assert _pairs(_inbound(store.data["github-tool"])) == [("r-user-dev", "s-tool-read")] # No APM re-derived for the deleted agent (nothing targeted it) — no new push. assert len(store.policy_pushes) == pushes_before + + +# =========================================================================== # +# Effect (ALLOW / DENY) routing and derivation (#118). Every inbound edge # +# carries a ``RuleEffect``; the engine files each into the owning SPM's # +# effect-matching list, and derivation classifies each edge by role.kind AND # +# effect into the split APM buckets (deny-overrides at request time). # +# =========================================================================== # +def test_deny_and_allow_rules_route_to_separate_inbound_lists(): + # A Deny edge lands in the owning SPM's inbound_deny_rules; an Allow edge in inbound_allow_rules. + AR, UR, AS, TS, catalog = _repro() + barred = _user_role("r-user-ops", "ops", users=["ops-user"]) + store = run_engine([_rule(UR, AS), _deny(barred, AS)], catalog=catalog) + + spm = store.data["github-agent"] + assert _pairs(spm.inbound_allow_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(spm.inbound_deny_rules) == [("r-user-ops", "s-agent-inbound")] + + +def test_same_role_scope_allow_and_deny_coexist(): + # Dedup identity is (role.id, scope.id, effect): the SAME (role, scope) may be present once as + # Allow and once as Deny — the two live in the separate lists, neither displacing the other. + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(UR, AS), _deny(UR, AS)], catalog=catalog) + + spm = store.data["github-agent"] + assert _pairs(spm.inbound_allow_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(spm.inbound_deny_rules) == [("r-user-dev", "s-agent-inbound")] + + +def test_override_purges_input_role_from_both_lists_on_one_spm(): + # override is role-level revocation over BOTH lists: a role present as an Allow edge and a Deny + # edge on the same SPM is purged from both before the fresh rule is re-appended. + shared = _user_role("r-shared", "shared", users=["u"]) + s1 = _scope("s-one", service_id="svc") + s2 = _scope("s-two", service_id="svc") + catalog = [_agent("svc", scopes=[s1, s2])] + initial = { + "svc": _spm("svc", owned_scopes=[s1, s2], inbound=[_rule(shared, s1), _deny(shared, s2)]), + } + # re-onboard the shared role as a single Allow edge on s1 + store = run_engine([_rule(shared, s1)], catalog=catalog, store_initial=initial, override=True) + + assert _pairs(store.data["svc"].inbound_allow_rules) == [("r-shared", "s-one")] + assert store.data["svc"].inbound_deny_rules == [] # the stale Deny edge purged too + + +def test_override_purges_role_across_spms_from_the_deny_list(): + # The role is an Allow edge on svc-one and a Deny edge on svc-two; override purges it from every + # SPM containing it in EITHER list, scanning by role once. + shared = _user_role("r-shared", "shared", users=["u"]) + s1 = _scope("s-one", service_id="svc-one") + s2 = _scope("s-two", service_id="svc-two") + catalog = [_agent("svc-one", scopes=[s1]), _agent("svc-two", scopes=[s2])] + initial = { + "svc-one": _spm("svc-one", owned_scopes=[s1], inbound=[_rule(shared, s1)]), + "svc-two": _spm("svc-two", owned_scopes=[s2], inbound=[_deny(shared, s2)]), + } + store = run_engine([_rule(shared, s1)], catalog=catalog, store_initial=initial, override=True) + + assert _inbound(store.data["svc-two"]) == [] # stale Deny edge on the other SPM is gone + assert _pairs(store.data["svc-one"].inbound_allow_rules) == [("r-shared", "s-one")] + assert [r.id for r in store.by_role_calls].count("r-shared") == 1 + + +def test_reconcile_drops_dangling_deny_edge_and_keeps_live_deny(): + # Reconcile scans the deny list too: a retired-scope DENY edge is pruned while the current DENY + # edge survives. + barred = _user_role("r-user-ops", "ops", users=["ops-user"]) + AS = _scope("s-agent-inbound", "agent-inbound", service_id="github-agent") + aud = _scope("s-aud", "agent-team1-github-agent-aud", service_id="github-agent") # retired + catalog = [_agent("github-agent", scopes=[AS])] # ``aud`` no longer exists + initial = { + "github-agent": _spm( + "github-agent", owned_scopes=[AS], inbound=[_deny(barred, aud), _deny(barred, AS)] + ) + } + store = run_engine([_deny(barred, AS)], catalog=catalog, store_initial=initial) + + assert store.data["github-agent"].inbound_allow_rules == [] + assert _pairs(store.data["github-agent"].inbound_deny_rules) == [("r-user-ops", "s-agent-inbound")] + + +def test_reconcile_churn_collapse_is_per_list_so_a_live_deny_survives(): + # The user-role churn collapse is computed independently per list. An Allow edge whose + # (scope, name) matches a Deny edge of a DIFFERENT id must not cause the live Deny edge to be + # pruned (a cross-list collapse would be a bug). + dev_allow = _user_role("r-dev-allow", "developer", users=["dev-user"]) + dev_deny = _user_role("r-dev-deny", "developer", users=["dev-user"]) + AS = _scope("s-agent-inbound", "agent-inbound", service_id="github-agent") + catalog = [_agent("github-agent", scopes=[AS])] + initial = { + "github-agent": _spm( + "github-agent", owned_scopes=[AS], inbound=[_rule(dev_allow, AS), _deny(dev_deny, AS)] + ) + } + store = run_engine([_rule(dev_allow, AS)], catalog=catalog, store_initial=initial) # allow gen only + + assert _pairs(store.data["github-agent"].inbound_allow_rules) == [("r-dev-allow", "s-agent-inbound")] + assert _pairs(store.data["github-agent"].inbound_deny_rules) == [("r-dev-deny", "s-agent-inbound")] + + +def test_reconcile_preserves_live_deny_edge_and_is_idempotent(): + # A live Deny edge (all entities current) is never pruned; a second identical compute leaves both + # lists unchanged (order-independence over both lists). + AR, UR, AS, TS, catalog = _repro() + barred = _user_role("r-user-ops", "ops", users=["ops-user"]) + initial = { + "github-agent": _spm( + "github-agent", owned_scopes=[AS], inbound=[_rule(UR, AS), _deny(barred, AS)] + ), + } + store = FakeStore(initial) + with engine_env(catalog, store) as compute: + compute([_rule(UR, AS), _deny(barred, AS)]) + first = ( + _pairs(store.data["github-agent"].inbound_allow_rules), + _pairs(store.data["github-agent"].inbound_deny_rules), + ) + compute([_rule(UR, AS), _deny(barred, AS)]) # idempotent second pass + + assert first == ([("r-user-dev", "s-agent-inbound")], [("r-user-ops", "s-agent-inbound")]) + assert _pairs(store.data["github-agent"].inbound_allow_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(store.data["github-agent"].inbound_deny_rules) == [("r-user-ops", "s-agent-inbound")] + + +def test_decommission_tool_deletes_spm_holding_both_allow_and_deny_inbound(): + # SPM(T) holds an Allow user edge, a Deny user edge, and an agent capability edge on TS. + # Offboarding T deletes SPM(T) (both lists at once) and re-derives the agent that targeted it + # with its outbound stranded. + AR = _agent_role("r-agent-src", "agent-source", owner="github-agent") + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + barred = _user_role("r-user-ops", "ops", users=["ops-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]), _tool("github-tool", scopes=[TS])] + store = FakeStore() + with engine_env(catalog, store) as compute: + compute([_rule(UR, AS), _rule(AR, TS), _rule(UR, TS), _deny(barred, TS)]) + + # sanity: both lists on SPM(T) are populated before offboard. + assert _pairs(store.data["github-tool"].inbound_allow_rules) == sorted( + [("r-agent-src", "s-tool-read"), ("r-user-dev", "s-tool-read")] + ) + assert _pairs(store.data["github-tool"].inbound_deny_rules) == [("r-user-ops", "s-tool-read")] + + run_decommission( + "github-tool", catalog=[_agent("github-agent", roles=[AR], scopes=[AS])], store=store + ) + + assert "github-tool" in store.service_deletes + assert "github-tool" not in store.data # SPM(T) gone — both lists torn down together + apm = store.pushed_agent("github-agent") # agent re-derived, outbound stranded + assert apm.outbound_target_allow_rules == [] + assert apm.target_allow_scopes == {} + assert apm.outbound_subject_allow_rules == [] + + +def test_decommission_purges_agent_deny_footprint_from_other_spm(): + # A's agent role carries a DENY edge on the tool (AR→TS deny). Offboarding A purges that edge + # from SPM(T)'s inbound_deny_rules — the footprint scan covers the deny list too — while the + # tool keeps its unrelated user allow grant. + 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]), _tool("github-tool", scopes=[TS])] + store = FakeStore() + with engine_env(catalog, store) as compute: + compute([_rule(UR, AS), _deny(AR, TS), _rule(UR, TS)]) + + assert _pairs(store.data["github-tool"].inbound_deny_rules) == [("r-agent-src", "s-tool-read")] + + run_decommission("github-agent", catalog=[_tool("github-tool", scopes=[TS])], store=store) + + assert "github-agent" in store.service_deletes + assert store.agent_deletes == ["github-agent"] + assert store.data["github-tool"].inbound_deny_rules == [] # A's deny footprint purged + assert _pairs(store.data["github-tool"].inbound_allow_rules) == [("r-user-dev", "s-tool-read")] + + +def test_derive_classifies_subject_deny_inbound_and_registers_identity(): + # A User-kind DENY edge on SPM(A) derives into inbound_subject_deny_rules; the barred user is + # still registered into the EFFECT-AGNOSTIC subject_roles map alongside the allowed one. + AR, UR, AS, TS, catalog = _repro() + barred = _user_role("r-user-ops", "ops", users=["ops-user"]) + store = run_engine([_rule(UR, AS), _deny(barred, AS)], catalog=catalog) + + apm = store.pushed_agent("github-agent") + assert _pairs(apm.inbound_subject_allow_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(apm.inbound_subject_deny_rules) == [("r-user-ops", "s-agent-inbound")] + assert apm.subject_roles == {"dev-user": [UR], "ops-user": [barred]} + + +def test_derive_registers_deny_only_subject_into_effect_agnostic_map(): + # Correctness invariant: a subject appearing ONLY in a DENY edge (no allow anywhere) must still + # register in subject_roles, or the generated deny lookup cannot resolve it and the prohibition + # silently never fires. + AR, UR, AS, TS, catalog = _repro() + barred = _user_role("r-user-ops", "ops", users=["ops-user"]) + store = run_engine([_deny(barred, AS)], catalog=catalog) + + apm = store.pushed_agent("github-agent") + assert apm.inbound_subject_allow_rules == [] + assert _pairs(apm.inbound_subject_deny_rules) == [("r-user-ops", "s-agent-inbound")] + assert apm.subject_roles == {"ops-user": [barred]} # deny-only, still registered + + +def test_derive_classifies_source_deny_inbound_and_registers_source_identity(): + # An Agent-kind DENY edge on SPM(B) derives into inbound_source_deny_rules and registers the + # calling agent into the effect-agnostic source_roles; A's outbound sees the deny target. + AR = _agent_role("r-a-caller", "a-caller", owner="agent-a") + BS = _scope("s-b-inbound", "b-inbound", service_id="agent-b") + catalog = [ + _agent("agent-a", roles=[AR], scopes=[_scope("s-a-inbound", service_id="agent-a")]), + _agent("agent-b", scopes=[BS]), + ] + store = run_engine([_deny(AR, BS)], catalog=catalog) + + apm_b = store.pushed_agent("agent-b") + assert _pairs(apm_b.inbound_source_deny_rules) == [("r-a-caller", "s-b-inbound")] + assert apm_b.inbound_source_allow_rules == [] + assert {k: [r.id for r in v] for k, v in apm_b.source_roles.items()} == {"agent-a": ["r-a-caller"]} + + apm_a = store.pushed_agent("agent-a") + assert _pairs(apm_a.outbound_target_deny_rules) == [("r-a-caller", "s-b-inbound")] + assert {k: [s.id for s in v] for k, v in apm_a.target_deny_scopes.items()} == {"agent-b": ["s-b-inbound"]} + assert apm_a.outbound_target_allow_rules == [] + assert apm_a.target_allow_scopes == {} + + +def test_derive_agent_deny_target_scope_and_outbound_subject_deny_gate(): + # An agent-role → target-scope DENY edge derives into outbound_target_deny_rules + + # target_deny_scopes. Per the spec the subject gate is gathered for every target scope (allow OR + # deny), split by the USER edge's own effect: an allowed user lands in the allow gate, a barred + # user in the deny gate, and both register into the effect-agnostic subject_roles. + AR = _agent_role("r-agent-src", "agent-source", owner="github-agent") + allowed = _user_role("r-user-dev", "developer", users=["dev-user"]) + barred = _user_role("r-user-ops", "ops", users=["ops-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]), _tool("github-tool", scopes=[TS])] + store = run_engine([_deny(AR, TS), _rule(allowed, TS), _deny(barred, TS)], catalog=catalog) + + apm = store.pushed_agent("github-agent") + # agent capability deny -> target_deny_scopes + outbound_target_deny_rules + assert _pairs(apm.outbound_target_deny_rules) == [("r-agent-src", "s-tool-read")] + assert {k: [s.id for s in v] for k, v in apm.target_deny_scopes.items()} == {"github-tool": ["s-tool-read"]} + assert apm.outbound_target_allow_rules == [] + assert apm.target_allow_scopes == {} + # outbound subject gate split by the USER edge's effect + assert _pairs(apm.outbound_subject_allow_rules) == [("r-user-dev", "s-tool-read")] + assert _pairs(apm.outbound_subject_deny_rules) == [("r-user-ops", "s-tool-read")] + assert apm.subject_roles == {"dev-user": [allowed], "ops-user": [barred]} + + +# --------------------------------------------------------------------------- # +# 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. # +# --------------------------------------------------------------------------- # +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) + + apm = store.pushed_agent("github-agent") + assert apm.default_effect == RuleEffect.DENY + + +def test_default_effect_allow_propagates_to_derived_apm(): + # A caller-requested ALLOW threads compute_and_apply -> _run -> _derive and lands on the APM. + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(UR, AS)], catalog=catalog, default_effect=RuleEffect.ALLOW) + + apm = store.pushed_agent("github-agent") + assert apm.default_effect == RuleEffect.ALLOW + + +def test_default_effect_stamped_on_every_emitted_apm(): + # The default_effect rides onto EVERY APM a single recompute emits, not just the first. + 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") + catalog = [ + _agent("agent-1", roles=[A1], scopes=[S1]), + _agent("agent-2", roles=[A2], scopes=[S2]), + ] + store = run_engine( + [_rule(U, S1), _rule(U, S2)], catalog=catalog, default_effect=RuleEffect.ALLOW + ) + + 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 diff --git a/aiac/test/policy/model/test_models.py b/aiac/test/policy/model/test_models.py index 4c009c45b..f9d9f571d 100644 --- a/aiac/test/policy/model/test_models.py +++ b/aiac/test/policy/model/test_models.py @@ -13,6 +13,7 @@ AgentPolicyModel, PolicyModel, PolicyRule, + RuleEffect, ServicePolicyModel, ) @@ -82,6 +83,19 @@ def test_role_rejects_non_list_actor_ids(): ) +# --- RuleEffect enum (ALLOW/DENY) --- + + +def test_rule_effect_values_mirror_service_type_style(): + assert RuleEffect.ALLOW == "Allow" + assert RuleEffect.DENY == "Deny" + + +def test_rule_effect_serializes_as_string(): + rule = PolicyRule(role=_role(), scope=_scope(), effect=RuleEffect.DENY) + assert rule.model_dump(mode="json")["effect"] == "Deny" + + # --- Scope.serviceId (SPM routing key) --- @@ -99,21 +113,48 @@ def test_scope_service_id_round_trip(): # --- ServicePolicyModel (persistent source of truth) --- -def test_service_policy_model_constructs(): +def test_service_policy_model_constructs_with_split_rule_lists(): role = _role() scope = _scope() + allow = PolicyRule(role=role, scope=scope, effect=RuleEffect.ALLOW) + deny = PolicyRule(role=role, scope=scope, effect=RuleEffect.DENY) spm = ServicePolicyModel( service_id="github-tool", service_type=ServiceType.TOOL, owned_roles=[role], owned_scopes=[scope], - inbound_rules=[PolicyRule(role=role, scope=scope)], + inbound_allow_rules=[allow], + inbound_deny_rules=[deny], ) assert spm.service_id == "github-tool" assert spm.service_type == ServiceType.TOOL assert spm.owned_roles == [role] assert spm.owned_scopes == [scope] - assert spm.inbound_rules == [PolicyRule(role=role, scope=scope)] + assert spm.inbound_allow_rules == [allow] + assert spm.inbound_deny_rules == [deny] + + +def test_service_policy_model_has_no_intermixed_inbound_rules_field(): + spm = ServicePolicyModel( + service_id="svc", + service_type=ServiceType.TOOL, + owned_roles=[], + owned_scopes=[], + ) + # The single intermixed list is gone — allow/deny are explicitly separated. + assert "inbound_rules" not in ServicePolicyModel.model_fields + assert not hasattr(spm, "inbound_rules") + + +def test_service_policy_model_rule_lists_default_empty(): + spm = ServicePolicyModel( + service_id="svc", + service_type=ServiceType.TOOL, + owned_roles=[], + owned_scopes=[], + ) + assert spm.inbound_allow_rules == [] + assert spm.inbound_deny_rules == [] def test_service_policy_model_round_trip_string_keys_only(): @@ -124,7 +165,8 @@ def test_service_policy_model_round_trip_string_keys_only(): service_type=ServiceType.AGENT, owned_roles=[role], owned_scopes=[scope], - inbound_rules=[PolicyRule(role=role, scope=scope)], + inbound_allow_rules=[PolicyRule(role=role, scope=scope, effect=RuleEffect.ALLOW)], + inbound_deny_rules=[PolicyRule(role=role, scope=scope, effect=RuleEffect.DENY)], ) dumped = spm.model_dump(mode="json") assert all(isinstance(k, str) for k in dumped.keys()) @@ -139,7 +181,8 @@ def test_service_policy_model_ignores_extra_fields(): "service_type": "Tool", "owned_roles": [], "owned_scopes": [], - "inbound_rules": [], + "inbound_allow_rules": [], + "inbound_deny_rules": [], "unknown_field": "ignored", } ) @@ -167,6 +210,28 @@ def test_policy_rule_rejects_plain_str_scope(): PolicyRule(role=_role(), scope="read") +def test_policy_rule_effect_defaults_to_allow(): + rule = PolicyRule(role=_role(), scope=_scope()) + assert rule.effect == RuleEffect.ALLOW + + +def test_policy_rule_accepts_explicit_deny(): + rule = PolicyRule(role=_role(), scope=_scope(), effect=RuleEffect.DENY) + assert rule.effect == RuleEffect.DENY + + +def test_same_role_scope_coexists_as_allow_and_deny(): + role = _role() + scope = _scope() + allow = PolicyRule(role=role, scope=scope, effect=RuleEffect.ALLOW) + deny = PolicyRule(role=role, scope=scope, effect=RuleEffect.DENY) + # Dedup identity is (role.id, scope.id, effect): differing only in effect keeps them distinct, + # so both survive side by side in a rule list. + assert allow != deny + rules = [allow, deny] + assert rules == [allow, deny] + + # --- AgentPolicyModel relationship maps keyed by string id --- @@ -179,31 +244,11 @@ def test_agent_policy_model_source_roles_keyed_by_service_id(): agent_scopes=[], subject_roles={}, source_roles={svc.id: [role]}, - target_scopes={}, - inbound_rules=[], - outbound_rules=[], ) dumped = model.model_dump(mode="json") assert list(dumped["source_roles"].keys()) == [svc.id] -def test_agent_policy_model_target_scopes_keyed_by_target_id(): - scope = _scope() - svc = _service() - model = AgentPolicyModel( - agent_id="agent-1", - agent_roles=[], - agent_scopes=[scope], - subject_roles={}, - source_roles={}, - target_scopes={svc.id: [scope]}, - inbound_rules=[], - outbound_rules=[], - ) - dumped = model.model_dump(mode="json") - assert list(dumped["target_scopes"].keys()) == [svc.id] - - def test_agent_policy_model_subject_roles_keyed_by_subject_id(): subject = _subject() role = _role() @@ -213,47 +258,72 @@ def test_agent_policy_model_subject_roles_keyed_by_subject_id(): agent_scopes=[], subject_roles={subject.id: [role]}, source_roles={}, - target_scopes={}, - inbound_rules=[], - outbound_rules=[], ) dumped = model.model_dump(mode="json") assert list(dumped["subject_roles"].keys()) == [subject.id] -# --- outbound_subject_rules (user role -> tool scope) --- +def test_agent_policy_model_target_allow_scopes_keyed_by_target_id(): + scope = _scope() + svc = _service() + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[], + agent_scopes=[scope], + subject_roles={}, + source_roles={}, + target_allow_scopes={svc.id: [scope]}, + ) + dumped = model.model_dump(mode="json") + assert list(dumped["target_allow_scopes"].keys()) == [svc.id] -def test_agent_policy_model_outbound_subject_rules_defaults_empty(): +def test_agent_policy_model_target_deny_scopes_keyed_by_target_id(): + scope = _scope() + svc = _service() model = AgentPolicyModel( agent_id="agent-1", agent_roles=[], - agent_scopes=[], + agent_scopes=[scope], subject_roles={}, source_roles={}, - target_scopes={}, - inbound_rules=[], - outbound_rules=[], + target_deny_scopes={svc.id: [scope]}, ) - assert model.outbound_subject_rules == [] + dumped = model.model_dump(mode="json") + assert list(dumped["target_deny_scopes"].keys()) == [svc.id] -def test_agent_policy_model_outbound_subject_rules_round_trip(): - role = _role() - scope = _scope() +# --- 8 entity×effect rule lists + split target maps --- + +_EIGHT_RULE_LISTS = [ + "inbound_subject_allow_rules", + "inbound_subject_deny_rules", + "inbound_source_allow_rules", + "inbound_source_deny_rules", + "outbound_target_allow_rules", + "outbound_target_deny_rules", + "outbound_subject_allow_rules", + "outbound_subject_deny_rules", +] + + +def test_agent_policy_model_eight_lists_and_target_maps_default_empty(): model = AgentPolicyModel( agent_id="agent-1", agent_roles=[], agent_scopes=[], subject_roles={}, source_roles={}, - target_scopes={}, - inbound_rules=[], - outbound_rules=[], - outbound_subject_rules=[PolicyRule(role=role, scope=scope)], ) - restored = AgentPolicyModel.model_validate(model.model_dump(mode="json")) - assert restored.outbound_subject_rules == [PolicyRule(role=role, scope=scope)] + for field in _EIGHT_RULE_LISTS: + assert getattr(model, field) == [], f"{field} should default to []" + assert model.target_allow_scopes == {} + assert model.target_deny_scopes == {} + + +def test_agent_policy_model_has_no_legacy_rule_fields(): + for legacy in ("inbound_rules", "outbound_rules", "outbound_subject_rules", "target_scopes"): + assert legacy not in AgentPolicyModel.model_fields, f"{legacy} should be removed" # --- model_validate round-trip (JSON mode) --- @@ -264,19 +334,72 @@ def test_agent_policy_model_round_trip(): role = _role() scope = _scope() svc = _service() + allow = PolicyRule(role=role, scope=scope, effect=RuleEffect.ALLOW) + deny = PolicyRule(role=role, scope=scope, effect=RuleEffect.DENY) model = AgentPolicyModel( agent_id="agent-1", agent_roles=[role], agent_scopes=[scope], subject_roles={subject.id: [role]}, source_roles={svc.id: [role]}, - target_scopes={svc.id: [scope]}, - inbound_rules=[PolicyRule(role=role, scope=scope)], - outbound_rules=[], + target_allow_scopes={svc.id: [scope]}, + target_deny_scopes={svc.id: [scope]}, + inbound_subject_allow_rules=[allow], + inbound_subject_deny_rules=[deny], + inbound_source_allow_rules=[allow], + inbound_source_deny_rules=[deny], + outbound_target_allow_rules=[allow], + outbound_target_deny_rules=[deny], + outbound_subject_allow_rules=[allow], + outbound_subject_deny_rules=[deny], ) dumped = model.model_dump(mode="json") restored = AgentPolicyModel.model_validate(dumped) assert restored == model + # Typed PolicyRule / Scope values survive the round-trip in the split lists and target maps. + assert restored.outbound_target_deny_rules[0].effect == RuleEffect.DENY + assert restored.target_allow_scopes[svc.id] == [scope] + + +# --- effect-agnostic identity maps must include deny-only roles --- + + +def test_deny_only_subject_role_still_registered_in_subject_roles(): + # A role that appears ONLY in a DENY edge must still be registered in the effect-agnostic + # subject_roles map, or the Rego deny lookup cannot resolve it and the prohibition never fires. + subject = _subject() + deny_role = _role(id="deny-role", name="developer") + agent_scope = _scope(id="agent-scope", name="invoke") + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[], + agent_scopes=[agent_scope], + source_roles={}, + subject_roles={subject.id: [deny_role]}, # deny-only role, still listed + inbound_subject_allow_rules=[], + inbound_subject_deny_rules=[PolicyRule(role=deny_role, scope=agent_scope, effect=RuleEffect.DENY)], + ) + assert deny_role in model.subject_roles[subject.id] + restored = AgentPolicyModel.model_validate(model.model_dump(mode="json")) + assert restored.subject_roles[subject.id] == [deny_role] + + +def test_deny_only_source_role_still_registered_in_source_roles(): + svc = _service() + deny_role = _role(id="deny-src", name="caller") + agent_scope = _scope(id="agent-scope", name="invoke") + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[], + agent_scopes=[agent_scope], + source_roles={svc.id: [deny_role]}, # deny-only source role, still listed + subject_roles={}, + inbound_source_allow_rules=[], + inbound_source_deny_rules=[PolicyRule(role=deny_role, scope=agent_scope, effect=RuleEffect.DENY)], + ) + assert deny_role in model.source_roles[svc.id] + restored = AgentPolicyModel.model_validate(model.model_dump(mode="json")) + assert restored.source_roles[svc.id] == [deny_role] # --- extra='ignore' on all three model types --- @@ -297,9 +420,6 @@ def test_agent_policy_model_ignores_extra_fields(): "agent_scopes": [], "subject_roles": {}, "source_roles": {}, - "target_scopes": {}, - "inbound_rules": [], - "outbound_rules": [], "unknown_field": "ignored", } ) diff --git a/aiac/test/policy/model_store/library/test_api.py b/aiac/test/policy/model_store/library/test_api.py index 6280aa6df..2e443f674 100644 --- a/aiac/test/policy/model_store/library/test_api.py +++ b/aiac/test/policy/model_store/library/test_api.py @@ -22,7 +22,7 @@ def _spm_dict(service_id: str = "svc-1", role_id: str = "role-1") -> dict: service_type=ServiceType.AGENT, owned_roles=[], owned_scopes=[], - inbound_rules=[ + inbound_allow_rules=[ PolicyRule( role=Role(id=role_id, name="admin", composite=False), scope=Scope(id="scope-1", name="read", serviceId=service_id), @@ -68,7 +68,8 @@ def test_by_id_miss_returns_fresh_empty_spm_no_raise(self): assert result.service_id == "brand-new" assert result.owned_roles == [] assert result.owned_scopes == [] - assert result.inbound_rules == [] + assert result.inbound_allow_rules == [] + assert result.inbound_deny_rules == [] def test_raises_on_other_error_response(self): with patch("requests.get") as mock_get: diff --git a/aiac/test/policy/model_store/service/test_main.py b/aiac/test/policy/model_store/service/test_main.py index ec0158e0f..05b1f7128 100644 --- a/aiac/test/policy/model_store/service/test_main.py +++ b/aiac/test/policy/model_store/service/test_main.py @@ -41,7 +41,7 @@ def _spm( service_type=service_type, owned_roles=[_role()], owned_scopes=[_scope(service_id=service_id)], - inbound_rules=[PolicyRule(role=_role(id=role_id), scope=_scope(service_id=service_id))], + inbound_allow_rules=[PolicyRule(role=_role(id=role_id), scope=_scope(service_id=service_id))], ) @@ -152,6 +152,23 @@ def test_returns_single_spm_referencing_role(self, client): body = resp.json() assert [s["service_id"] for s in body] == ["svc-a"] + def test_returns_spm_when_role_referenced_only_in_deny_rules(self, client): + # The scan must cover BOTH parallel lists: a role reference living only in + # inbound_deny_rules (with an empty allow list) must still surface here, because + # override-purge needs to find stale deny edges just as much as allow edges. + deny_spm = ServicePolicyModel( + service_id="svc-deny", + service_type=ServiceType.AGENT, + owned_roles=[_role()], + owned_scopes=[_scope(service_id="svc-deny")], + inbound_allow_rules=[], + inbound_deny_rules=[PolicyRule(role=_role(id="denied-role"), scope=_scope(service_id="svc-deny"))], + ) + _preload(deny_spm) + resp = client.get("/policy/services", params={"role": "denied-role"}) + assert resp.status_code == 200 + assert [s["service_id"] for s in resp.json()] == ["svc-deny"] + def test_returns_all_spms_when_several_reference_role(self, client): _preload(_spm("svc-a", role_id="shared-role")) _preload(_spm("svc-b", role_id="shared-role")) @@ -198,7 +215,7 @@ def test_repeat_post_replaces_row_upsert_round_trip(self, client): # The stored/cached SPM now carries the second write's rule. resp = client.get(f"/policy/services/{encoded}") - rule_role_ids = [r["role"]["id"] for r in resp.json()["inbound_rules"]] + rule_role_ids = [r["role"]["id"] for r in resp.json()["inbound_allow_rules"]] assert rule_role_ids == ["role-b"] def test_returns_502_on_sqlite_error(self, client):