diff --git a/models/src/agent_control_models/__init__.py b/models/src/agent_control_models/__init__.py index 148cdd7a..3e1cdf07 100644 --- a/models/src/agent_control_models/__init__.py +++ b/models/src/agent_control_models/__init__.py @@ -84,6 +84,7 @@ AgentRef, AgentSummary, ConflictMode, + ControlAttachments, ControlSummary, ControlVersionSummary, CreateControlBindingRequest, @@ -107,9 +108,11 @@ PatchControlBindingResponse, PatchControlRequest, PatchControlResponse, + PolicyRef, RenderControlTemplateRequest, RenderControlTemplateResponse, StepKey, + TargetAttachmentRef, UpsertControlBindingRequest, UpsertControlBindingResponse, ValidateControlDataRequest, @@ -177,6 +180,7 @@ "AgentRef", "AgentSummary", "ConflictMode", + "ControlAttachments", "ControlVersionSummary", "ControlSummary", "CreateControlBindingRequest", @@ -200,9 +204,11 @@ "PatchControlBindingResponse", "PatchControlRequest", "PatchControlResponse", + "PolicyRef", "RenderControlTemplateRequest", "RenderControlTemplateResponse", "StepKey", + "TargetAttachmentRef", "UpsertControlBindingRequest", "UpsertControlBindingResponse", "ValidateControlDataRequest", diff --git a/models/src/agent_control_models/server.py b/models/src/agent_control_models/server.py index 3529a5d4..c2a4bacc 100644 --- a/models/src/agent_control_models/server.py +++ b/models/src/agent_control_models/server.py @@ -514,6 +514,38 @@ class AgentRef(BaseModel): agent_name: str = Field(..., description="Agent name") +class PolicyRef(BaseModel): + """Reference to a policy attached to a control.""" + + policy_id: int = Field(..., description="Policy ID") + + +class TargetAttachmentRef(BaseModel): + """Reference to a target binding attached to a control.""" + + binding_id: int = Field(..., description="Control binding ID") + target_type: str = Field(..., description="Opaque target kind") + target_id: str = Field(..., description="Opaque target identifier") + enabled: bool = Field(..., description="Whether this target binding is enabled") + + +class ControlAttachments(BaseModel): + """Attachments for a listed control.""" + + agents: list[AgentRef] = Field( + default_factory=list, + description="Direct agent associations for this control", + ) + policies: list[PolicyRef] = Field( + default_factory=list, + description="Policy associations for this control", + ) + targets: list[TargetAttachmentRef] = Field( + default_factory=list, + description="Target bindings for this control", + ) + + class ControlSummary(BaseModel): """Summary of a control for list responses.""" @@ -542,6 +574,13 @@ class ControlSummary(BaseModel): used_by_agents_count: int = Field( 0, description="Number of unique agents using this control" ) + attachments: ControlAttachments | None = Field( + None, + description=( + "Expanded attachment details. Present when list controls is called " + "with include_attachments=true." + ), + ) class ListControlsResponse(BaseModel): @@ -759,4 +798,3 @@ class DeleteControlBindingByKeyResponse(BaseModel): "binding existed." ), ) - diff --git a/sdks/typescript/src/generated/funcs/controls-list.ts b/sdks/typescript/src/generated/funcs/controls-list.ts index 2fd69073..30cf6c50 100644 --- a/sdks/typescript/src/generated/funcs/controls-list.ts +++ b/sdks/typescript/src/generated/funcs/controls-list.ts @@ -45,6 +45,8 @@ import { Result } from "../types/fp.js"; * stage: Optional filter by stage ('pre' or 'post') * execution: Optional filter by execution ('server' or 'sdk') * tag: Optional filter by tag + * include_attachments: Whether to include attachment details for listed controls + * attachment_target_type: Optional target binding type filter for attachments * db: Database session (injected) * * Returns: @@ -119,9 +121,11 @@ async function $do( const path = pathToFunc("/api/v1/controls")(); const query = encodeFormQuery({ + "attachment_target_type": payload?.attachment_target_type, "cursor": payload?.cursor, "enabled": payload?.enabled, "execution": payload?.execution, + "include_attachments": payload?.include_attachments, "limit": payload?.limit, "name": payload?.name, "stage": payload?.stage, diff --git a/sdks/typescript/src/generated/models/control-attachments.ts b/sdks/typescript/src/generated/models/control-attachments.ts new file mode 100644 index 00000000..b2fcded4 --- /dev/null +++ b/sdks/typescript/src/generated/models/control-attachments.ts @@ -0,0 +1,53 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { safeParse } from "../lib/schemas.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { AgentRef, AgentRef$inboundSchema } from "./agent-ref.js"; +import { SDKValidationError } from "./errors/sdk-validation-error.js"; +import { PolicyRef, PolicyRef$inboundSchema } from "./policy-ref.js"; +import { + TargetAttachmentRef, + TargetAttachmentRef$inboundSchema, +} from "./target-attachment-ref.js"; + +/** + * Attachments for a listed control. + */ +export type ControlAttachments = { + /** + * Direct agent associations for this control + */ + agents?: Array | undefined; + /** + * Policy associations for this control + */ + policies?: Array | undefined; + /** + * Target bindings for this control + */ + targets?: Array | undefined; +}; + +/** @internal */ +export const ControlAttachments$inboundSchema: z.ZodMiniType< + ControlAttachments, + unknown +> = z.object({ + agents: types.optional(z.array(AgentRef$inboundSchema)), + policies: types.optional(z.array(PolicyRef$inboundSchema)), + targets: types.optional(z.array(TargetAttachmentRef$inboundSchema)), +}); + +export function controlAttachmentsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ControlAttachments$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ControlAttachments' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/control-summary.ts b/sdks/typescript/src/generated/models/control-summary.ts index 4c0b0fb3..c096e7f3 100644 --- a/sdks/typescript/src/generated/models/control-summary.ts +++ b/sdks/typescript/src/generated/models/control-summary.ts @@ -8,12 +8,20 @@ import { safeParse } from "../lib/schemas.js"; import { Result as SafeParseResult } from "../types/fp.js"; import * as types from "../types/primitives.js"; import { AgentRef, AgentRef$inboundSchema } from "./agent-ref.js"; +import { + ControlAttachments, + ControlAttachments$inboundSchema, +} from "./control-attachments.js"; import { SDKValidationError } from "./errors/sdk-validation-error.js"; /** * Summary of a control for list responses. */ export type ControlSummary = { + /** + * Expanded attachment details. Present when list controls is called with include_attachments=true. + */ + attachments?: ControlAttachments | null | undefined; /** * Control description */ @@ -70,6 +78,7 @@ export const ControlSummary$inboundSchema: z.ZodMiniType< unknown > = z.pipe( z.object({ + attachments: z.optional(z.nullable(ControlAttachments$inboundSchema)), description: z.optional(z.nullable(types.string())), enabled: z._default(types.boolean(), true), execution: z.optional(z.nullable(types.string())), diff --git a/sdks/typescript/src/generated/models/index.ts b/sdks/typescript/src/generated/models/index.ts index 595a9501..3b364f67 100644 --- a/sdks/typescript/src/generated/models/index.ts +++ b/sdks/typescript/src/generated/models/index.ts @@ -17,6 +17,7 @@ export * from "./condition-node-output.js"; export * from "./config-response.js"; export * from "./conflict-mode.js"; export * from "./control-action.js"; +export * from "./control-attachments.js"; export * from "./control-definition-input.js"; export * from "./control-definition-output.js"; export * from "./control-execution-event.js"; @@ -77,6 +78,7 @@ export * from "./patch-control-binding-request.js"; export * from "./patch-control-binding-response.js"; export * from "./patch-control-request.js"; export * from "./patch-control-response.js"; +export * from "./policy-ref.js"; export * from "./regex-template-parameter.js"; export * from "./remove-agent-control-response.js"; export * from "./render-control-template-request.js"; @@ -95,6 +97,7 @@ export * from "./step-schema.js"; export * from "./step.js"; export * from "./string-list-template-parameter.js"; export * from "./string-template-parameter.js"; +export * from "./target-attachment-ref.js"; export * from "./template-control-input.js"; export * from "./template-definition-input.js"; export * from "./template-definition-output.js"; diff --git a/sdks/typescript/src/generated/models/operations/list-controls-api-v1-controls-get.ts b/sdks/typescript/src/generated/models/operations/list-controls-api-v1-controls-get.ts index 7f19162e..e1d9b50f 100644 --- a/sdks/typescript/src/generated/models/operations/list-controls-api-v1-controls-get.ts +++ b/sdks/typescript/src/generated/models/operations/list-controls-api-v1-controls-get.ts @@ -39,6 +39,14 @@ export type ListControlsApiV1ControlsGetRequest = { * Filter by tag */ tag?: string | null | undefined; + /** + * When true, include direct agent associations, policy associations, and target bindings for each listed control. + */ + includeAttachments?: boolean | undefined; + /** + * Optional target_type filter applied to expanded target bindings. Only used when include_attachments=true. + */ + attachmentTargetType?: string | null | undefined; }; /** @internal */ @@ -52,6 +60,8 @@ export type ListControlsApiV1ControlsGetRequest$Outbound = { stage?: string | null | undefined; execution?: string | null | undefined; tag?: string | null | undefined; + include_attachments: boolean; + attachment_target_type?: string | null | undefined; }; /** @internal */ @@ -69,11 +79,15 @@ export const ListControlsApiV1ControlsGetRequest$outboundSchema: z.ZodMiniType< stage: z.optional(z.nullable(z.string())), execution: z.optional(z.nullable(z.string())), tag: z.optional(z.nullable(z.string())), + includeAttachments: z._default(z.boolean(), false), + attachmentTargetType: z.optional(z.nullable(z.string())), }), z.transform((v) => { return remap$(v, { templateBacked: "template_backed", stepType: "step_type", + includeAttachments: "include_attachments", + attachmentTargetType: "attachment_target_type", }); }), ); diff --git a/sdks/typescript/src/generated/models/policy-ref.ts b/sdks/typescript/src/generated/models/policy-ref.ts new file mode 100644 index 00000000..ab6f9fbc --- /dev/null +++ b/sdks/typescript/src/generated/models/policy-ref.ts @@ -0,0 +1,43 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { remap as remap$ } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { SDKValidationError } from "./errors/sdk-validation-error.js"; + +/** + * Reference to a policy attached to a control. + */ +export type PolicyRef = { + /** + * Policy ID + */ + policyId: number; +}; + +/** @internal */ +export const PolicyRef$inboundSchema: z.ZodMiniType = z + .pipe( + z.object({ + policy_id: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "policy_id": "policyId", + }); + }), + ); + +export function policyRefFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => PolicyRef$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'PolicyRef' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/target-attachment-ref.ts b/sdks/typescript/src/generated/models/target-attachment-ref.ts new file mode 100644 index 00000000..c1393dc5 --- /dev/null +++ b/sdks/typescript/src/generated/models/target-attachment-ref.ts @@ -0,0 +1,62 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { remap as remap$ } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { SDKValidationError } from "./errors/sdk-validation-error.js"; + +/** + * Reference to a target binding attached to a control. + */ +export type TargetAttachmentRef = { + /** + * Control binding ID + */ + bindingId: number; + /** + * Whether this target binding is enabled + */ + enabled: boolean; + /** + * Opaque target identifier + */ + targetId: string; + /** + * Opaque target kind + */ + targetType: string; +}; + +/** @internal */ +export const TargetAttachmentRef$inboundSchema: z.ZodMiniType< + TargetAttachmentRef, + unknown +> = z.pipe( + z.object({ + binding_id: types.number(), + enabled: types.boolean(), + target_id: types.string(), + target_type: types.string(), + }), + z.transform((v) => { + return remap$(v, { + "binding_id": "bindingId", + "target_id": "targetId", + "target_type": "targetType", + }); + }), +); + +export function targetAttachmentRefFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TargetAttachmentRef$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TargetAttachmentRef' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/sdk/controls.ts b/sdks/typescript/src/generated/sdk/controls.ts index ed3cf8db..6d92330e 100644 --- a/sdks/typescript/src/generated/sdk/controls.ts +++ b/sdks/typescript/src/generated/sdk/controls.ts @@ -55,6 +55,8 @@ export class Controls extends ClientSDK { * stage: Optional filter by stage ('pre' or 'post') * execution: Optional filter by execution ('server' or 'sdk') * tag: Optional filter by tag + * include_attachments: Whether to include attachment details for listed controls + * attachment_target_type: Optional target binding type filter for attachments * db: Database session (injected) * * Returns: diff --git a/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index 6e6441e9..adc5e62a 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -5,6 +5,7 @@ from agent_control_models.errors import ErrorCode, ValidationErrorItem from agent_control_models.server import ( AgentRef, + ControlAttachments, ControlSummary, ControlVersionSummary, CreateControlRequest, @@ -19,10 +20,12 @@ PaginationInfo, PatchControlRequest, PatchControlResponse, + PolicyRef, RenderControlTemplateRequest, RenderControlTemplateResponse, SetControlDataRequest, SetControlDataResponse, + TargetAttachmentRef, ValidateControlDataRequest, ValidateControlDataResponse, ) @@ -858,6 +861,22 @@ async def list_controls( stage: str | None = Query(None, description="Filter by stage ('pre' or 'post')"), execution: str | None = Query(None, description="Filter by execution ('server' or 'sdk')"), tag: str | None = Query(None, description="Filter by tag"), + include_attachments: bool = Query( + False, + description=( + "When true, include direct agent associations, policy associations, " + "and target bindings for each listed control." + ), + ), + attachment_target_type: str | None = Query( + None, + min_length=1, + max_length=255, + description=( + "Optional target_type filter applied to expanded target bindings. " + "Only used when include_attachments=true." + ), + ), db: AsyncSession = Depends(get_async_db), principal: Principal = Depends(require_operation(Operation.CONTROLS_READ)), ) -> ListControlsResponse: @@ -876,6 +895,8 @@ async def list_controls( stage: Optional filter by stage ('pre' or 'post') execution: Optional filter by execution ('server' or 'sdk') tag: Optional filter by tag + include_attachments: Whether to include attachment details for listed controls + attachment_target_type: Optional target binding type filter for attachments db: Database session (injected) Returns: @@ -902,6 +923,15 @@ async def list_controls( [control.id for control in page.controls], namespace_key=namespace_key, ) + attachments_by_control_id = ( + await control_service.list_control_attachments( + [control.id for control in page.controls], + namespace_key=namespace_key, + target_type=attachment_target_type, + ) + if include_attachments + else {} + ) # Build summaries (filtering already done at DB level) summaries: list[ControlSummary] = [] @@ -910,6 +940,7 @@ async def list_controls( data = ctrl.data or {} scope = data.get("scope") or {} usage = usage_by_control_id.get(ctrl.id) + attachments = attachments_by_control_id.get(ctrl.id) summaries.append( ControlSummary( id=ctrl.id, @@ -933,6 +964,29 @@ async def list_controls( else None ), used_by_agents_count=usage.used_by_agents_count if usage is not None else 0, + attachments=( + ControlAttachments( + agents=[ + AgentRef(agent_name=agent_name) + for agent_name in attachments.agent_names + ], + policies=[ + PolicyRef(policy_id=policy_id) + for policy_id in attachments.policy_ids + ], + targets=[ + TargetAttachmentRef( + binding_id=target.binding_id, + target_type=target.target_type, + target_id=target.target_id, + enabled=target.enabled, + ) + for target in attachments.targets + ], + ) + if attachments is not None + else None + ), ) ) diff --git a/server/src/agent_control_server/services/controls.py b/server/src/agent_control_server/services/controls.py index 6c015310..35076b21 100644 --- a/server/src/agent_control_server/services/controls.py +++ b/server/src/agent_control_server/services/controls.py @@ -74,6 +74,25 @@ class ControlUsage: used_by_agents_count: int +@dataclass(frozen=True) +class ControlTargetAttachment: + """Target binding attached to a control.""" + + binding_id: int + target_type: str + target_id: str + enabled: bool + + +@dataclass(frozen=True) +class ControlAttachmentSet: + """Direct attachments for a listed control.""" + + policy_ids: list[int] + agent_names: list[str] + targets: list[ControlTargetAttachment] + + @dataclass(frozen=True) class ControlAssociations: """Policy and agent associations for a control.""" @@ -535,6 +554,82 @@ async def list_control_usage( for control_id, agent_names in usage_names.items() } + async def list_control_attachments( + self, + control_ids: Sequence[int], + *, + namespace_key: str, + target_type: str | None = None, + ) -> dict[int, ControlAttachmentSet]: + """Return direct policy, direct agent, and target attachments for controls.""" + if not control_ids: + return {} + + unique_control_ids = list(dict.fromkeys(control_ids)) + policy_ids_by_control: dict[int, set[int]] = { + control_id: set() for control_id in unique_control_ids + } + agent_names_by_control: dict[int, set[str]] = { + control_id: set() for control_id in unique_control_ids + } + targets_by_control: dict[int, list[ControlTargetAttachment]] = { + control_id: [] for control_id in unique_control_ids + } + + policy_result = await self._db.execute( + select(policy_controls.c.control_id, policy_controls.c.policy_id).where( + policy_controls.c.namespace_key == namespace_key, + policy_controls.c.control_id.in_(unique_control_ids), + ) + ) + for control_id, policy_id in policy_result.all(): + policy_ids_by_control[cast(int, control_id)].add(cast(int, policy_id)) + + agent_result = await self._db.execute( + select(agent_controls.c.control_id, agent_controls.c.agent_name).where( + agent_controls.c.namespace_key == namespace_key, + agent_controls.c.control_id.in_(unique_control_ids), + ) + ) + for control_id, agent_name in agent_result.all(): + agent_names_by_control[cast(int, control_id)].add(cast(str, agent_name)) + + target_query = ( + select( + ControlBinding.control_id, + ControlBinding.id, + ControlBinding.target_type, + ControlBinding.target_id, + ControlBinding.enabled, + ) + .where( + ControlBinding.namespace_key == namespace_key, + ControlBinding.control_id.in_(unique_control_ids), + ) + .order_by(ControlBinding.id.desc()) + ) + if target_type is not None: + target_query = target_query.where(ControlBinding.target_type == target_type) + target_result = await self._db.execute(target_query) + for control_id, binding_id, binding_target_type, target_id, enabled in target_result.all(): + targets_by_control[cast(int, control_id)].append( + ControlTargetAttachment( + binding_id=cast(int, binding_id), + target_type=cast(str, binding_target_type), + target_id=cast(str, target_id), + enabled=cast(bool, enabled), + ) + ) + + return { + control_id: ControlAttachmentSet( + policy_ids=sorted(policy_ids_by_control[control_id]), + agent_names=sorted(agent_names_by_control[control_id]), + targets=targets_by_control[control_id], + ) + for control_id in unique_control_ids + } + async def list_active_control_counts_by_agent( self, agent_names: Sequence[str], diff --git a/server/tests/test_controls_additional.py b/server/tests/test_controls_additional.py index dfbb15f5..f1750cb0 100644 --- a/server/tests/test_controls_additional.py +++ b/server/tests/test_controls_additional.py @@ -696,19 +696,110 @@ def test_delete_control_force_dissociates_direct_agent_links(client: TestClient) assert list_resp.json()["pagination"]["total"] == 0 -def _create_target_binding(client: TestClient, *, control_id: int) -> int: +def _create_target_binding( + client: TestClient, + *, + control_id: int, + target_type: str = "env", + target_id: str = "prod", + enabled: bool = True, +) -> int: resp = client.put( "/api/v1/control-bindings", json={ - "target_type": "env", - "target_id": "prod", + "target_type": target_type, + "target_id": target_id, "control_id": control_id, + "enabled": enabled, }, ) assert resp.status_code == 200, resp.text return int(resp.json()["binding_id"]) +def test_list_controls_returns_null_attachments_by_default( + client: TestClient, +) -> None: + # Given: a control with no requested attachment expansion + control_id, control_name = _create_control(client, name=f"Attachments-{uuid.uuid4()}") + _set_control_data(client, control_id, deepcopy(VALID_CONTROL_PAYLOAD)) + + # When: listing controls without include_attachments + resp = client.get("/api/v1/controls", params={"name": control_name}) + + # Then: the response keeps the attachment expansion disabled by default + assert resp.status_code == 200, resp.text + controls = resp.json()["controls"] + assert len(controls) == 1 + assert controls[0]["id"] == control_id + assert controls[0]["attachments"] is None + + +def test_list_controls_expands_policy_agent_and_target_attachments( + client: TestClient, +) -> None: + # Given: a control attached to a policy, an agent, and two target types + control_id, control_name = _create_control(client, name=f"Attachments-{uuid.uuid4()}") + _set_control_data(client, control_id, deepcopy(VALID_CONTROL_PAYLOAD)) + + policy_resp = client.put("/api/v1/policies", json={"name": f"pol-{uuid.uuid4()}"}) + assert policy_resp.status_code == 200 + policy_id = policy_resp.json()["policy_id"] + policy_assoc_resp = client.post(f"/api/v1/policies/{policy_id}/controls/{control_id}") + assert policy_assoc_resp.status_code == 200 + + agent_name = f"agent-{uuid.uuid4().hex[:12]}" + init_resp = client.post( + "/api/v1/agents/initAgent", + json={"agent": {"agent_name": agent_name}, "steps": []}, + ) + assert init_resp.status_code == 200 + agent_assoc_resp = client.post(f"/api/v1/agents/{agent_name}/controls/{control_id}") + assert agent_assoc_resp.status_code == 200 + + log_stream_binding_id = _create_target_binding( + client, + control_id=control_id, + target_type="log_stream", + target_id="ls-prod", + enabled=False, + ) + _create_target_binding( + client, + control_id=control_id, + target_type="env", + target_id="prod", + ) + + # When: listing controls with attachment expansion filtered to log streams + resp = client.get( + "/api/v1/controls", + params={ + "name": control_name, + "include_attachments": "true", + "attachment_target_type": "log_stream", + }, + ) + + # Then: direct policy/agent attachments and filtered target bindings are returned + assert resp.status_code == 200, resp.text + controls = resp.json()["controls"] + assert len(controls) == 1 + attachments = controls[0]["attachments"] + assert attachments == { + "agents": [{"agent_name": agent_name}], + "policies": [{"policy_id": policy_id}], + "targets": [ + { + "binding_id": log_stream_binding_id, + "target_type": "log_stream", + "target_id": "ls-prod", + "enabled": False, + } + ], + } + + def test_delete_control_blocks_when_target_binding_exists( client: TestClient, ) -> None: