From fa3e96aa111e6983c79c0719a792be8171bd05cc Mon Sep 17 00:00:00 2001 From: Baraa Abuarra Date: Tue, 9 Jun 2026 13:55:55 +0200 Subject: [PATCH] Add bounded policy conditions --- api/nexus/authorization/http_models.py | 16 ++++ api/nexus/authorization/models.py | 2 + api/nexus/authorization/routes.py | 41 +++++++++ api/nexus/authorization/service.py | 33 ++++++++ api/nexus/main.py | 3 + api/nexus/repositories/decisions.py | 11 ++- api/nexus/repositories/policy_conditions.py | 90 ++++++++++++++++++++ api/tests/conftest.py | 1 + api/tests/test_authorization_service.py | 56 ++++++++++++- api/tests/test_authorize_endpoint.py | 92 ++++++++++++++++++++- migrations/0018_policy_conditions.sql | 41 +++++++++ 11 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 api/nexus/repositories/policy_conditions.py create mode 100644 migrations/0018_policy_conditions.sql diff --git a/api/nexus/authorization/http_models.py b/api/nexus/authorization/http_models.py index 17845b5..7c79807 100644 --- a/api/nexus/authorization/http_models.py +++ b/api/nexus/authorization/http_models.py @@ -57,3 +57,19 @@ class ToolPermissionResponse(BaseModel): agent_key: str tool_name: str enabled: bool + + +class PolicyConditionRequest(BaseModel): + policy_id: UUID + agent_key: str = Field(min_length=1, max_length=128) + condition_type: str = Field(min_length=1, max_length=64) + condition_value: str = Field(min_length=1, max_length=256) + enabled: bool = True + + +class PolicyConditionResponse(BaseModel): + policy_id: UUID + agent_key: str + condition_type: str + condition_value: str + enabled: bool diff --git a/api/nexus/authorization/models.py b/api/nexus/authorization/models.py index 35ff3ec..76ea758 100644 --- a/api/nexus/authorization/models.py +++ b/api/nexus/authorization/models.py @@ -18,6 +18,7 @@ class DecisionReason(StrEnum): KILL_SWITCH_ACTIVE = "denied: kill switch active" BUDGET_EXCEEDED = "denied: budget exceeded" TOOL_NOT_ALLOWED = "denied: tool not allowed" + POLICY_CONDITION_MATCHED = "denied: policy condition matched" @dataclass(frozen=True) @@ -45,3 +46,4 @@ class AuthorizationDecision: fail_mode_applied: bool reason: DecisionReason decided_at: datetime + policy_id: UUID | None = None diff --git a/api/nexus/authorization/routes.py b/api/nexus/authorization/routes.py index 040397c..7d1abd6 100644 --- a/api/nexus/authorization/routes.py +++ b/api/nexus/authorization/routes.py @@ -13,6 +13,8 @@ BudgetResponse, ToolPermissionRequest, ToolPermissionResponse, + PolicyConditionRequest, + PolicyConditionResponse, ) from nexus.authorization.models import AuthorizationRequest from nexus.authorization.service import AuthorizationService @@ -35,6 +37,7 @@ def _authorization_service(request: Request) -> AuthorizationService: request.app.state.kill_switch_repo, request.app.state.budget_repo, request.app.state.tool_permission_repo, + request.app.state.policy_condition_repo, ) @@ -194,3 +197,41 @@ async def set_tool_permission(request: Request): tool_name=payload.tool_name, enabled=payload.enabled, ) + + +@router.post("/policy-conditions", response_model=PolicyConditionResponse) +async def set_policy_condition(request: Request): + try: + body = await request.json() + except Exception: + return _error(400, "malformed_request", "request body is not valid JSON") + + try: + payload = PolicyConditionRequest.model_validate(body) + except PydanticValidationError as e: + details = [ + {"field": ".".join(str(p) for p in err["loc"]), "issue": err["msg"]} + for err in e.errors() + ] + return _error( + 422, + "validation_failed", + "request failed schema validation", + details, + ) + + await request.app.state.policy_condition_repo.set_policy( + policy_id=payload.policy_id, + agent_key=payload.agent_key, + condition_type=payload.condition_type, + condition_value=payload.condition_value, + enabled=payload.enabled, + ) + + return PolicyConditionResponse( + policy_id=payload.policy_id, + agent_key=payload.agent_key, + condition_type=payload.condition_type, + condition_value=payload.condition_value, + enabled=payload.enabled, + ) diff --git a/api/nexus/authorization/service.py b/api/nexus/authorization/service.py index 4d5c652..d237528 100644 --- a/api/nexus/authorization/service.py +++ b/api/nexus/authorization/service.py @@ -48,6 +48,16 @@ async def is_allowed(self, agent_key: str, tool_name: str) -> bool: """Return whether this agent may invoke this tool.""" +class PolicyConditionReader(Protocol): + async def matching_policy( + self, + *, + agent_key: str, + tool_name: str, + ): + """Return matched bounded policy condition, if any.""" + + class AuthorizationService: def __init__( self, @@ -55,11 +65,13 @@ def __init__( kill_switch: KillSwitchReader | None = None, budget: BudgetReader | None = None, tool_permissions: ToolPermissionReader | None = None, + policy_conditions: PolicyConditionReader | None = None, ): self._writer = writer self._kill_switch = kill_switch self._budget = budget self._tool_permissions = tool_permissions + self._policy_conditions = policy_conditions async def _budget_exceeded(self, request: AuthorizationRequest) -> bool: if self._budget is None: @@ -89,6 +101,15 @@ async def _tool_not_allowed(self, request: AuthorizationRequest) -> bool: request.tool_name, ) + async def _matched_policy(self, request: AuthorizationRequest): + if self._policy_conditions is None: + return None + + return await self._policy_conditions.matching_policy( + agent_key=request.agent_key, + tool_name=request.tool_name, + ) + async def authorize( self, request: AuthorizationRequest, @@ -97,6 +118,8 @@ async def authorize( if self._kill_switch is not None: kill_switch_active = await self._kill_switch.is_halted(request.agent_key) + matched_policy = await self._matched_policy(request) + if kill_switch_active: decision = AuthorizationDecision( decision_id=uuid4(), @@ -121,6 +144,15 @@ async def authorize( reason=DecisionReason.TOOL_NOT_ALLOWED, decided_at=datetime.now(UTC), ) + elif matched_policy is not None: + decision = AuthorizationDecision( + decision_id=uuid4(), + effect=DecisionEffect.DENY, + fail_mode_applied=False, + reason=DecisionReason.POLICY_CONDITION_MATCHED, + decided_at=datetime.now(UTC), + policy_id=matched_policy.policy_id, + ) elif request.tool_name == "__nexus_deny_test__": decision = AuthorizationDecision( decision_id=uuid4(), @@ -155,4 +187,5 @@ async def authorize( fail_mode_applied=recorded.fail_mode_applied, reason=DecisionReason(recorded.reason), decided_at=recorded.decided_at, + policy_id=recorded.policy_id, ) diff --git a/api/nexus/main.py b/api/nexus/main.py index fbf5e77..f67197f 100644 --- a/api/nexus/main.py +++ b/api/nexus/main.py @@ -37,6 +37,8 @@ from nexus.repositories.kill_switch import KillSwitchRepository from nexus.repositories.budget import BudgetRepository from nexus.repositories.tool_permissions import ToolPermissionRepository +from nexus.repositories.policy_conditions import PolicyConditionRepository + def create_app(settings: Settings | None = None) -> FastAPI: @@ -58,6 +60,7 @@ async def lifespan(app: FastAPI): app.state.kill_switch_repo = KillSwitchRepository(pool) app.state.budget_repo = BudgetRepository(pool) app.state.tool_permission_repo = ToolPermissionRepository(pool) + app.state.policy_condition_repo = PolicyConditionRepository(pool) try: yield finally: diff --git a/api/nexus/repositories/decisions.py b/api/nexus/repositories/decisions.py index 6f9b8f4..ff30354 100644 --- a/api/nexus/repositories/decisions.py +++ b/api/nexus/repositories/decisions.py @@ -17,6 +17,7 @@ class RecordedDecision: effect: str fail_mode_applied: bool reason: str + policy_id: UUID | None decided_at: datetime @@ -31,12 +32,12 @@ async def record( """ INSERT INTO decisions (tenant_id, decision_id, tool_call_id, agent_key, tool_name, - effect, fail_mode_applied, reason, decided_at) + effect, fail_mode_applied, reason, policy_id, decided_at) VALUES (current_setting('app.tenant_id')::uuid, - $1, $2, $3, $4, $5, $6, $7, $8) + $1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (tenant_id, tool_call_id) DO NOTHING - RETURNING decision_id, effect, fail_mode_applied, reason, decided_at + RETURNING decision_id, effect, fail_mode_applied, reason, policy_id, decided_at """, decision.decision_id, request.tool_call_id, @@ -45,13 +46,14 @@ async def record( decision.effect.value, decision.fail_mode_applied, decision.reason.value, + decision.policy_id, decision.decided_at, ) if row is None: row = await conn.fetchrow( """ - SELECT decision_id, effect, fail_mode_applied, reason, decided_at + SELECT decision_id, effect, fail_mode_applied, reason, policy_id, decided_at FROM decisions WHERE tool_call_id = $1 """, @@ -63,5 +65,6 @@ async def record( effect=row["effect"], fail_mode_applied=row["fail_mode_applied"], reason=row["reason"], + policy_id=row["policy_id"], decided_at=row["decided_at"], ) diff --git a/api/nexus/repositories/policy_conditions.py b/api/nexus/repositories/policy_conditions.py new file mode 100644 index 0000000..1f5d77a --- /dev/null +++ b/api/nexus/repositories/policy_conditions.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from nexus.repositories.base import BaseRepository + + +@dataclass(frozen=True) +class PolicyCondition: + policy_id: UUID + agent_key: str + condition_type: str + condition_value: str + + +class PolicyConditionRepository(BaseRepository): + async def matching_policy( + self, + *, + agent_key: str, + tool_name: str, + ) -> PolicyCondition | None: + async with self._tx() as conn: + row = await conn.fetchrow( + """ + SELECT + policy_id, + agent_key, + condition_type, + condition_value + FROM policy_conditions + WHERE enabled = true + AND agent_key = $1 + AND condition_type = 'agent_tool' + AND condition_value = $2 + LIMIT 1 + """, + agent_key, + tool_name, + ) + + if row is None: + return None + + return PolicyCondition( + policy_id=row["policy_id"], + agent_key=row["agent_key"], + condition_type=row["condition_type"], + condition_value=row["condition_value"], + ) + + async def set_policy( + self, + *, + policy_id: UUID, + agent_key: str, + condition_type: str, + condition_value: str, + enabled: bool = True, + ) -> None: + async with self._tx() as conn: + await conn.execute( + """ + INSERT INTO policy_conditions + ( + tenant_id, + policy_id, + agent_key, + condition_type, + condition_value, + enabled + ) + VALUES ( + current_setting('app.tenant_id')::uuid, + $1, $2, $3, $4, $5 + ) + ON CONFLICT (tenant_id, policy_id) + DO UPDATE SET + agent_key = EXCLUDED.agent_key, + condition_type = EXCLUDED.condition_type, + condition_value = EXCLUDED.condition_value, + enabled = EXCLUDED.enabled + """, + policy_id, + agent_key, + condition_type, + condition_value, + enabled, + ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index ce81c3f..6813aef 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -119,6 +119,7 @@ async def _seeded_key(): tenant_id, ) for tbl in ( + "policy_conditions", "tool_permissions", "budget_controls", "decisions", diff --git a/api/tests/test_authorization_service.py b/api/tests/test_authorization_service.py index cdc6cbb..5c4b279 100644 --- a/api/tests/test_authorization_service.py +++ b/api/tests/test_authorization_service.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from decimal import Decimal -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest @@ -24,6 +24,7 @@ async def record(self, request, decision): effect=decision.effect.value, fail_mode_applied=decision.fail_mode_applied, reason=decision.reason.value, + policy_id=decision.policy_id, decided_at=decision.decided_at, ) @@ -78,6 +79,24 @@ async def is_allowed(self, agent_key, tool_name): return self.allowed +@dataclass(frozen=True) +class FakePolicy: + policy_id: UUID + + +class FakePolicyConditions: + def __init__(self, policy=None): + self.policy = policy + + async def matching_policy( + self, + *, + agent_key, + tool_name, + ): + return self.policy + + @pytest.mark.asyncio async def test_authorization_defaults_to_allow(): writer = FakeWriter() @@ -260,4 +279,37 @@ async def test_absent_tool_grant_defaults_allow(): decision = await service.authorize(make_request()) assert decision.effect == DecisionEffect.ALLOW - assert decision.reason == DecisionReason.DEFAULT_ALLOW \ No newline at end of file + assert decision.reason == DecisionReason.DEFAULT_ALLOW + + +@pytest.mark.asyncio +async def test_policy_condition_match_denies(): + writer = FakeWriter() + + policy = FakePolicy(policy_id=uuid4()) + + service = AuthorizationService( + writer, + policy_conditions=FakePolicyConditions(policy), + ) + + decision = await service.authorize(make_request()) + + assert decision.effect == DecisionEffect.DENY + assert decision.reason == DecisionReason.POLICY_CONDITION_MATCHED + assert decision.policy_id == policy.policy_id + + +@pytest.mark.asyncio +async def test_absent_policy_condition_defaults_allow(): + writer = FakeWriter() + + service = AuthorizationService( + writer, + policy_conditions=FakePolicyConditions(None), + ) + + decision = await service.authorize(make_request()) + + assert decision.effect == DecisionEffect.ALLOW + assert decision.reason == DecisionReason.DEFAULT_ALLOW diff --git a/api/tests/test_authorize_endpoint.py b/api/tests/test_authorize_endpoint.py index f653cd6..d4a4175 100644 --- a/api/tests/test_authorize_endpoint.py +++ b/api/tests/test_authorize_endpoint.py @@ -476,4 +476,94 @@ async def test_tool_governance_deny_is_idempotent(client, auth_headers): assert r1.json()["decision_id"] == r2.json()["decision_id"] assert r1.json()["effect"] == "deny" assert r2.json()["effect"] == "deny" - assert r1.json()["reason"] == "denied: tool not allowed" \ No newline at end of file + assert r1.json()["reason"] == "denied: tool not allowed" + + +async def test_policy_condition_match_forces_deny(client, auth_headers): + agent_key = "support-bot" + policy_id = str(uuid.uuid4()) + + r_seed = await client.post( + "/v1/runs", + json=_run_body(agent_key=agent_key), + headers=auth_headers, + ) + assert r_seed.status_code == 200, r_seed.text + + r_policy = await client.post( + "/v1/policy-conditions", + json={ + "policy_id": policy_id, + "agent_key": agent_key, + "condition_type": "agent_tool", + "condition_value": "customer_lookup", + "enabled": True, + }, + headers=auth_headers, + ) + assert r_policy.status_code == 200, r_policy.text + + r = await client.post( + "/v1/authorize", + json=_authorize_body(tool_name="customer_lookup"), + headers=auth_headers, + ) + + assert r.status_code == 200, r.text + assert r.json()["effect"] == "deny" + assert r.json()["reason"] == "denied: policy condition matched" + + +async def test_absent_policy_condition_allows(client, auth_headers): + r = await client.post( + "/v1/authorize", + json=_authorize_body(tool_name="customer_lookup"), + headers=auth_headers, + ) + + assert r.status_code == 200, r.text + assert r.json()["effect"] == "allow" + assert r.json()["reason"] == "default_allow" + + +async def test_policy_condition_deny_is_idempotent(client, auth_headers): + agent_key = "support-bot" + policy_id = str(uuid.uuid4()) + tool_call_id = str(uuid.uuid4()) + + r_seed = await client.post( + "/v1/runs", + json=_run_body(agent_key=agent_key), + headers=auth_headers, + ) + assert r_seed.status_code == 200, r_seed.text + + await client.post( + "/v1/policy-conditions", + json={ + "policy_id": policy_id, + "agent_key": agent_key, + "condition_type": "agent_tool", + "condition_value": "customer_lookup", + "enabled": True, + }, + headers=auth_headers, + ) + + r1 = await client.post( + "/v1/authorize", + json=_authorize_body(tool_call_id, tool_name="customer_lookup"), + headers=auth_headers, + ) + r2 = await client.post( + "/v1/authorize", + json=_authorize_body(tool_call_id, tool_name="customer_lookup"), + headers=auth_headers, + ) + + assert r1.status_code == 200, r1.text + assert r2.status_code == 200, r2.text + assert r1.json()["decision_id"] == r2.json()["decision_id"] + assert r1.json()["effect"] == "deny" + assert r2.json()["effect"] == "deny" + assert r1.json()["reason"] == "denied: policy condition matched" \ No newline at end of file diff --git a/migrations/0018_policy_conditions.sql b/migrations/0018_policy_conditions.sql new file mode 100644 index 0000000..40a5bb2 --- /dev/null +++ b/migrations/0018_policy_conditions.sql @@ -0,0 +1,41 @@ +-- 0018_policy_conditions.sql +-- Run as nexus_owner. +-- PR #35: bounded policy conditions. +-- +-- Closed condition vocabulary only. +-- No policy DSL, no scripts, no arbitrary expression trees. + +SET search_path = nexus, public; + +CREATE TABLE policy_conditions ( + tenant_id uuid NOT NULL, + policy_id uuid NOT NULL, + agent_key text NOT NULL, + condition_type text NOT NULL, + condition_value text NOT NULL, + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (tenant_id, policy_id), + + FOREIGN KEY (tenant_id, agent_key) + REFERENCES agents (tenant_id, agent_key), + + CHECK (condition_type IN ( + 'agent_tool' + )) +); + +ALTER TABLE policy_conditions ENABLE ROW LEVEL SECURITY; +ALTER TABLE policy_conditions FORCE ROW LEVEL SECURITY; + +CREATE POLICY policy_conditions_tenant_isolation + ON policy_conditions + USING (tenant_id = current_setting('app.tenant_id')::uuid) + WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid); + +ALTER TABLE decisions + ADD COLUMN policy_id uuid; + +GRANT SELECT, INSERT, UPDATE ON policy_conditions TO nexus_app; +REVOKE DELETE ON policy_conditions FROM nexus_app;