Repository navigation
Commit 15ed4fd
authored
feat(server): namespace scoping and control bindings (#203)
## Summary
Adds the namespace-scoping data model and a single merged
effective-controls contract that `initAgent`,
`GET /agents/{name}/controls`, and `POST /evaluation` all share.
- `namespace_key VARCHAR(255) NOT NULL DEFAULT 'default'` on `agents`,
`controls`, `policies`, `agent_controls`, `agent_policies`,
`policy_controls`, `control_bindings`.
- Single-column uniqueness replaced by namespace-scoped composite
uniqueness; association-table foreign keys are composite
same-namespace foreign keys (Postgres-enforced).
- New `control_bindings` table for attaching controls to opaque
external targets. **One binding shape**: each row attaches one
control to one target inside a namespace, uniqueness on
`(namespace_key, target_type, target_id, control_id)`. The `enabled`
flag is a soft toggle: disabled bindings are preserved but excluded
from the effective set.
- **Single merged resolver.** `ControlService.list_controls_for_agent`
(and its runtime cousin) returns the de-duplicated union of the
agent's direct controls, policy-derived controls, and (when target
context is supplied) controls attached to that target via enabled
bindings in the same namespace. `initAgent`,
`GET /agents/{name}/controls?target_type=...&target_id=...`, and
`POST /evaluation` all call into this resolver and return the same
set for the same inputs.
- Management API (`/control-bindings`): full CRUD plus idempotent
natural-key upsert/delete (`PUT /control-bindings/by-key`,
`POST /control-bindings/by-key:delete`). Cursor-based pagination on
list with an opaque string cursor that round-trips cleanly to
clients. Natural key is `(target_type, target_id, control_id)`.
- `initAgent` accepts optional top-level `target_type` / `target_id`.
Bindings can pre-exist the agent row, so a newly created agent
registering with target context picks up pre-existing bindings on
its first response (no second round-trip).
- Python SDK target context is fixed per session: `init(target_type=...,
target_id=...)` stores it on `state` and forwards it on the
registration call and on every subsequent `/agents/{name}/controls`
poll. The single existing policy refresh loop carries the merged
set; there is no separate target-controls cache or refresh worker.
- Session-target enforcement lives only on the session-bound entry
point (`evaluate_controls`). `check_evaluation` and
`check_evaluation_with_local` accept their own client and controls
and run only the both-or-neither validation, so callers using those
helpers are not implicitly bound to a previous `init()`.
## Namespace scoping
Every effective-controls query filters every joined table on
`namespace_key` explicitly. Composite FKs prevent cross-namespace
writes; explicit query scoping prevents reads from spanning namespaces
in the presence of namespace-collision attacks or compromised callers.
Both layers are required.
`_get_agent_or_404` requires `namespace_key`; an agent that exists
only in another namespace surfaces as 404 (non-disclosing). Every
agent endpoint that resolves an agent threads the namespace through:
- `initAgent`, `GET /agents/{name}`, `GET /agents`,
`GET /agents/{name}/evaluators`,
`GET /agents/{name}/evaluators/{evaluator_name}`,
`GET /agents/{name}/controls`, `PATCH /agents/{name}`.
- All `/agents/{name}/policies*` routes (add / set / list / get /
remove / remove-all / delete) and the corresponding `agent_policies`
reads, writes, and deletes.
- All `/agents/{name}/controls/{control_id}` routes (add / remove) and
the corresponding `agent_controls` reads, writes, and deletes.
- The list-cursor lookup is namespace-scoped so pagination cannot
redirect through a foreign-namespace agent of the same name.
`ControlService.get_active_control_or_404`, `list_controls_for_policy`,
`add_control_to_agent`, and `remove_control_from_agent` accept
`namespace_key` so the service layer is no longer namespace-blind on
the migrated paths. Policy lookups in the agent association routes
also filter by namespace.
The initial release ships namespace plumbing at the schema level.
Endpoints route through a single `get_namespace_key` dependency that
always returns the default namespace; **overriding it is not supported
yet** because `/controls` and `/policies` write endpoints still write
under the default namespace, and an override here would create rows
the existing endpoints cannot find. The initial release honors
`get_namespace_key` on the effective-control paths and the migrated
agent association paths; full `/controls` and `/policies`
namespace-aware writes are follow-up work.
Single-namespace deployments are preserved by the `'default'` server
default. Plain `ix_agents_name`, `ix_policies_name`, and
`ix_controls_name` (partial on `deleted_at IS NULL`) indexes preserve
name-only lookup performance during the rollout window.
The migration is reversible. `downgrade()` aborts with a clear error
if cross-namespace duplicate names exist on `agents`, `policies`, or
live `controls`, since restoring global single-column uniqueness
would conflict. Soft-deleted control duplicates do not block
downgrade.
## Initial-release contract
```
SDK init target is fixed per session.
initAgent and GET /agents/{name}/controls return the effective controls for that session context.
Runtime /evaluation uses the same merged resolution.
Dynamic per-request target switching, inheritance, DAGs, and target-agent overrides remain out of scope.
```
The sessionful SDK path supports one active `init()` context. Callers
that need multiple agents or targets in one process should use the
lower-level helpers, or separate sessions once multi-session support
exists.
## Notes on `control_bindings`
- `ON DELETE CASCADE` on the parent control fires only on hard
deletes. Soft-deleted controls (`deleted_at IS NOT NULL`) keep
their bindings; the resolver excludes soft-deleted controls.
- `delete_control` rejects with 409 when the control has active
policy associations, direct agent associations, or active target
bindings unless `force=true`, in which case all three classes of
attachment are detached as part of the soft-delete lifecycle.
- `updated_at` refreshes on every UPDATE via SQLAlchemy `onupdate`.
- A `(namespace_key, control_id)` index covers the cascade path and
`list_bindings(control_id=...)` filtering.
- `idx_controls_namespace_name_active` is recognized as a
name-conflict constraint, so concurrent duplicate-name races
surface as 409, not 500.
- Concurrent natural-key writes are safe. Both
`ControlBindingsService.create_binding` and `upsert_by_natural_key`
wrap their inserts in `begin_nested()` so a unique-constraint
collision rolls back the SAVEPOINT only: the surrounding
transaction is intact, and a caller that composed the service
after another flush does not lose its prior writes.
- Target bindings reject controls whose condition tree references
agent-scoped evaluators (`agent_name:evaluator_name`). Bindings
have no specific agent to validate the reference against, so
accepting them would surface as a runtime evaluation failure on
the first call rather than a clear 400 at attach time. New error
code `CONTROL_BINDING_INCOMPATIBLE`.
- Per-agent overrides and exemptions within a target are
intentionally out of scope at this stage. Two forward paths are
documented in code (migration comment plus `ControlBinding`
docstring):
- re-introduce an `agent_name` column with a partial-index pair
and an `enabled`-aware most-specific-wins resolver; supports
both per-agent additions and per-agent exemptions.
- or merge target-bearing resolution with the existing
`agent_controls` table at runtime; supports per-agent additions
only, since `agent_controls` has no `enabled` flag.
## Generated client
The TypeScript wrapper exposes the new `controlBindings` getter
alongside the existing `agents`, `controls`, `evaluation`,
`evaluators`, `observability`, `policies`, and `system` getters, so
consumers using the public client can manage bindings without
reaching into the generated internals.
## Out of scope (follow-up PRs)
- Threading `get_namespace_key` through `/controls` and `/policies`
write endpoints / services.
- Auth-derived `get_namespace_key` resolution.
- Namespace scoping for `control_versions` and
`control_execution_events`.
- Per-agent overrides and exemptions within a target.
## Test plan
- [x] Migration: upgrade applies columns/constraints/indexes;
`control_bindings` table created; downgrade restores originals;
upgrade/downgrade round-trip; downgrade rejects cross-namespace
duplicates on agents, policies, and live controls; allows
soft-deleted duplicates.
- [x] Isolation: same name across namespaces allowed; same name
within a namespace rejected; soft-deleted control names reusable
within a namespace; cross-namespace foreign keys rejected on every
association table.
- [x] `control_bindings` table: same control bindable to different
targets; duplicate `(namespace, target_type, target_id, control_id)`
rejected; cross-namespace `control_id` rejected;
`ON DELETE CASCADE` on hard delete; bindings survive parent soft
delete.
- [x] Merged resolver: target bindings union with direct + policy
controls; de-duplication when a control is attached through both
paths; disabled binding excluded; soft-deleted controls excluded;
namespace isolation; absence of target context omits bindings.
- [x] `initAgent`: target params merge into the returned controls;
newly created agent with target context picks up pre-existing
bindings; partial target pair rejected (422).
- [x] `GET /agents/{name}/controls?target_type=...&target_id=...`
returns the same merged set as `initAgent`; partial target pair
rejected (400); cross-namespace agent surfaces as 404.
- [x] Cross-namespace agent association routes (read and delete)
surface as 404, mirroring the effective-controls path.
- [x] `/evaluation`: target context flows through the same merged
resolver (target + agent + policy); 404 when the agent is not
registered; partial target pair rejected (422).
- [x] Management API: create / get / list (cursor pagination, opaque
string cursor round-trips) / patch / delete; non-admin write
rejection; natural-key upsert (idempotent, updates `enabled` and
`updated_at`, handles concurrent insert race via SAVEPOINT);
natural-key delete (idempotent); duplicate create surfaces as 409
with the surrounding transaction intact.
- [x] Target binding rejects controls referencing agent-scoped
evaluators with 400 `CONTROL_BINDING_INCOMPATIBLE`.
- [x] `delete_control` lifecycle: refuses with 409 when policy /
agent / binding attachments exist and `force=false`; detaches all
three on `force=true`.
- [x] Control-name conflict mapping: both legacy and new
partial-unique index names trigger `CONTROL_NAME_CONFLICT` (409)
instead of 500.
- [x] SDK: `init(target_type=..., target_id=...)` stores session
target and forwards it on registration and on every refresh;
partial target pair rejected; per-call target overrides default
from state and reject mismatches with the session target on the
session-bound `evaluate_controls`; standalone `check_evaluation` /
`check_evaluation_with_local` are not session-bound and run only
the both-or-neither check.
- [x] Full server test suite: 621 tests pass.
- [x] SDK test suite: 541 tests pass.
- [x] Models tests: 64 pass.
- [x] TypeScript SDK tests: 8 pass.
- [x] `make lint` clean.
- [x] `make typecheck` clean.
- [x] `make sdk-ts-generate-check` clean.1 parent 09653e3 commit 15ed4fd
78 files changed
Lines changed: 7148 additions & 214 deletions
File tree
- examples
- target_context_demo
- models/src/agent_control_models
- sdks
- python
- src/agent_control
- tests
- typescript
- overlays
- src
- generated
- funcs
- models
- operations
- sdk
- server
- alembic/versions
- src/agent_control_server
- endpoints
- services
- tests
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
0 commit comments