Spec: authorization for tenant configuration, path-tenant routes and human approvals
Status: APPROVED and BUILT 2026-10-18 (all four tasks). Branch
spec/tenant-config-write-scope. The follow-up named indocs/specs/principals-and-template-authz.md(PR #501). Related:docs/spec-registry-write-authorization.md(the write gate),docs/specs/killswitch-tenant-scope.md(authorized_tenant_scope).
1. Problem & outcome
How the inventory was taken
Every POST/PUT/PATCH/DELETE route on both apps (core/app.py, admin_app.py)
outside /v1/admin/*, walked with tests/route_tables.flat_routes (FastAPI
0.143 nests included routers), keeping those whose handler never reaches
core.auth.require_registry_write: 162 routes. Most must stay open to a
runtime key (guard checks, token issuance, sign-in flows, telemetry ingest,
dry-run validators; section 4.5). The rest fall into four findings, verified
in-process on main (a406024) with the shipped configuration
(auth.enabled: false).
Finding A (critical): the global config route has no authorization
PUT /v1/shield/config (api/routes_config.py, both planes) changes the
process-wide guardrail configuration (enabled, action, settings) and the
global RBAC roles and agent mappings, the fallback for every tenant without its
own override. The handler takes a raw dict and checks nothing. Verified: with no
credentials it returns 200 and the change is live in that process. With auth
enabled, any tenant’s key can make it.
Finding B (critical): path-tenant routes accept callers with no tenant
api/routes_policy.py (/v1/shield/policies/{tenant_id}/*),
api/routes_webhooks.py (/v1/shield/webhooks/{tenant_id}/*) and
api/routes_decisions.py (/v1/shield/decisions/{tenant_id}) rely on
core.auth.verify_tenant_path_access, which restricts a caller only when
request.state.tenant_id is set, and leaves anyone without one unrestricted
(it was written so global master keys stay unrestricted). A caller with no key,
or an unrecognized key, has no tenant either. Verified with no credentials:
| Route | Result |
|---|---|
POST /v1/shield/policies/{tenant_id} |
200, policy created in that tenant |
POST /v1/shield/webhooks/{tenant_id} |
200, webhook created in that tenant (its events go to the URL given) |
GET /v1/shield/decisions/{tenant_id}, GET /v1/shield/webhooks/{tenant_id}, GET /v1/shield/policies/{tenant_id} |
200 |
The same applies to update, delete, bulk, rollback and cache-clear on these routers. This is the kill-switch defect (PR #497) in three more routers.
Finding C (high): an approval can be decided by the agent that requested it
POST /v1/tenant/me/agentic/approvals/{request_id}/approve and /deny
(api/routes_agentic_control_plane.py) check only the tenant. Verified under
SHIELD_REGISTRY_WRITE_SCOPE=enforce: a runtime key approves a pending
request and receives the signed approval grant, which then satisfies the
approval rule on /v1/shield/tool/check. Also, the route records the approver
as SSO-verified (approver_method: "sso") whenever the request carries an
X-Approver-Sub header, from any caller, so the audit cannot tell a person’s
approval from the agent’s own.
Finding D (medium): configuration writes ignore the write gate
Under enforce a runtime key still changes the tenant’s enforcement
configuration: guardrail policies, tools, custom policies, data policies, tool
policies, MCP servers and gateway upstreams, the agentic control plane, vault
secrets, SIEM and webhook destinations, shadow-agent decisions, AIBOM records,
access-review decisions. 74 routes, listed in section 4.4. Under off (the
default) every key may do this by design; the gap is that enforce promises
runtime keys cannot change authorization, and here they can.
Outcome
PUT /v1/shield/configrequires an admin identity (A).- Path-tenant routes act only for the caller’s own tenant, or any tenant for an admin identity; no credentials is 401 (B).
- An approval is decided by a person (a portal session) or an admin-scoped
key, never by a runtime key;
X-Approver-Subcounts only from the trusted proxy (C). - Configuration writes pass a write gate with its own mode,
SHIELD_CONFIG_WRITE_SCOPE, that follows the registry mode intowarnso no deployment breaks on upgrade (D). - Route-table guards over both apps keep each class closed.
Non-goals
- Reads by runtime keys of
/v1/tenant/me/*configuration. They stay as they are; whether an agent’s key should read the tenant’s policies is a separate question. - Per-tenant modes. The mode stays a process setting, read from the environment, never from the request (as the write gate).
- Operator SSO roles, admin-plane routes (
/v1/admin/*, covered byrequire_admin_scopeand its route-table guard since PR #501). - The guard routes themselves (section 4.5): unchanged.
2. Plane & latency contract
- Both planes. Data plane and admin plane both mount
routes_config,routes_tenant_self,routes_data_policiesandroutes_governance; the admin plane alone mountsroutes_mcp_admin,routes_aibom,routes_vaultandroutes_siem; the data plane alone mountsroutes_custom_policies. The sharedcore.authhelpers cover both. - All changed routes are management routes, off the guard path. The guard
routes (
/guardrails/*,/v1/shield/tool/check,cap/mint, MCPtools/call, …) are not touched. The added cost per management write is the write gate’s cached key-scope read.
3. Data model
No new keys. One new process setting, SHIELD_CONFIG_WRITE_SCOPE.
4. API / interface
4.1 Finding A: PUT /v1/shield/config
Requires an admin identity: the workload-identity providers’ admin result
(admin key, SPIFFE or mTLS configured as admin), a global master key, or an SSO
operator session. A tenant key gets 403; no credentials 401. The same check on
GET /v1/shield/config would expose nothing secret (it already sanitizes) and
is left as is. Audit row global_config_updated with admin_actor.
4.2 Finding B: path-tenant routes
verify_tenant_path_access keeps its name and signature and changes its rule,
using core.auth.caller_tenant_scope (PR #497):
| Caller | Before | After |
|---|---|---|
| Admin identity or global master key | any tenant | any tenant |
| Tenant key or portal session for that tenant | allowed | allowed |
| Tenant key for another tenant | 403 | 403 |
| No credentials, or an unrecognized key | allowed | 401 |
Applies to every route of routes_policy, routes_webhooks and
routes_decisions (reads included: decision audit and webhook URLs are
tenant data). Writes on routes_policy and routes_webhooks additionally
pass the config write gate (4.4).
4.3 Finding C: approvals
approve and deny:
- Who may decide: a signed-in portal user of the tenant, or an admin-scoped tenant key, or an admin identity. A runtime or unscoped key gets 403 in every mode (this is not a configuration write; an agent must never decide its own approval). Escape hatch below.
- Who the approver is: the portal session’s subject when there is one
(the body’s
approveris ignored);X-Approver-Subonly whencore.proxy_trust.peer_is_trusted(request), otherwise ignored; else the body’s value, recordedapprover_method: "asserted". - If the approval rule names approvers, the resolved approver must be one of them (today’s check, now against the resolved value).
4.4 Finding D: the config write gate
core.auth.require_config_write(request, tenant_id, action): the same logic as
require_registry_write (portal administrator, admin-scoped key, sandbox
tenant exemption, fail closed when the key store is degraded) with its own
mode:
SHIELD_CONFIG_WRITE_SCOPE |
Meaning |
|---|---|
| unset | follows the registry mode, capped at warn: off → off, warn or enforce → warn |
off / warn / enforce |
as set |
So a deployment on registry enforce starts recording, after upgrade, which
callers configuration enforce would refuse, and nothing breaks until the
operator sets SHIELD_CONFIG_WRITE_SCOPE=enforce.
Routes (74):
| Group | Routes |
|---|---|
Guardrail policy (routes_tenant_self) |
PUT /v1/tenant/me/policies, PUT /v1/tenant/me/tools, POST /v1/tenant/me/policies/custom, PUT/DELETE .../custom/{policy_id}, POST .../custom/{policy_id}/enable, .../disable, POST .../custom/import/sigma (8) |
Custom policies (routes_custom_policies) |
create, update, delete, enable, disable, import sigma (6) |
Data-classification policies (routes_policy) |
create, bulk, update, delete, rollback, cache clear (6) |
Data policies (routes_data_policies) |
global set/delete, tool set/delete (4) |
| Tool policies | PUT /v1/agents/tools/policies (both handlers), DELETE /v1/agents/tools/policies/{tool_name} (3) |
Shadow agents (routes_agents_registry) |
allow, block, dismiss (3) |
| Agentic control plane | PUT /v1/tenant/me/agentic/config, execution grants create/revoke, circuit breaker reset, checkpoint resume (5) |
MCP servers (routes_mcp_admin) |
the 18 remaining writes (servers, activation, catalog approval, scans, bindings, grants, OAuth connect/disconnect, tool enable/disable, profiles, credential scope) |
| MCP gateway and registration | PUT/DELETE /v1/tenant/me/mcp-gateway/upstreams/{route}, POST /v1/shield/mcp/register, POST /v1/openapi/import, /generate (5) |
| Destinations and secrets | vault create/remove, SIEM create/remove, webhooks create/update/delete (7) |
| Inventory and reviews | AIBOM put manifest/components, delete component; access reviews create/close/decisions (6) |
Agent certificates (routes_agent_identity) |
register, revoke (2; already confined to the tenant) |
| Agent instances | POST /v1/tenant/me/agent-auth/revoke (1) |
Validators and simulators in the same routers (validate*, simulate,
try, preview-sanitization, test, hash) change nothing and stay open.
4.5 Unchanged by design
Guard and decision routes (/guardrails/*, /v1/shield/{tool,action,agent,
topic,memory,embodied,runtime,mcp}/check and friends, tool/output,
tool/confirm, cap/mint, cap/verify, hooks, LiteLLM, chat completions,
A2A, MCP message and gateway routes, ext-authz), token issuance
(agent-token), OAuth and sign-in flows (/oauth/*, consent, connect,
logout, captcha), device enrollment and heartbeats, telemetry ingest (tenant
audit, SIEM ingest, SSF, runtime events, circuit-breaker reports, AIBOM ingest
and snapshots), exception requests, evidence packs and hook kits (generated
artifacts), and the playground. A runtime key must be able to call these; the
route-table guard lists them explicitly so a new route has to be classified.
5. Security & backward compatibility
| Change | Default behavior change? | Cushion |
|---|---|---|
| A: global config needs admin | Yes | Escape hatch SHIELD_GLOBAL_CONFIG_OPEN=true (one release, warns once, still requires a credential, never anonymous) |
| B: path-tenant routes need a tenant | Yes, for callers without a recognized key | None needed for real callers (they send a key); escape hatch SHIELD_PATH_TENANT_TRUST=true for one release, still requires a credential |
| C: approvals by people or admin keys | Yes | Escape hatch SHIELD_APPROVALS_TENANT_KEY=true lets unscoped (not runtime) keys decide for one release, recorded asserted; X-Approver-Sub from untrusted peers is ignored with no hatch (it only ever relabeled) |
| D: config write gate | No: off unless the registry mode is on, then warn |
SHIELD_CONFIG_WRITE_SCOPE=off |
Each escape hatch requires a credential (the lesson of PR #497: a hatch that
re-opens anonymous access is itself the vulnerability), logs once per process,
and is documented as temporary. Migration notes in the release notes and in
the runbooks of each feature (docs/policy-lifecycle.md,
docs/enterprise-features.md).
What a malicious caller can still do after this: with a runtime key under
config off, change its tenant’s configuration (by design of off); never
another tenant’s, never the global config, never decide an approval.
6. Packaging & deploy
No new modules or dependencies. New env flags:
SHIELD_CONFIG_WRITE_SCOPE, and the three temporary hatches. Rebuild both
images.
7. Failure modes & edge cases
| Case | Behavior |
|---|---|
Key store degraded, config enforce |
503 on config writes (as the write gate) |
| Global master key on a path-tenant route | Any tenant (unchanged) |
Approval with a portal session and a body approver |
The session’s subject is the approver |
X-Approver-Sub from the trusted proxy |
Honored, approver_method: "sso" |
| Rule with an approver list, asserted approver not on it | 403 (today’s check) |
| Sandbox tenant | Exempt from the config gate, as from the registry gate |
| A new write route on either app | The route-table guard fails until it is gated or listed as a guard route |
8. Test plan (Definition of Done)
- A: no credentials and a tenant key: 401/403 on
PUT /v1/shield/config, global config unchanged; admin key: 200. Hatch: a tenant key allowed, no credentials still 401. - B: for each route of the three routers, no credentials and an unrecognized key: 401 and nothing written or returned; own tenant: as today; another tenant: 403; admin and master key: any tenant.
- C: a runtime key cannot approve or deny (403, request still pending, no
grant), in every mode; a portal user and an admin-scoped key can, recorded
correctly;
X-Approver-Subfrom an untrusted peer is ignored and recordedasserted; from the trusted proxy it is honored. - D: under config
enforce, a runtime key gets 403 on each of the 74 routes and nothing changes; admin-scoped key and portal administrator allowed;warnrecords; the unset mode follows the registry mode capped atwarn; validators stay open. - Guards (both apps,
flat_routes, non-vacuous): every write route is either gated (registry, config, or admin identity) or in the explicit guard-route list of 4.5;verify_tenant_path_accessrefuses a caller with no tenant; no route records an approval from an unauthenticated header. - Existing suites green; full suite green in a clean venv; CI passes.
Tasks (one branch, one PR, one commit per task, in this order)
- A and B (critical, small): admin identity on the global config route;
verify_tenant_path_accessrefuses no-tenant callers; hatches; tests 1, 2. - C: approvals by people or admin keys; trusted-proxy
X-Approver-Sub; test 3. - D:
require_config_writeandSHIELD_CONFIG_WRITE_SCOPE; the 74 routes; test 4. - Guards: the route-table classification test (test 5).
As built
core.auth.admin_callerrecognizes an admin on any route: the/v1/admingate’s identity, an SSO operator, the root key or a managed admin credential (held to its scope throughrequire_admin_scope), an admin workload identity, a global master key. Managed credentials were not named in section 4; without this they would have lost access to the path-tenant routes they could reach before.- B has no escape hatch. A hatch that still requires a credential would change nothing: every legitimate caller of these routes sends a key the check recognizes. One existing test asserted the bug (“no key is a master key”) and now asserts the opposite.
- C. Approval rules do not name approvers, so section 4.3 item 3 had nothing to check against. Added instead, as part of the same finding: an approver counts once (a two-approval rule needs two people), and each approval records how its approver was established, so the signed grant names it per approver (it used the last approver’s method for all).
- D. 72 handlers call
require_config_write.POST /v1/shield/mcp/registerwrites a process-wide registry with no tenant, so it is admin only (as A), not tenant-gated.POST /v1/openapi/generateonly returns generated source; it is listed as a generator and stays open. Admin callers are above both write gates. The registry and config gates share_require_scoped_write. - Tests.
tests/test_tenant_config_write_scope.py(86). End to end, D is exercised on one body-less write per group of section 4.4 (FastAPI rejects a bad body before any handler runs, so a body-less request is what shows the gate) plus one write with a body; every route is covered structurally by the task 4 guard, which fails if any write route on either app is neither gated nor listed inOPEN_BY_DESIGNwith a reason, and if the list names a route that no longer exists. On the previous code: 16 of the A/B tests fail, 12 of the C tests, 38 of the D tests. - Existing tests that relied on the old behavior now act as an operator (path-tenant routes) or use a separate admin-scoped approver key (approvals). The agent identity POC’s local key is admin-scoped; the drone example approves with an admin-scoped key.