Skip to content

Commit 764bd4b

Browse files
feat(server): migrate controls routes to auth framework (#212)
## Summary - Move `/controls` and `/control-templates/render` onto operation-based auth. - Keep `GET /controls/schema` public because it returns static metadata. - Require `CONTROLS_CREATE` for validate and render because both use the authoring path. - Preserve no-auth deployment mode. ## Behavior Change - `POST /controls/validate` and `POST /control-templates/render` now require create access under the default header provider. ## Testing - `make prepush` on the stacked branch in #215.
1 parent 1efe30f commit 764bd4b

6 files changed

Lines changed: 397 additions & 31 deletions

File tree

sdks/typescript/src/generated/funcs/controls-get-schema.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { AgentControlSDKCore } from "../core.js";
66
import * as M from "../lib/matchers.js";
77
import { compactMap } from "../lib/primitives.js";
88
import { RequestOptions } from "../lib/sdks.js";
9-
import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js";
109
import { pathToFunc } from "../lib/url.js";
1110
import { AgentControlSDKError } from "../models/errors/agent-control-sdk-error.js";
1211
import {
@@ -75,27 +74,22 @@ async function $do(
7574
Accept: "application/json",
7675
}));
7776

78-
const secConfig = await extractSecurity(client._options.apiKeyHeader);
79-
const securityInput = secConfig == null ? {} : { apiKeyHeader: secConfig };
80-
const requestSecurity = resolveGlobalSecurity(securityInput);
81-
8277
const context = {
8378
options: client._options,
8479
baseURL: options?.serverURL ?? client._baseURL ?? "",
8580
operationID: "get_control_schema_api_v1_controls_schema_get",
8681
oAuth2Scopes: null,
8782

88-
resolvedSecurity: requestSecurity,
83+
resolvedSecurity: null,
8984

90-
securitySource: client._options.apiKeyHeader,
85+
securitySource: null,
9186
retryConfig: options?.retries
9287
|| client._options.retryConfig
9388
|| { strategy: "none" },
9489
retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"],
9590
};
9691

