-
Notifications
You must be signed in to change notification settings - Fork 29
feat(server): migrate controls routes to auth framework #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8a31d15
3a5b7e4
8312b99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,7 +33,7 @@ | |
| from sqlalchemy.exc import IntegrityError | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from ..auth import require_admin_key | ||
| from ..auth_framework import Operation, Principal, require_operation | ||
| from ..db import get_async_db | ||
| from ..errors import ( | ||
| APIValidationError, | ||
|
|
@@ -443,9 +443,11 @@ async def _validate_control_definition( | |
| summary="Render a control template preview", | ||
| response_description="Rendered control preview", | ||
| ) | ||
| # Rendering is part of the authoring flow, so require create access. | ||
| async def render_control_template( | ||
| request: RenderControlTemplateRequest, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)), | ||
| ) -> RenderControlTemplateResponse: | ||
| """Render a template-backed control without persisting it.""" | ||
| control_def = await _render_and_validate_template_input( | ||
|
|
@@ -461,13 +463,14 @@ async def render_control_template( | |
|
|
||
| @router.put( | ||
| "", | ||
| dependencies=[Depends(require_admin_key)], | ||
| response_model=CreateControlResponse, | ||
| summary="Create a new control", | ||
| response_description="Created control ID", | ||
| ) | ||
| async def create_control( | ||
| request: CreateControlRequest, db: AsyncSession = Depends(get_async_db) | ||
| request: CreateControlRequest, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)), | ||
| ) -> CreateControlResponse: | ||
| """ | ||
| Create a new control with a unique name. | ||
|
|
@@ -549,6 +552,7 @@ async def create_control( | |
| summary="Get control definition JSON schema", | ||
| response_description="JSON schema for ControlDefinition", | ||
| ) | ||
| # Public schema metadata: no tenant state, no auth operation. | ||
| async def get_control_schema() -> GetControlSchemaResponse: | ||
| """Return the canonical JSON schema for ControlDefinition.""" | ||
| return GetControlSchemaResponse( | ||
|
|
@@ -563,7 +567,9 @@ async def get_control_schema() -> GetControlSchemaResponse: | |
| response_description="Control metadata and configuration", | ||
| ) | ||
| async def get_control( | ||
| control_id: int, db: AsyncSession = Depends(get_async_db) | ||
| control_id: int, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The endpoint binds _principal for the auth side-effect but never threads _principal.namespace_key into the ControlService calls. list_controls_page doesn't even accept a namespace; get_active_control_or_404 accepts one but isn't called with it. Today this is fine because HeaderAuthProvider always returns DEFAULT_NAMESPACE_KEY, but the moment any other provider lands (and the framework is explicitly built for that), controls become cross-tenant readable/writable here. Recommended fix in this PR (cheap, defensive): add a small _require_default_namespace(principal) helper in this module and call it from every endpoint that captures _principal. That converts a silent forward-compat hazard into a loud deploy-time failure the day a non-default namespace shows up. Threading it properly through the service layer can be a follow-up PR. |
||
| ) -> GetControlResponse: | ||
| """ | ||
| Retrieve a control by ID including its name and configuration data. | ||
|
|
@@ -600,7 +606,9 @@ async def get_control( | |
| response_description="Control data payload", | ||
| ) | ||
| async def get_control_data( | ||
| control_id: int, db: AsyncSession = Depends(get_async_db) | ||
| control_id: int, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)), | ||
| ) -> GetControlDataResponse: | ||
| """ | ||
| Retrieve the configuration data for a control. | ||
|
|
@@ -640,6 +648,7 @@ async def list_control_versions( | |
| ), | ||
| limit: int = Query(_DEFAULT_PAGINATION_LIMIT, ge=1, le=_MAX_PAGINATION_LIMIT), | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)), | ||
| ) -> ListControlVersionsResponse: | ||
| """List control versions ordered newest-first using cursor-based pagination.""" | ||
| page = await ControlService(db).list_versions(control_id, cursor=cursor, limit=limit) | ||
|
|
@@ -673,6 +682,7 @@ async def get_control_version( | |
| control_id: int, | ||
| version_num: int, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)), | ||
| ) -> GetControlVersionResponse: | ||
| """Return a specific control version, including its raw persisted snapshot.""" | ||
| version = await ControlService(db).get_version_or_404(control_id, version_num) | ||
|
|
@@ -687,7 +697,6 @@ async def get_control_version( | |
|
|
||
| @router.put( | ||
| "/{control_id}/data", | ||
| dependencies=[Depends(require_admin_key)], | ||
| response_model=SetControlDataResponse, | ||
| summary="Update control configuration data", | ||
| response_description="Success confirmation", | ||
|
|
@@ -696,6 +705,7 @@ async def set_control_data( | |
| control_id: int, | ||
| request: SetControlDataRequest, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_UPDATE)), | ||
| ) -> SetControlDataResponse: | ||
| """ | ||
| Update the configuration data for a control. | ||
|
|
@@ -757,8 +767,11 @@ async def set_control_data( | |
| summary="Validate control configuration", | ||
| response_description="Validation result", | ||
| ) | ||
| # Validation uses the authoring path, so require create access. | ||
| async def validate_control_data( | ||
| request: ValidateControlDataRequest, db: AsyncSession = Depends(get_async_db) | ||
| request: ValidateControlDataRequest, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)), | ||
| ) -> ValidateControlDataResponse: | ||
| """ | ||
| Validate control configuration data without saving it. | ||
|
|
@@ -798,6 +811,7 @@ async def list_controls( | |
| execution: str | None = Query(None, description="Filter by execution ('server' or 'sdk')"), | ||
| tag: str | None = Query(None, description="Filter by tag"), | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)), | ||
| ) -> ListControlsResponse: | ||
| """ | ||
| List all controls with optional filtering and cursor-based pagination. | ||
|
|
@@ -884,7 +898,6 @@ async def list_controls( | |
|
|
||
| @router.delete( | ||
| "/{control_id}", | ||
| dependencies=[Depends(require_admin_key)], | ||
| response_model=DeleteControlResponse, | ||
| summary="Delete a control", | ||
| response_description="Deletion confirmation with dissociation info", | ||
|
|
@@ -897,6 +910,7 @@ async def delete_control( | |
| "If false, fail if control is associated with any policy or agent.", | ||
| ), | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_DELETE)), | ||
| ) -> DeleteControlResponse: | ||
| """ | ||
| Delete a control by ID. | ||
|
|
@@ -1035,7 +1049,6 @@ async def delete_control( | |
|
|
||
| @router.patch( | ||
| "/{control_id}", | ||
| dependencies=[Depends(require_admin_key)], | ||
| response_model=PatchControlResponse, | ||
| summary="Update control metadata", | ||
| response_description="Updated control information", | ||
|
|
@@ -1044,6 +1057,7 @@ async def patch_control( | |
| control_id: int, | ||
| request: PatchControlRequest, | ||
| db: AsyncSession = Depends(get_async_db), | ||
| _principal: Principal = Depends(require_operation(Operation.CONTROLS_UPDATE)), | ||
| ) -> PatchControlResponse: | ||
| """ | ||
| Update control metadata (name and/or enabled status). | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1:
GET /controls/schemais now anonymous. Previously gated byrequire_api_keyat the router. The diff intentionally drops auth and test_schema_endpoint_reachable_without_credentials pins it. Schema content isControlDefinition.model_json_schema()(no tenant data), so likely fine, but worth explicit security sign-off because it changes the "every /api/v1/controls/* route requires creds" invariant operators may have depended on.