Skip to content

Commit 4fc551e

Browse files
feat(server): migrate /controls + /control-templates onto auth framework
Mirrors #204's bindings migration: replaces require_admin_key and router-level require_api_key with require_operation(CONTROLS_*) on every protected route on /controls and on /control-templates/render. Both routers now mount with the non-validating get_api_key_from_header so the framework owns authentication and authorization, with the extractor attached purely so the generated OpenAPI advertises X-API-Key. GET /controls/schema is intentionally left without a require_operation dependency: it returns a static model schema with no tenant state and routing it through the framework would force the upstream provider to handle a meta-only operation that has no permission semantics. POST /controls/validate and POST /control-templates/render are wired to CONTROLS_CREATE rather than CONTROLS_READ. Both exercise the authoring materialization path and exist to support the create / set- data flow; a caller who cannot create controls has no use for the result. Backwards-incompatible for OSS deployments that previously called these routes with non-admin keys; deployments that want the old behavior can override with HeaderAuthProvider(operation_access={...}). Storage namespace continues to come from get_namespace_key, matching the bindings migration in #204. The unified principal-derived cutover across /controls, /policies, /agents, and /evaluation is a follow-up.
1 parent 8c5c35e commit 4fc551e

7 files changed

Lines changed: 445 additions & 13 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ import { Result } from "../types/fp.js";
2727
*
2828
* @remarks
2929
* Return the canonical JSON schema for ControlDefinition.
30+
*
31+
* Intentionally has no ``require_operation`` dependency: the schema is
32+
* static metadata derived from the model class and exposes no tenant
33+
* state. Routing it through the auth framework would force callers
34+
* (and the upstream authorizer) to handle a meta-only operation that
35+
* has no permission semantics.
3036
*/
3137
export function controlsGetSchema(
3238
client: AgentControlSDKCore,

sdks/typescript/src/generated/funcs/controls-render-template.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import { Result } from "../types/fp.js";
3131
*
3232
* @remarks
3333
* Render a template-backed control without persisting it.
34+
*
35+
* Authorized as ``controls.create``: rendering is part of the authoring
36+
* flow (the result feeds the create / update endpoints), so a caller
37+
* who cannot create controls has no use for the materialized output.
3438
*/
3539
export function controlsRenderTemplate(
3640
client: AgentControlSDKCore,

sdks/typescript/src/generated/funcs/controls-validate-data.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import { Result } from "../types/fp.js";
3232
* @remarks
3333
* Validate control configuration data without saving it.
3434
*
35+
* Authorized as ``controls.create`` rather than ``controls.read``:
36+
* validation exercises the full create / update materialization path
37+
* and exists to support authoring, so a caller who cannot create
38+
* controls has no use for the result.
39+
*
3540
* Args:
3641
* request: Control configuration data to validate
3742
* db: Database session (injected)

sdks/typescript/src/generated/sdk/controls.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export class Controls extends ClientSDK {
2525
*
2626
* @remarks
2727
* Render a template-backed control without persisting it.
28+
*
29+
* Authorized as ``controls.create``: rendering is part of the authoring
30+
* flow (the result feeds the create / update endpoints), so a caller
31+
* who cannot create controls has no use for the materialized output.
2832
*/
2933
async renderTemplate(
3034
request: models.RenderControlTemplateRequest,
@@ -110,6 +114,12 @@ export class Controls extends ClientSDK {
110114
*
111115
* @remarks
112116
* Return the canonical JSON schema for ControlDefinition.
117+
*
118+
* Intentionally has no ``require_operation`` dependency: the schema is
119+
* static metadata derived from the model class and exposes no tenant
120+
* state. Routing it through the auth framework would force callers
121+
* (and the upstream authorizer) to handle a meta-only operation that
122+
* has no permission semantics.
113123
*/
114124
async getSchema(
115125
options?: RequestOptions,
@@ -126,6 +136,11 @@ export class Controls extends ClientSDK {
126136
* @remarks
127137
* Validate control configuration data without saving it.
128138
*
139+
* Authorized as ``controls.create`` rather than ``controls.read``:
140+
* validation exercises the full create / update materialization path
141+
* and exists to support authoring, so a caller who cannot create
142+
* controls has no use for the result.
143+
*
129144
* Args:
130145
* request: Control configuration data to validate
131146
* db: Database session (injected)

server/src/agent_control_server/endpoints/controls.py

Lines changed: 39 additions & 11 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,
@@ -446,8 +446,14 @@ async def _validate_control_definition(
446446
async def render_control_template(
447447
request: RenderControlTemplateRequest,
448448
db: AsyncSession = Depends(get_async_db),
449+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)),
449450
) -> RenderControlTemplateResponse:
450-
"""Render a template-backed control without persisting it."""
451+
"""Render a template-backed control without persisting it.
452+
453+
Authorized as ``controls.create``: rendering is part of the authoring
454+
flow (the result feeds the create / update endpoints), so a caller
455+
who cannot create controls has no use for the materialized output.
456+
"""
451457
control_def = await _render_and_validate_template_input(
452458
TemplateControlInput(
453459
template=request.template,
@@ -461,13 +467,14 @@ async def render_control_template(
461467

462468
@router.put(
463469
"",
464-
dependencies=[Depends(require_admin_key)],
465470
response_model=CreateControlResponse,
466471
summary="Create a new control",
467472
response_description="Created control ID",
468473
)
469474
async def create_control(
470-
request: CreateControlRequest, db: AsyncSession = Depends(get_async_db)
475+
request: CreateControlRequest,
476+
db: AsyncSession = Depends(get_async_db),
477+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)),
471478
) -> CreateControlResponse:
472479
"""
473480
Create a new control with a unique name.
@@ -550,7 +557,14 @@ async def create_control(
550557
response_description="JSON schema for ControlDefinition",
551558
)
552559
async def get_control_schema() -> GetControlSchemaResponse:
553-
"""Return the canonical JSON schema for ControlDefinition."""
560+
"""Return the canonical JSON schema for ControlDefinition.
561+
562+
Intentionally has no ``require_operation`` dependency: the schema is
563+
static metadata derived from the model class and exposes no tenant
564+
state. Routing it through the auth framework would force callers
565+
(and the upstream authorizer) to handle a meta-only operation that
566+
has no permission semantics.
567+
"""
554568
return GetControlSchemaResponse(
555569
schema=ControlDefinition.model_json_schema(by_alias=True)
556570
)
@@ -563,7 +577,9 @@ async def get_control_schema() -> GetControlSchemaResponse:
563577
response_description="Control metadata and configuration",
564578
)
565579
async def get_control(
566-
control_id: int, db: AsyncSession = Depends(get_async_db)
580+
control_id: int,
581+
db: AsyncSession = Depends(get_async_db),
582+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
567583
) -> GetControlResponse:
568584
"""
569585
Retrieve a control by ID including its name and configuration data.
@@ -600,7 +616,9 @@ async def get_control(
600616
response_description="Control data payload",
601617
)
602618
async def get_control_data(
603-
control_id: int, db: AsyncSession = Depends(get_async_db)
619+
control_id: int,
620+
db: AsyncSession = Depends(get_async_db),
621+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
604622
) -> GetControlDataResponse:
605623
"""
606624
Retrieve the configuration data for a control.
@@ -640,6 +658,7 @@ async def list_control_versions(
640658
),
641659
limit: int = Query(_DEFAULT_PAGINATION_LIMIT, ge=1, le=_MAX_PAGINATION_LIMIT),
642660
db: AsyncSession = Depends(get_async_db),
661+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
643662
) -> ListControlVersionsResponse:
644663
"""List control versions ordered newest-first using cursor-based pagination."""
645664
page = await ControlService(db).list_versions(control_id, cursor=cursor, limit=limit)
@@ -673,6 +692,7 @@ async def get_control_version(
673692
control_id: int,
674693
version_num: int,
675694
db: AsyncSession = Depends(get_async_db),
695+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
676696
) -> GetControlVersionResponse:
677697
"""Return a specific control version, including its raw persisted snapshot."""
678698
version = await ControlService(db).get_version_or_404(control_id, version_num)
@@ -687,7 +707,6 @@ async def get_control_version(
687707

688708
@router.put(
689709
"/{control_id}/data",
690-
dependencies=[Depends(require_admin_key)],
691710
response_model=SetControlDataResponse,
692711
summary="Update control configuration data",
693712
response_description="Success confirmation",
@@ -696,6 +715,7 @@ async def set_control_data(
696715
control_id: int,
697716
request: SetControlDataRequest,
698717
db: AsyncSession = Depends(get_async_db),
718+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_UPDATE)),
699719
) -> SetControlDataResponse:
700720
"""
701721
Update the configuration data for a control.
@@ -758,11 +778,18 @@ async def set_control_data(
758778
response_description="Validation result",
759779
)
760780
async def validate_control_data(
761-
request: ValidateControlDataRequest, db: AsyncSession = Depends(get_async_db)
781+
request: ValidateControlDataRequest,
782+
db: AsyncSession = Depends(get_async_db),
783+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)),
762784
) -> ValidateControlDataResponse:
763785
"""
764786
Validate control configuration data without saving it.
765787
788+
Authorized as ``controls.create`` rather than ``controls.read``:
789+
validation exercises the full create / update materialization path
790+
and exists to support authoring, so a caller who cannot create
791+
controls has no use for the result.
792+
766793
Args:
767794
request: Control configuration data to validate
768795
db: Database session (injected)
@@ -798,6 +825,7 @@ async def list_controls(
798825
execution: str | None = Query(None, description="Filter by execution ('server' or 'sdk')"),
799826
tag: str | None = Query(None, description="Filter by tag"),
800827
db: AsyncSession = Depends(get_async_db),
828+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)),
801829
) -> ListControlsResponse:
802830
"""
803831
List all controls with optional filtering and cursor-based pagination.
@@ -884,7 +912,6 @@ async def list_controls(
884912

885913
@router.delete(
886914
"/{control_id}",
887-
dependencies=[Depends(require_admin_key)],
888915
response_model=DeleteControlResponse,
889916
summary="Delete a control",
890917
response_description="Deletion confirmation with dissociation info",
@@ -897,6 +924,7 @@ async def delete_control(
897924
"If false, fail if control is associated with any policy or agent.",
898925
),
899926
db: AsyncSession = Depends(get_async_db),
927+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_DELETE)),
900928
) -> DeleteControlResponse:
901929
"""
902930
Delete a control by ID.
@@ -1035,7 +1063,6 @@ async def delete_control(
10351063

10361064
@router.patch(
10371065
"/{control_id}",
1038-
dependencies=[Depends(require_admin_key)],
10391066
response_model=PatchControlResponse,
10401067
summary="Update control metadata",
10411068
response_description="Updated control information",
@@ -1044,6 +1071,7 @@ async def patch_control(
10441071
control_id: int,
10451072
request: PatchControlRequest,
10461073
db: AsyncSession = Depends(get_async_db),
1074+
_principal: Principal = Depends(require_operation(Operation.CONTROLS_UPDATE)),
10471075
) -> PatchControlResponse:
10481076
"""
10491077
Update control metadata (name and/or enabled status).

server/src/agent_control_server/main.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,15 @@ async def attach_version_header(request, call_next): # type: ignore[no-untyped-
273273
dependencies=[Depends(require_api_key)],
274274
)
275275
app.include_router(
276+
# ``/controls`` CRUD goes through the auth framework on each
277+
# endpoint (``require_operation(Operation.CONTROLS_*)``); see the
278+
# ``control_binding_router`` rationale below for the
279+
# ``get_api_key_from_header`` mounting pattern. The single route on
280+
# this router without ``require_operation`` is ``GET /controls/schema``,
281+
# which is intentionally public meta — see its endpoint docstring.
276282
control_router,
277283
prefix=api_v1_prefix,
278-
dependencies=[Depends(require_api_key)],
284+
dependencies=[Depends(get_api_key_from_header)],
279285
)
280286
app.include_router(
281287
# The auth framework on each endpoint owns authentication and
@@ -300,9 +306,12 @@ async def attach_version_header(request, call_next): # type: ignore[no-untyped-
300306
dependencies=[Depends(get_api_key_from_header)],
301307
)
302308
app.include_router(
309+
# Control templates: ``/render`` is on the auth framework via
310+
# ``require_operation(Operation.CONTROLS_CREATE)``; same mounting
311+
# pattern as the controls and control-bindings routers.
303312
control_template_router,
304313
prefix=api_v1_prefix,
305-
dependencies=[Depends(require_api_key)],
314+
dependencies=[Depends(get_api_key_from_header)],
306315
)
307316
app.include_router(
308317
evaluation_router,

0 commit comments

Comments
 (0)