Skip to content

Commit 718d172

Browse files
committed
feat: enforce registry update/delete via OPA and extend CLI
1 parent 3af20f9 commit 718d172

3 files changed

Lines changed: 137 additions & 1 deletion

File tree

example_app/api.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ class AgentCreate(BaseModel):
4343
description: str
4444
tools: list[str]
4545

46+
47+
class ToolUpdate(BaseModel):
48+
description: Optional[str] = None
49+
side_effect_level: Optional[str] = None
50+
data_classes: Optional[list[str]] = None
51+
tags: Optional[list[str]] = None
52+
redaction_paths: Optional[list[str]] = None
53+
allowed_roles: Optional[list[str]] = None
54+
55+
56+
class AgentUpdate(BaseModel):
57+
description: Optional[str] = None
58+
tools: Optional[list[str]] = None
59+
4660
@asynccontextmanager
4761
async def lifespan(app: FastAPI):
4862
register_example_tools()
@@ -152,6 +166,90 @@ def create_agent(
152166
return {"status": "ok", "agent_id": agent.agent_id}
153167

154168

169+
@app.patch("/registry/tools/{tool_id}")
170+
def update_tool(
171+
tool_id: str,
172+
payload: ToolUpdate,
173+
admin_token: Optional[str] = Header(None, alias="Authorization"),
174+
roles_header: Optional[str] = Header(None, alias="X-OCPA-Roles"),
175+
) -> Dict[str, Any]:
176+
_require_admin(admin_token, roles_header)
177+
existing = registry.get_tool(tool_id)
178+
if not existing:
179+
raise HTTPException(status_code=404, detail="tool not found")
180+
updates = {k: v for k, v in payload.model_dump(exclude_none=True).items()}
181+
updated = existing.__dict__ | updates
182+
if updated.get("side_effect_level") not in {"none", "low", "high"}:
183+
raise HTTPException(status_code=400, detail="invalid side_effect_level")
184+
_require_registry_policy("update", "tool", {"tool_id": tool_id, **updates}, roles_header)
185+
registry.register_tool(ToolMetadata(**updated))
186+
emit_validated(
187+
lambda: build_registry_audit_event(
188+
action="update",
189+
resource_type="tool",
190+
resource_id=tool_id,
191+
actor=_actor_from_headers(roles_header),
192+
env=os.environ.get("OCPA_ENV", "dev"),
193+
request=updates,
194+
)
195+
)
196+
return {"status": "ok", "tool_id": tool_id}
197+
198+
199+
@app.delete("/registry/tools/{tool_id}")
200+
def delete_tool(
201+
tool_id: str,
202+
admin_token: Optional[str] = Header(None, alias="Authorization"),
203+
roles_header: Optional[str] = Header(None, alias="X-OCPA-Roles"),
204+
) -> Dict[str, Any]:
205+
_require_admin(admin_token, roles_header)
206+
_require_registry_policy("delete", "tool", {"tool_id": tool_id}, roles_header)
207+
if not registry.get_tool(tool_id):
208+
raise HTTPException(status_code=404, detail="tool not found")
209+
# remove tool and any references in agents
210+
registry._tools.pop(tool_id, None) # noqa: SLF001
211+
for agent in registry._agents.values(): # noqa: SLF001
212+
if tool_id in agent.tools:
213+
agent.tools = [t for t in agent.tools if t != tool_id]
214+
registry._save()
215+
emit_validated(
216+
lambda: build_registry_audit_event(
217+
action="delete",
218+
resource_type="tool",
219+
resource_id=tool_id,
220+
actor=_actor_from_headers(roles_header),
221+
env=os.environ.get("OCPA_ENV", "dev"),
222+
request={"tool_id": tool_id},
223+
)
224+
)
225+
return {"status": "deleted", "tool_id": tool_id}
226+
227+
228+
@app.delete("/registry/agents/{agent_id}")
229+
def delete_agent(
230+
agent_id: str,
231+
admin_token: Optional[str] = Header(None, alias="Authorization"),
232+
roles_header: Optional[str] = Header(None, alias="X-OCPA-Roles"),
233+
) -> Dict[str, Any]:
234+
_require_admin(admin_token, roles_header)
235+
_require_registry_policy("delete", "agent", {"agent_id": agent_id}, roles_header)
236+
if not registry.get_agent(agent_id):
237+
raise HTTPException(status_code=404, detail="agent not found")
238+
registry._agents.pop(agent_id, None) # noqa: SLF001
239+
registry._save()
240+
emit_validated(
241+
lambda: build_registry_audit_event(
242+
action="delete",
243+
resource_type="agent",
244+
resource_id=agent_id,
245+
actor=_actor_from_headers(roles_header),
246+
env=os.environ.get("OCPA_ENV", "dev"),
247+
request={"agent_id": agent_id},
248+
)
249+
)
250+
return {"status": "deleted", "agent_id": agent_id}
251+
252+
155253
def _require_admin(auth_header: Optional[str], roles_header: Optional[str]) -> None:
156254
required_role = os.environ.get("OCPA_ADMIN_ROLE", "admin")
157255
verify_bearer_token(auth_header, required_role, role_claim=os.environ.get("OCPA_ROLE_CLAIM", "roles"))

policies/registry_allow.rego

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@ allow = result {
4040
result := {"allow": true, "reason": "registry_create_agent_allow"}
4141
}
4242

43+
allow = result {
44+
role_ok
45+
input.action == "update"
46+
input.resource_type == "tool"
47+
not deny_prod_without_ticket
48+
result := {"allow": true, "reason": "registry_update_tool_allow"}
49+
}
50+
51+
allow = result {
52+
role_ok
53+
input.action == "delete"
54+
input.resource_type == "tool"
55+
not deny_prod_without_ticket
56+
result := {"allow": true, "reason": "registry_delete_tool_allow"}
57+
}
58+
59+
allow = result {
60+
role_ok
61+
input.action == "delete"
62+
input.resource_type == "agent"
63+
not deny_prod_without_ticket
64+
result := {"allow": true, "reason": "registry_delete_agent_allow"}
65+
}
66+
4367
deny_prod_without_ticket {
4468
input.context.env == "prod"
4569
not input.context.ticket_id

tests/test_registry_api.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from tests.test_opa_policy import DummyResponse
55

66

7-
def test_registry_api_create_tool_and_agent(tmp_path, monkeypatch):
7+
def test_registry_api_create_update_delete(tmp_path, monkeypatch):
88
monkeypatch.setenv("OCPA_REGISTRY_PATH", str(tmp_path / "registry.json"))
99
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
1010
monkeypatch.setenv("OCPA_ADMIN_ROLE", "admin")
@@ -61,3 +61,17 @@ def ok_post(url, json=None, timeout=None):
6161
assert len(captured_events) == 2
6262
assert captured_events[0]["resource_type"] == "tool"
6363
assert captured_events[1]["resource_type"] == "agent"
64+
65+
# Update tool
66+
resp_update = client.patch(
67+
"/registry/tools/t1",
68+
json={"description": "Updated"},
69+
headers=headers,
70+
)
71+
assert resp_update.status_code == 200
72+
73+
# Delete agent and tool
74+
resp_delete_agent = client.delete("/registry/agents/a1", headers=headers)
75+
assert resp_delete_agent.status_code == 200
76+
resp_delete_tool = client.delete("/registry/tools/t1", headers=headers)
77+
assert resp_delete_tool.status_code == 200

0 commit comments

Comments
 (0)