Skip to content

Commit b7feba2

Browse files
fix(server): harden namespace rollout edges
1 parent c67ea44 commit b7feba2

10 files changed

Lines changed: 204 additions & 16 deletions

File tree

server/alembic/versions/b6f4c2d8e9a1_namespace_observability_events.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,43 @@ def upgrade() -> None:
2828
nullable=False,
2929
),
3030
)
31-
op.create_index(
32-
"ix_events_namespace_agent_time",
31+
op.drop_constraint(
32+
"control_execution_events_pkey",
3333
"control_execution_events",
34-
["namespace_key", "agent_name", sa.literal_column("timestamp DESC")],
35-
unique=False,
34+
type_="primary",
3635
)
36+
op.create_primary_key(
37+
"control_execution_events_pkey",
38+
"control_execution_events",
39+
["namespace_key", "control_execution_id"],
40+
)
41+
with op.get_context().autocommit_block():
42+
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_events_agent_time")
43+
op.execute(
44+
"""
45+
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_events_namespace_agent_time
46+
ON control_execution_events (namespace_key, agent_name, timestamp DESC)
47+
"""
48+
)
3749

3850

3951
def downgrade() -> None:
40-
op.drop_index(
41-
"ix_events_namespace_agent_time",
42-
table_name="control_execution_events",
52+
with op.get_context().autocommit_block():
53+
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_events_namespace_agent_time")
54+
op.execute(
55+
"""
56+
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_events_agent_time
57+
ON control_execution_events (agent_name, timestamp DESC)
58+
"""
59+
)
60+
op.drop_constraint(
61+
"control_execution_events_pkey",
62+
"control_execution_events",
63+
type_="primary",
64+
)
65+
op.create_primary_key(
66+
"control_execution_events_pkey",
67+
"control_execution_events",
68+
["control_execution_id"],
4369
)
4470
op.drop_column("control_execution_events", "namespace_key")

server/src/agent_control_server/auth_framework/config.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,12 @@ def _build_default_provider() -> RequestAuthorizer:
232232
)
233233

234234

235-
def _validate_local_api_key_mode() -> None:
235+
def _validate_local_api_key_mode(mode_env: str = _MODE_ENV) -> None:
236236
"""Fail startup when local API-key mode has no local key validator."""
237237
if not auth_settings.api_key_enabled:
238238
raise RuntimeError(
239-
f"{_MODE_ENV}=api_key requires AGENT_CONTROL_API_KEY_ENABLED=true. "
240-
f"Use {_MODE_ENV}=none for deployments without credential enforcement."
239+
f"{mode_env}=api_key requires AGENT_CONTROL_API_KEY_ENABLED=true. "
240+
f"Use {mode_env}=none for deployments without credential enforcement."
241241
)
242242
if not auth_settings.get_api_keys() and not auth_settings.get_admin_api_keys():
243243
raise RuntimeError(
@@ -295,6 +295,7 @@ def _build_runtime_provider(
295295
if mode == "none":
296296
return NoAuthProvider()
297297
if mode == "api_key":
298+
_validate_local_api_key_mode(_RUNTIME_MODE_ENV)
298299
return HeaderAuthProvider()
299300
if mode == "jwt":
300301
if config is None:

server/src/agent_control_server/auth_framework/providers/http_upstream.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,12 +336,20 @@ def _ensure_target_context_matches_grant(
336336
if principal.target_type is None and principal.target_id is None:
337337
return
338338
if context is None:
339-
return
339+
raise ForbiddenError(
340+
error_code=ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES,
341+
detail="Authorization grant is target-bound but the request target is unavailable.",
342+
hint="Use an endpoint that includes target_type and target_id in the authorization context.",
343+
)
340344

341345
expected_type = context.get("target_type")
342346
expected_id = context.get("target_id")
343347
if not isinstance(expected_type, str) or not isinstance(expected_id, str):
344-
return
348+
raise ForbiddenError(
349+
error_code=ErrorCode.AUTH_INSUFFICIENT_PRIVILEGES,
350+
detail="Authorization grant is target-bound but the request target is incomplete.",
351+
hint="Provide both target_type and target_id for target-bound credentials.",
352+
)
345353
if principal.target_type == expected_type and principal.target_id == expected_id:
346354
return
347355

server/src/agent_control_server/endpoints/agents.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,13 @@ async def init_agent(
912912

913913
data_model.evaluators = new_evaluators
914914

915+
if (
916+
not request.force_replace
917+
and request.conflict_mode != ConflictMode.OVERWRITE
918+
and (steps_changed or evaluators_changed or metadata_changed)
919+
):
920+
await _authorize_existing_agent_overwrite(http_request, principal)
921+
915922
if steps_changed or evaluators_changed or metadata_changed or force_write:
916923
existing.data = data_model.model_dump(mode="json")
917924

server/src/agent_control_server/models.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ForeignKeyConstraint,
1515
Index,
1616
Integer,
17+
PrimaryKeyConstraint,
1718
String,
1819
Table,
1920
Text,
@@ -354,7 +355,7 @@ class ControlExecutionEventDB(Base):
354355

355356
# Primary key
356357
control_execution_id: Mapped[str] = mapped_column(
357-
String(36), primary_key=True
358+
String(36)
358359
)
359360

360361
# Minimal indexed columns for efficient queries
@@ -377,7 +378,11 @@ class ControlExecutionEventDB(Base):
377378

378379
# Composite index for agent + time queries (primary access pattern)
379380
__table_args__ = (
381+
PrimaryKeyConstraint(
382+
"namespace_key",
383+
"control_execution_id",
384+
name="control_execution_events_pkey",
385+
),
380386
Index("ix_events_namespace_agent_time", "namespace_key", "agent_name", timestamp.desc()),
381-
Index("ix_events_agent_time", "agent_name", timestamp.desc()),
382387
Index("ix_events_data_control_id", text("(data ->> 'control_id'::text)")),
383388
)

server/src/agent_control_server/observability/store/postgres.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ async def store(
156156
:namespace_key, :control_execution_id, :timestamp, :agent_name,
157157
CAST(:data AS JSONB)
158158
)
159-
ON CONFLICT (control_execution_id) DO NOTHING
159+
ON CONFLICT (namespace_key, control_execution_id) DO NOTHING
160160
"""),
161161
values,
162162
)

server/tests/test_auth_framework.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1077,7 +1077,11 @@ async def test_http_upstream_accepts_iso_datetime_and_array_scopes():
10771077
},
10781078
)
10791079
)
1080-
principal = await provider.authorize(_build_request(), Operation.RUNTIME_TOKEN_EXCHANGE)
1080+
principal = await provider.authorize(
1081+
_build_request(),
1082+
Operation.RUNTIME_TOKEN_EXCHANGE,
1083+
context={"target_type": "log_stream", "target_id": "ls-1"},
1084+
)
10811085
assert principal.namespace_key == "org-1"
10821086
assert principal.scopes == ("runtime.use", "runtime.read_only")
10831087
assert principal.target_type == "log_stream"
@@ -1107,6 +1111,44 @@ async def test_http_upstream_rejects_target_grant_mismatch():
11071111
)
11081112

11091113

1114+
@pytest.mark.asyncio
1115+
async def test_http_upstream_rejects_target_grant_without_context():
1116+
provider = _build_upstream(
1117+
lambda req: httpx.Response(
1118+
200,
1119+
json={
1120+
"namespace_key": "org-1",
1121+
"target_type": "log_stream",
1122+
"target_id": "bound",
1123+
},
1124+
)
1125+
)
1126+
1127+
with pytest.raises(ForbiddenError, match="request target is unavailable"):
1128+
await provider.authorize(_build_request(), Operation.CONTROL_BINDINGS_READ)
1129+
1130+
1131+
@pytest.mark.asyncio
1132+
async def test_http_upstream_rejects_target_grant_with_incomplete_context():
1133+
provider = _build_upstream(
1134+
lambda req: httpx.Response(
1135+
200,
1136+
json={
1137+
"namespace_key": "org-1",
1138+
"target_type": "log_stream",
1139+
"target_id": "bound",
1140+
},
1141+
)
1142+
)
1143+
1144+
with pytest.raises(ForbiddenError, match="request target is incomplete"):
1145+
await provider.authorize(
1146+
_build_request(),
1147+
Operation.CONTROL_BINDINGS_READ,
1148+
context={"target_type": "log_stream"},
1149+
)
1150+
1151+
11101152
# ---------------------------------------------------------------------------
11111153
# configure_auth_from_env / teardown_auth lifecycle
11121154
# ---------------------------------------------------------------------------
@@ -1248,6 +1290,18 @@ def test_configure_runtime_api_key_ignores_jwt_secret(monkeypatch):
12481290
assert auth_config.runtime_auth_config() is None
12491291

12501292

1293+
def test_configure_runtime_api_key_rejects_without_validator(monkeypatch):
1294+
from agent_control_server.auth_framework import config as auth_config
1295+
1296+
clear_authorizers()
1297+
1298+
monkeypatch.setenv("AGENT_CONTROL_RUNTIME_AUTH_MODE", "api_key")
1299+
monkeypatch.setattr(auth_settings, "api_key_enabled", False)
1300+
1301+
with pytest.raises(RuntimeError, match="AGENT_CONTROL_RUNTIME_AUTH_MODE=api_key"):
1302+
auth_config.configure_auth_from_env()
1303+
1304+
12511305
def test_configure_runtime_unset_preserves_no_auth_default(monkeypatch):
12521306
from agent_control_server.auth_framework import config as auth_config
12531307

server/tests/test_data_model_v1_alembic_migration.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
SERVER_DIR = Path(__file__).resolve().parents[1]
1717
PRE_MIGRATION_REVISION = "c1e9f9c4a1d2"
1818
MIGRATION_REVISION = "a7f3b1e0d9c5"
19+
OBSERVABILITY_NAMESPACE_REVISION = "b6f4c2d8e9a1"
1920
_BASE_DB_URL = make_url(db_config.get_url())
2021

2122
pytestmark = pytest.mark.skipif(
@@ -223,6 +224,21 @@ def test_downgrade_round_trip(alembic_config: Config, temp_engine: Engine) -> No
223224
assert "control_bindings" in inspect(temp_engine).get_table_names()
224225

225226

227+
def test_observability_namespace_migration_scopes_event_primary_key(
228+
alembic_config: Config, temp_engine: Engine
229+
) -> None:
230+
command.upgrade(alembic_config, OBSERVABILITY_NAMESPACE_REVISION)
231+
232+
assert "namespace_key" in _column_names(temp_engine, "control_execution_events")
233+
assert _pk_columns(temp_engine, "control_execution_events") == [
234+
"namespace_key",
235+
"control_execution_id",
236+
]
237+
indexes = _index_names(temp_engine, "control_execution_events")
238+
assert "ix_events_namespace_agent_time" in indexes
239+
assert "ix_events_agent_time" not in indexes
240+
241+
226242
def test_downgrade_rejects_cross_namespace_agents_duplicates(
227243
alembic_config: Config, temp_engine: Engine
228244
) -> None:

server/tests/test_init_agent_conflict_mode.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,35 @@ def test_init_agent_force_replace_existing_agent_requires_update_auth(
212212
assert force_resp.status_code == 403
213213

214214

215+
def test_init_agent_strict_existing_agent_mutation_requires_update_auth(
216+
client: TestClient,
217+
) -> None:
218+
agent_name = f"agent-{uuid.uuid4().hex[:12]}"
219+
create_resp = client.post(
220+
"/api/v1/agents/initAgent",
221+
json=_init_payload(agent_name=agent_name),
222+
)
223+
assert create_resp.status_code == 200
224+
225+
set_authorizer(CreateOnlyAuthorizer())
226+
strict_resp = client.post(
227+
"/api/v1/agents/initAgent",
228+
json=_init_payload(
229+
agent_name=agent_name,
230+
steps=[
231+
{
232+
"type": "tool",
233+
"name": "new-tool",
234+
"input_schema": {"type": "object"},
235+
"output_schema": {"type": "object"},
236+
}
237+
],
238+
),
239+
)
240+
241+
assert strict_resp.status_code == 403
242+
243+
215244
def test_init_agent_overwrite_warns_on_removed_referenced_evaluator(client: TestClient) -> None:
216245
# Given: an agent whose assigned policy contains a control referencing an agent evaluator.
217246
agent_name = f"agent-{uuid.uuid4().hex[:12]}"

server/tests/test_observability_store_postgres.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,48 @@ async def test_postgres_event_store_scopes_queries_by_namespace() -> None:
175175
assert stats_a.stats[0].control_id == 1
176176

177177

178+
@pytest.mark.asyncio
179+
async def test_postgres_event_store_idempotency_is_scoped_by_namespace() -> None:
180+
session_maker = async_sessionmaker(
181+
bind=async_engine,
182+
class_=AsyncSession,
183+
expire_on_commit=False,
184+
)
185+
store = PostgresEventStore(session_maker)
186+
187+
shared_execution_id = str(uuid4())
188+
agent_name = f"agent-{uuid4().hex[:12]}"
189+
now = datetime.now(UTC)
190+
event_a = _event(
191+
control_execution_id=shared_execution_id,
192+
agent_name=agent_name,
193+
control_id=1,
194+
action="observe",
195+
matched=True,
196+
timestamp=now,
197+
trace_id="a" * 32,
198+
)
199+
event_b = _event(
200+
control_execution_id=shared_execution_id,
201+
agent_name=agent_name,
202+
control_id=2,
203+
action="deny",
204+
matched=True,
205+
timestamp=now,
206+
trace_id="b" * 32,
207+
)
208+
209+
await store.store([event_a], namespace_key="tenant-a")
210+
await store.store([event_b], namespace_key="tenant-b")
211+
212+
query = EventQueryRequest(agent_name=agent_name, limit=10, offset=0)
213+
events_a = await store.query_events(query, namespace_key="tenant-a")
214+
events_b = await store.query_events(query, namespace_key="tenant-b")
215+
216+
assert [event.control_id for event in events_a.events] == [1]
217+
assert [event.control_id for event in events_b.events] == [2]
218+
219+
178220
@pytest.mark.asyncio
179221
async def test_postgres_event_store_store_empty_returns_zero() -> None:
180222
# Given: a Postgres-backed store

0 commit comments

Comments
 (0)