9792
const requestRes = client._createRequest(context, {
98-
security: requestSecurity,
9993
method: "GET",
10094
baseURL: options?.serverURL,
10195
path: path,

sdks/typescript/src/generated/models/security.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import * as z from "zod/v4-mini";
66
import { remap as remap$ } from "../lib/primitives.js";
77

88
export type Security = {
9-
apiKeyHeader: string;
9+
apiKeyHeader?: string | undefined;
1010
};
1111

1212
/** @internal */
1313
export type Security$Outbound = {
14-
APIKeyHeader: string;
14+
APIKeyHeader?: string | undefined;
1515
};
1616

1717
/** @internal */
@@ -20,7 +20,7 @@ export const Security$outboundSchema: z.ZodMiniType<
2020
Security
2121
> = z.pipe(
2222
z.object({
23-
apiKeyHeader: z.string(),
23+
apiKeyHeader: z.optional(z.string()),
2424
}),
2525
z.transform((v) => {
2626
return remap$(v, {

server/src/agent_control_server/endpoints/control_bindings.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,11 @@
3636

3737

3838
async def _binding_body_context(request: Request) -> dict[str, Any]:
39-
"""Surface ``(target_type, target_id)`` to the authorizer's context.
39+
"""Surface ``(target_type, target_id)`` to the authorization context.
4040
4141
The body-bearing binding endpoints carry the target identifiers in
42-
the request payload. Upstream authorizers that resolve the target's
43-
owning project (e.g., Galileo's ``check_management_access``) need
44-
those identifiers to make a project-level decision; without them the
45-
upstream returns 400.
42+
the request payload. Authorization providers can use those
43+
identifiers when a request needs target-scoped access checks.
4644
4745
FastAPI caches the parsed body, so the endpoint's own Pydantic
4846
request model still binds normally.
@@ -60,13 +58,12 @@ async def _binding_body_context(request: Request) -> dict[str, Any]:
6058

6159

6260
async def _binding_list_context(request: Request) -> dict[str, Any]:
63-
"""Surface optional target query parameters to the authorizer.
61+
"""Surface optional target query parameters to authorization context.
6462
6563
When the GET list endpoint is called with ``target_type`` and
6664
``target_id`` query params, the request is target-scoped and the
67-
upstream needs the identifiers to make a project-level decision.
68-
When neither is present the request is namespace-wide and forwards
69-
no target context (upstream may then reject if it requires one).
65+
request context includes those identifiers. When neither is present
66+
the request is namespace-wide and forwards no target context.
7067
"""
7168
target_type = request.query_params.get("target_type")
7269
target_id = request.query_params.get("target_id")

server/src/agent_control_server/endpoints/controls.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from sqlalchemy.exc import IntegrityError
3434
from sqlalchemy.ext.asyncio import AsyncSession
3535

36-
from ..auth import require_admin_key
36+
from ..auth_framework import Operation, Principal, require_operation
3737
from ..db import get_async_db
3838
from ..errors import (
3939
APIValidationError,
@@ -443,9 +443,11 @@ async def _validate_control_definition(
443443
summary="Render a control template preview",
444444
response_description="Rendered control preview",
445445
)
446+
# Rendering is part of the authoring flow, so require create access.
446447
async def render_control_template(
447448
request: RenderControlTemplateRequest,
448449
db: AsyncSession = Depends(get_async_db),
450+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)),
449451
) -> RenderControlTemplateResponse:
450452
"""Render a template-backed control without persisting it."""
451453
control_def = await _render_and_validate_template_input(
@@ -461,13 +463,14 @@ async def render_control_template(
461463

462464
@router.put(
463465
"",
464-
dependencies=[Depends(require_admin_key)],
465466
response_model=CreateControlResponse,
466467
summary="Create a new control",
467468
response_description="Created control ID",
468469
)
469470
async def create_control(
470-
request: CreateControlRequest, db: AsyncSession = Depends(get_async_db)
471+
request: CreateControlRequest,
472+
db: AsyncSession = Depends(get_async_db),
473+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)),
471474
) -> CreateControlResponse:
472475
"""
473476
Create a new control with a unique name.
@@ -549,6 +552,7 @@ async def create_control(
549552
summary="Get control definition JSON schema",
550553
response_description="JSON schema for ControlDefinition",
551554
)
555+
# Public schema metadata: no tenant state, no auth operation.
552556
async def get_control_schema() -> GetControlSchemaResponse:
553557
"""Return the canonical JSON schema for ControlDefinition."""
554558
return GetControlSchemaResponse(
@@ -563,7 +567,9 @@ async def get_control_schema() -> GetControlSchemaResponse:
563567
response_description="Control metadata and configuration",
564568
)
565569
async def get_control(
566-
control_id: int, db: AsyncSession = Depends(get_async_db)
570+
control_id: int,
571+
db: AsyncSession = Depends(get_async_db),
572+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
567573
) -> GetControlResponse:
568574
"""
569575
Retrieve a control by ID including its name and configuration data.
@@ -600,7 +606,9 @@ async def get_control(
600606
response_description="Control data payload",
601607
)
602608
async def get_control_data(
603-
control_id: int, db: AsyncSession = Depends(get_async_db)
609+
control_id: int,
610+
db: AsyncSession = Depends(get_async_db),
611+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
604612
) -> GetControlDataResponse:
605613
"""
606614
Retrieve the configuration data for a control.
@@ -640,6 +648,7 @@ async def list_control_versions(
640648
),
641649
limit: int = Query(_DEFAULT_PAGINATION_LIMIT, ge=1, le=_MAX_PAGINATION_LIMIT),
642650
db: AsyncSession = Depends(get_async_db),
651+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
643652
) -> ListControlVersionsResponse:
644653
"""List control versions ordered newest-first using cursor-based pagination."""
645654
page = await ControlService(db).list_versions(control_id, cursor=cursor, limit=limit)
@@ -673,6 +682,7 @@ async def get_control_version(
673682
control_id: int,
674683
version_num: int,
675684
db: AsyncSession = Depends(get_async_db),
685+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
676686
) -> GetControlVersionResponse:
677687
"""Return a specific control version, including its raw persisted snapshot."""
678688
version = await ControlService(db).get_version_or_404(control_id, version_num)
@@ -687,7 +697,6 @@ async def get_control_version(
687697

688698
@router.put(
689699
"/{control_id}/data",
690-
dependencies=[Depends(require_admin_key)],
691700
response_model=SetControlDataResponse,
692701
summary="Update control configuration data",
693702
response_description="Success confirmation",
@@ -696,6 +705,7 @@ async def set_control_data(
696705
control_id: int,
697706
request: SetControlDataRequest,
698707
db: AsyncSession = Depends(get_async_db),
708+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_UPDATE)),
699709
) -> SetControlDataResponse:
700710
"""
701711
Update the configuration data for a control.
@@ -757,8 +767,11 @@ async def set_control_data(
757767
summary="Validate control configuration",
758768
response_description="Validation result",
759769
)
770+
# Validation uses the authoring path, so require create access.
760771
async def validate_control_data(
761-
request: ValidateControlDataRequest, db: AsyncSession = Depends(get_async_db)
772+
request: ValidateControlDataRequest,
773+
db: AsyncSession = Depends(get_async_db),
774+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)),
762775
) -> ValidateControlDataResponse:
763776
"""
764777
Validate control configuration data without saving it.
@@ -798,6 +811,7 @@ async def list_controls(
798811
execution: str | None = Query(None, description="Filter by execution ('server' or 'sdk')"),
799812
tag: str | None = Query(None, description="Filter by tag"),
800813
db: AsyncSession = Depends(get_async_db),
814+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
801815
) -> ListControlsResponse:
802816
"""
803817
List all controls with optional filtering and cursor-based pagination.
@@ -884,7 +898,6 @@ async def list_controls(
884898

885899
@router.delete(
886900
"/{control_id}",
887-
dependencies=[Depends(require_admin_key)],
888901
response_model=DeleteControlResponse,
889902
summary="Delete a control",
890903
response_description="Deletion confirmation with dissociation info",
@@ -897,6 +910,7 @@ async def delete_control(
897910
"If false, fail if control is associated with any policy or agent.",
898911
),
899912
db: AsyncSession = Depends(get_async_db),
913+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_DELETE)),
900914
) -> DeleteControlResponse:
901915
"""
902916
Delete a control by ID.
@@ -1035,7 +1049,6 @@ async def delete_control(
10351049

10361050
@router.patch(
10371051
"/{control_id}",
1038-
dependencies=[Depends(require_admin_key)],
10391052
response_model=PatchControlResponse,
10401053
summary="Update control metadata",
10411054
response_description="Updated control information",
@@ -1044,6 +1057,7 @@ async def patch_control(
10441057
control_id: int,
10451058
request: PatchControlRequest,
10461059
db: AsyncSession = Depends(get_async_db),
1060+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_UPDATE)),
10471061
) -> PatchControlResponse:
10481062
"""
10491063
Update control metadata (name and/or enabled status).

server/src/agent_control_server/main.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,10 @@ async def attach_version_header(request, call_next): # type: ignore[no-untyped-
273273
dependencies=[Depends(require_api_key)],
274274
)
275275
app.include_router(
276+
# Endpoint dependencies handle auth; this advertises X-API-Key.
276277
control_router,
277278
prefix=api_v1_prefix,
278-
dependencies=[Depends(require_api_key)],
279+
dependencies=[Depends(get_api_key_from_header)],
279280
)
280281
app.include_router(
281282
# The auth framework on each endpoint owns authentication and
@@ -300,9 +301,10 @@ async def attach_version_header(request, call_next): # type: ignore[no-untyped-
300301
dependencies=[Depends(get_api_key_from_header)],
301302
)
302303
app.include_router(
304+
# Endpoint dependencies handle auth; this advertises X-API-Key.
303305
control_template_router,
304306
prefix=api_v1_prefix,
305-
dependencies=[Depends(require_api_key)],
307+
dependencies=[Depends(get_api_key_from_header)],
306308
)
307309
app.include_router(
308310
evaluation_router,
@@ -345,6 +347,17 @@ def custom_openapi() -> dict[str, Any]:
345347
if "JSONValue" in schemas:
346348
schemas["JSONValue"] = {"description": "Any JSON value"}
347349

350+
# This route is intentionally public metadata. FastAPI still emits inherited
351+
# API-key security for it, so patch only this operation in the generated spec.
352+
controls_schema_path = f"{api_v1_prefix}/controls/schema"
353+
controls_schema_operation = (
354+
openapi_schema.get("paths", {})
355+
.get(controls_schema_path, {})
356+
.get("get")
357+
)
358+
if isinstance(controls_schema_operation, dict):
359+
controls_schema_operation["security"] = []
360+
348361
app.openapi_schema = openapi_schema
349362
return app.openapi_schema
350363

0 commit comments

Comments
 (0)