From 2e9fe2042fe412b6bfd8a5f83552c94e52410a9b Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Wed, 20 May 2026 00:04:11 +0530 Subject: [PATCH] feat(server): add control clone-and-bind endpoint --- models/src/agent_control_models/__init__.py | 6 + models/src/agent_control_models/server.py | 51 ++++- sdks/python/src/agent_control/__init__.py | 47 ++++ sdks/python/src/agent_control/controls.py | 45 ++++ sdks/python/tests/test_controls_api.py | 139 ++++++++++++ .../overlays/method-names.overlay.yaml | 5 + .../funcs/controls-clone-and-bind-control.ts | 188 ++++++++++++++++ .../src/generated/funcs/controls-list.ts | 2 + .../models/clone-and-bind-control-request.ts | 55 +++++ .../models/clone-and-bind-control-response.ts | 62 +++++ .../models/clone-and-bind-target-binding.ts | 57 +++++ .../src/generated/models/control-summary.ts | 6 + .../generated/models/get-control-response.ts | 29 ++- .../models/get-control-version-response.ts | 2 +- sdks/typescript/src/generated/models/index.ts | 3 + ...controls-control-id-clone-and-bind-post.ts | 46 ++++ .../src/generated/models/operations/index.ts | 1 + .../list-controls-api-v1-controls-get.ts | 6 + sdks/typescript/src/generated/sdk/controls.ts | 20 ++ .../e2b7f4a9c6d1_control_clone_lineage.py | 48 ++++ .../endpoints/controls.py | 157 ++++++++++++- server/src/agent_control_server/models.py | 4 + .../agent_control_server/services/controls.py | 25 ++- server/tests/test_control_versions.py | 1 + server/tests/test_controls_additional.py | 212 +++++++++++++++++- 25 files changed, 1199 insertions(+), 18 deletions(-) create mode 100644 sdks/typescript/src/generated/funcs/controls-clone-and-bind-control.ts create mode 100644 sdks/typescript/src/generated/models/clone-and-bind-control-request.ts create mode 100644 sdks/typescript/src/generated/models/clone-and-bind-control-response.ts create mode 100644 sdks/typescript/src/generated/models/clone-and-bind-target-binding.ts create mode 100644 sdks/typescript/src/generated/models/operations/clone-and-bind-control-api-v1-controls-control-id-clone-and-bind-post.ts create mode 100644 server/alembic/versions/e2b7f4a9c6d1_control_clone_lineage.py diff --git a/models/src/agent_control_models/__init__.py b/models/src/agent_control_models/__init__.py index 148cdd7a..5a978d95 100644 --- a/models/src/agent_control_models/__init__.py +++ b/models/src/agent_control_models/__init__.py @@ -83,6 +83,9 @@ from .server import ( AgentRef, AgentSummary, + CloneAndBindControlRequest, + CloneAndBindControlResponse, + CloneAndBindTargetBinding, ConflictMode, ControlSummary, ControlVersionSummary, @@ -176,6 +179,9 @@ # Server models "AgentRef", "AgentSummary", + "CloneAndBindControlRequest", + "CloneAndBindControlResponse", + "CloneAndBindTargetBinding", "ConflictMode", "ControlVersionSummary", "ControlSummary", diff --git a/models/src/agent_control_models/server.py b/models/src/agent_control_models/server.py index 3529a5d4..876fabff 100644 --- a/models/src/agent_control_models/server.py +++ b/models/src/agent_control_models/server.py @@ -347,6 +347,9 @@ class GetControlResponse(BaseModel): id: int = Field(..., description="Control ID") name: str = Field(..., description="Control name") + cloned_from_control_id: int | None = Field( + None, description="Source control ID when this control is a clone." + ) data: ControlDefinition | UnrenderedTemplateControl = Field( description=( "Control configuration data. A ControlDefinition for raw/rendered " @@ -519,6 +522,9 @@ class ControlSummary(BaseModel): id: int = Field(..., description="Control ID") name: str = Field(..., description="Control name") + cloned_from_control_id: int | None = Field( + None, description="Source control ID when this control is a clone." + ) description: str | None = Field(None, description="Control description") enabled: bool = Field(True, description="Whether control is enabled") execution: str | None = Field(None, description="'server' or 'sdk'") @@ -580,7 +586,7 @@ class GetControlVersionResponse(BaseModel): ..., description=( "Raw persisted snapshot of the control state at this version, including " - "metadata such as name, deleted_at, and cloned_control_id." + "metadata such as name, deleted_at, and cloned_from_control_id." ), ) @@ -635,6 +641,48 @@ class PatchControlResponse(BaseModel): ] +class CloneAndBindTargetBinding(BaseModel): + """Target binding to create for a cloned control.""" + + target_type: ControlBindingTargetField = Field( + ..., + description="Opaque attachment kind (caller-defined; e.g. 'environment', 'session').", + ) + target_id: ControlBindingTargetField = Field( + ..., description="Opaque external identifier within the target_type." + ) + enabled: bool = Field( + default=True, + description="Whether the created binding is active.", + ) + + +class CloneAndBindControlRequest(BaseModel): + """Request to clone a control and attach the clone to one target.""" + + model_config = ConfigDict(extra="forbid") + + name: SlugName | None = Field( + None, + description=( + "Optional unique name for the cloned control. If omitted, the server " + "generates a name from the source control name." + ), + ) + target_binding: CloneAndBindTargetBinding = Field( + ..., description="Target binding to create for the cloned control." + ) + + +class CloneAndBindControlResponse(BaseModel): + """Response from cloning and binding a control.""" + + control_id: int = Field(..., description="Identifier of the cloned control.") + name: str = Field(..., description="Name of the cloned control.") + cloned_from_control_id: int = Field(..., description="Source control ID.") + binding_id: int = Field(..., description="Identifier of the created binding.") + + class CreateControlBindingRequest(BaseModel): """Request to attach a control to an opaque external target.""" @@ -759,4 +807,3 @@ class DeleteControlBindingByKeyResponse(BaseModel): "binding existed." ), ) - diff --git a/sdks/python/src/agent_control/__init__.py b/sdks/python/src/agent_control/__init__.py index 0a1dc1ea..a1af5f0c 100644 --- a/sdks/python/src/agent_control/__init__.py +++ b/sdks/python/src/agent_control/__init__.py @@ -1019,6 +1019,7 @@ async def list_controls( name: str | None = None, enabled: bool | None = None, template_backed: bool | None = None, + cloned: bool | None = None, step_type: str | None = None, stage: Literal["pre", "post"] | None = None, execution: Literal["server", "sdk"] | None = None, @@ -1035,6 +1036,7 @@ async def list_controls( name: Optional filter by name (partial, case-insensitive) enabled: Optional filter by enabled status template_backed: Optional filter by whether the control is template-backed + cloned: Optional filter by whether the control was cloned from another control step_type: Optional filter by step type (built-ins: 'tool', 'llm') stage: Optional filter by stage ('pre' or 'post') execution: Optional filter by execution ('server' or 'sdk') @@ -1079,6 +1081,7 @@ async def main(): name=name, enabled=enabled, template_backed=template_backed, + cloned=cloned, step_type=step_type, stage=stage, execution=execution, @@ -1147,6 +1150,49 @@ async def main(): return await controls.create_control(client, name, data=data) +async def clone_and_bind_control( + control_id: int, + *, + target_type: str, + target_id: str, + name: str | None = None, + enabled: bool = True, + server_url: str | None = None, + api_key: str | None = None, + api_key_header: str | None = None, +) -> dict[str, Any]: + """ + Clone an existing control and bind the clone to a target. + + Args: + control_id: Source control ID to clone + target_type: Opaque attachment kind + target_id: Opaque external target identifier + name: Optional unique name for the cloned control + enabled: Whether the created binding is active + server_url: Optional server URL (defaults to AGENT_CONTROL_URL env var) + api_key: Optional API key for authentication (defaults to AGENT_CONTROL_API_KEY env var) + + Returns: + Dictionary containing control_id, name, cloned_from_control_id, and binding_id. + """ + _final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000' + + async with _ad_hoc_client( + server_url=_final_server_url, + api_key=api_key, + api_key_header=api_key_header, + ) as client: + return await controls.clone_and_bind_control( + client, + control_id, + target_type=target_type, + target_id=target_id, + name=name, + enabled=enabled, + ) + + async def validate_control_data( data: dict[str, Any] | ControlDefinition | TemplateControlInput, server_url: str | None = None, @@ -1502,6 +1548,7 @@ async def main(): "add_agent_control", "remove_agent_control", # Control management + "clone_and_bind_control", "create_control", "list_controls", "get_control", diff --git a/sdks/python/src/agent_control/controls.py b/sdks/python/src/agent_control/controls.py index 99fc6265..afd62d5d 100644 --- a/sdks/python/src/agent_control/controls.py +++ b/sdks/python/src/agent_control/controls.py @@ -20,6 +20,7 @@ async def list_controls( name: str | None = None, enabled: bool | None = None, template_backed: bool | None = None, + cloned: bool | None = None, step_type: str | None = None, stage: Literal["pre", "post"] | None = None, execution: Literal["server", "sdk"] | None = None, @@ -37,6 +38,7 @@ async def list_controls( name: Optional filter by name (partial, case-insensitive match) enabled: Optional filter by enabled status template_backed: Optional filter by whether the control is template-backed + cloned: Optional filter by whether the control was cloned from another control step_type: Optional filter by step type (built-ins: 'tool', 'llm') stage: Optional filter by stage ('pre' or 'post') execution: Optional filter by execution ('server' or 'sdk') @@ -78,6 +80,8 @@ async def list_controls( params["enabled"] = enabled if template_backed is not None: params["template_backed"] = template_backed + if cloned is not None: + params["cloned"] = cloned if step_type is not None: params["step_type"] = step_type if stage is not None: @@ -243,6 +247,47 @@ async def create_control( return result +async def clone_and_bind_control( + client: AgentControlClient, + control_id: int, + *, + target_type: str, + target_id: str, + name: str | None = None, + enabled: bool = True, +) -> dict[str, Any]: + """ + Clone an existing control and bind the clone to a target in one API call. + + Args: + client: AgentControlClient instance + control_id: Source control ID to clone + target_type: Opaque attachment kind + target_id: Opaque external target identifier + name: Optional unique name for the cloned control + enabled: Whether the created binding is active + + Returns: + Dictionary containing control_id, name, cloned_from_control_id, and binding_id. + """ + payload: dict[str, Any] = { + "target_binding": { + "target_type": target_type, + "target_id": target_id, + "enabled": enabled, + } + } + if name is not None: + payload["name"] = name + + response = await client.http_client.post( + f"/api/v1/controls/{control_id}/clone-and-bind", + json=payload, + ) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + + async def set_control_data( client: AgentControlClient, control_id: int, diff --git a/sdks/python/tests/test_controls_api.py b/sdks/python/tests/test_controls_api.py index ed505451..ac169a25 100644 --- a/sdks/python/tests/test_controls_api.py +++ b/sdks/python/tests/test_controls_api.py @@ -2,7 +2,10 @@ from __future__ import annotations +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, Mock import pytest @@ -29,6 +32,24 @@ async def test_list_controls_passes_template_backed_filter() -> None: ) +@pytest.mark.asyncio +async def test_list_controls_passes_cloned_filter() -> None: + # Given: an SDK client stub and a cloned list filter + response = Mock() + response.raise_for_status = Mock() + response.json = Mock(return_value={"controls": [], "pagination": {}}) + client = SimpleNamespace(http_client=SimpleNamespace(get=AsyncMock(return_value=response))) + + # When: listing controls through the SDK wrapper + await agent_control.controls.list_controls(client, cloned=False) + + # Then: the filter is forwarded to the API request + client.http_client.get.assert_awaited_once_with( + "/api/v1/controls", + params={"limit": 20, "cloned": False}, + ) + + @pytest.mark.asyncio async def test_create_control_accepts_template_control_input() -> None: # Given: an SDK client stub and template-backed control input @@ -71,6 +92,124 @@ async def test_create_control_accepts_template_control_input() -> None: assert kwargs["json"]["data"]["template_values"]["pattern"] == "hello" +@pytest.mark.asyncio +async def test_clone_and_bind_control_calls_clone_endpoint() -> None: + # Given: an SDK client stub for clone-and-bind + response = Mock() + response.raise_for_status = Mock() + response.json = Mock( + return_value={ + "control_id": 456, + "name": "clone-name", + "cloned_from_control_id": 123, + "binding_id": 789, + } + ) + client = SimpleNamespace(http_client=SimpleNamespace(post=AsyncMock(return_value=response))) + + # When: cloning and binding through the SDK wrapper + result = await agent_control.controls.clone_and_bind_control( + client, + 123, + target_type="log_stream", + target_id="logstream-123", + name="clone-name", + enabled=False, + ) + + # Then: the SDK posts the expected payload + assert result["control_id"] == 456 + client.http_client.post.assert_awaited_once_with( + "/api/v1/controls/123/clone-and-bind", + json={ + "target_binding": { + "target_type": "log_stream", + "target_id": "logstream-123", + "enabled": False, + }, + "name": "clone-name", + }, + ) + + +@pytest.mark.asyncio +async def test_top_level_list_controls_passes_cloned_filter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + stub_client = object() + + @asynccontextmanager + async def fake_ad_hoc_client(**kwargs: Any) -> AsyncGenerator[object, None]: + captured["client_kwargs"] = kwargs + yield stub_client + + async def fake_list_controls(client: object, **kwargs: Any) -> dict[str, Any]: + captured["client"] = client + captured["list_kwargs"] = kwargs + return {"controls": [], "pagination": {}} + + monkeypatch.setattr(agent_control, "_ad_hoc_client", fake_ad_hoc_client) + monkeypatch.setattr(agent_control.controls, "list_controls", fake_list_controls) + + result = await agent_control.list_controls(cloned=False, server_url="http://server") + + assert result["controls"] == [] + assert captured["client"] is stub_client + assert captured["client_kwargs"]["server_url"] == "http://server" + assert captured["list_kwargs"]["cloned"] is False + + +@pytest.mark.asyncio +async def test_top_level_clone_and_bind_control_uses_ad_hoc_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + stub_client = object() + + @asynccontextmanager + async def fake_ad_hoc_client(**kwargs: Any) -> AsyncGenerator[object, None]: + captured["client_kwargs"] = kwargs + yield stub_client + + async def fake_clone_and_bind_control( + client: object, + control_id: int, + **kwargs: Any, + ) -> dict[str, Any]: + captured["client"] = client + captured["control_id"] = control_id + captured["clone_kwargs"] = kwargs + return {"control_id": 456, "binding_id": 789} + + monkeypatch.setattr(agent_control, "_ad_hoc_client", fake_ad_hoc_client) + monkeypatch.setattr( + agent_control.controls, + "clone_and_bind_control", + fake_clone_and_bind_control, + ) + + result = await agent_control.clone_and_bind_control( + 123, + target_type="log_stream", + target_id="logstream-123", + name="clone-name", + enabled=False, + server_url="http://server", + ) + + assert result["binding_id"] == 789 + assert captured["client"] is stub_client + assert captured["client_kwargs"]["server_url"] == "http://server" + assert captured["control_id"] == 123 + assert captured["clone_kwargs"] == { + "target_type": "log_stream", + "target_id": "logstream-123", + "name": "clone-name", + "enabled": False, + } + + @pytest.mark.asyncio async def test_list_control_versions_forwards_cursor_and_limit() -> None: # Given: an SDK client stub and version-history pagination params diff --git a/sdks/typescript/overlays/method-names.overlay.yaml b/sdks/typescript/overlays/method-names.overlay.yaml index ce36006c..fc2e3429 100644 --- a/sdks/typescript/overlays/method-names.overlay.yaml +++ b/sdks/typescript/overlays/method-names.overlay.yaml @@ -180,6 +180,11 @@ actions: x-speakeasy-group: controls x-speakeasy-name-override: delete + - target: $["paths"]["/api/v1/controls/{control_id}/clone-and-bind"]["post"] + update: + x-speakeasy-group: controls + x-speakeasy-name-override: cloneAndBindControl + - target: $["paths"]["/api/v1/controls/{control_id}/data"]["get"] update: x-speakeasy-group: controls diff --git a/sdks/typescript/src/generated/funcs/controls-clone-and-bind-control.ts b/sdks/typescript/src/generated/funcs/controls-clone-and-bind-control.ts new file mode 100644 index 00000000..559c3848 --- /dev/null +++ b/sdks/typescript/src/generated/funcs/controls-clone-and-bind-control.ts @@ -0,0 +1,188 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AgentControlSDKCore } from "../core.js"; +import { encodeJSON, encodeSimple } from "../lib/encodings.js"; +import * as M from "../lib/matchers.js"; +import { compactMap } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js"; +import { pathToFunc } from "../lib/url.js"; +import { AgentControlSDKError } from "../models/errors/agent-control-sdk-error.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/errors/http-client-errors.js"; +import * as errors from "../models/errors/index.js"; +import { ResponseValidationError } from "../models/errors/response-validation-error.js"; +import { SDKValidationError } from "../models/errors/sdk-validation-error.js"; +import * as models from "../models/index.js"; +import * as operations from "../models/operations/index.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Clone a control and bind the clone to a target + * + * @remarks + * Clone an active control and attach the clone to an opaque target. + */ +export function controlsCloneAndBindControl( + client: AgentControlSDKCore, + request: + operations.CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest, + options?: RequestOptions, +): APIPromise< + Result< + models.CloneAndBindControlResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: + operations.CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest, + options?: RequestOptions, +): Promise< + [ + Result< + models.CloneAndBindControlResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations + .CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest$outboundSchema, + value, + ), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = encodeJSON("body", payload.body, { explode: true }); + + const pathParams = { + control_id: encodeSimple("control_id", payload.control_id, { + explode: false, + charEncoding: "percent", + }), + }; + + const path = pathToFunc("/api/v1/controls/{control_id}/clone-and-bind")( + pathParams, + ); + + const headers = new Headers(compactMap({ + "Content-Type": "application/json", + Accept: "application/json", + })); + + const secConfig = await extractSecurity(client._options.apiKeyHeader); + const securityInput = secConfig == null ? {} : { apiKeyHeader: secConfig }; + const requestSecurity = resolveGlobalSecurity(securityInput); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? "", + operationID: + "clone_and_bind_control_api_v1_controls__control_id__clone_and_bind_post", + oAuth2Scopes: null, + + resolvedSecurity: requestSecurity, + + securitySource: client._options.apiKeyHeader, + retryConfig: options?.retries + || client._options.retryConfig + || { strategy: "none" }, + retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"], + }; + + const requestRes = client._createRequest(context, { + security: requestSecurity, + method: "POST", + baseURL: options?.serverURL, + path: path, + headers: headers, + body: body, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, options); + if (!requestRes.ok) { + return [requestRes, { status: "invalid" }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + errorCodes: ["422", "4XX", "5XX"], + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: "request-error", request: req }]; + } + const response = doResult.value; + + const responseFields = { + HttpMeta: { Response: response, Request: req }, + }; + + const [result] = await M.match< + models.CloneAndBindControlResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.CloneAndBindControlResponse$inboundSchema), + M.jsonErr(422, errors.HTTPValidationError$inboundSchema), + M.fail("4XX"), + M.fail("5XX"), + )(response, req, { extraFields: responseFields }); + if (!result.ok) { + return [result, { status: "complete", request: req, response }]; + } + + return [result, { status: "complete", request: req, response }]; +} diff --git a/sdks/typescript/src/generated/funcs/controls-list.ts b/sdks/typescript/src/generated/funcs/controls-list.ts index 2fd69073..8e472252 100644 --- a/sdks/typescript/src/generated/funcs/controls-list.ts +++ b/sdks/typescript/src/generated/funcs/controls-list.ts @@ -41,6 +41,7 @@ import { Result } from "../types/fp.js"; * name: Optional filter by name (partial, case-insensitive match) * enabled: Optional filter by enabled status * template_backed: Optional filter by whether the control is template-backed + * cloned: Optional filter by whether the control was cloned from another control * step_type: Optional filter by step type (built-ins: 'tool', 'llm') * stage: Optional filter by stage ('pre' or 'post') * execution: Optional filter by execution ('server' or 'sdk') @@ -119,6 +120,7 @@ async function $do( const path = pathToFunc("/api/v1/controls")(); const query = encodeFormQuery({ + "cloned": payload?.cloned, "cursor": payload?.cursor, "enabled": payload?.enabled, "execution": payload?.execution, diff --git a/sdks/typescript/src/generated/models/clone-and-bind-control-request.ts b/sdks/typescript/src/generated/models/clone-and-bind-control-request.ts new file mode 100644 index 00000000..90d4a54b --- /dev/null +++ b/sdks/typescript/src/generated/models/clone-and-bind-control-request.ts @@ -0,0 +1,55 @@ +/* + * 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 { + CloneAndBindTargetBinding, + CloneAndBindTargetBinding$Outbound, + CloneAndBindTargetBinding$outboundSchema, +} from "./clone-and-bind-target-binding.js"; + +/** + * Request to clone a control and attach the clone to one target. + */ +export type CloneAndBindControlRequest = { + /** + * Optional unique name for the cloned control. If omitted, the server generates a name from the source control name. + */ + name?: string | null | undefined; + /** + * Target binding to create for a cloned control. + */ + targetBinding: CloneAndBindTargetBinding; +}; + +/** @internal */ +export type CloneAndBindControlRequest$Outbound = { + name?: string | null | undefined; + target_binding: CloneAndBindTargetBinding$Outbound; +}; + +/** @internal */ +export const CloneAndBindControlRequest$outboundSchema: z.ZodMiniType< + CloneAndBindControlRequest$Outbound, + CloneAndBindControlRequest +> = z.pipe( + z.object({ + name: z.optional(z.nullable(z.string())), + targetBinding: CloneAndBindTargetBinding$outboundSchema, + }), + z.transform((v) => { + return remap$(v, { + targetBinding: "target_binding", + }); + }), +); + +export function cloneAndBindControlRequestToJSON( + cloneAndBindControlRequest: CloneAndBindControlRequest, +): string { + return JSON.stringify( + CloneAndBindControlRequest$outboundSchema.parse(cloneAndBindControlRequest), + ); +} diff --git a/sdks/typescript/src/generated/models/clone-and-bind-control-response.ts b/sdks/typescript/src/generated/models/clone-and-bind-control-response.ts new file mode 100644 index 00000000..5965411d --- /dev/null +++ b/sdks/typescript/src/generated/models/clone-and-bind-control-response.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"; + +/** + * Response from cloning and binding a control. + */ +export type CloneAndBindControlResponse = { + /** + * Identifier of the created binding. + */ + bindingId: number; + /** + * Source control ID. + */ + clonedFromControlId: number; + /** + * Identifier of the cloned control. + */ + controlId: number; + /** + * Name of the cloned control. + */ + name: string; +}; + +/** @internal */ +export const CloneAndBindControlResponse$inboundSchema: z.ZodMiniType< + CloneAndBindControlResponse, + unknown +> = z.pipe( + z.object({ + binding_id: types.number(), + cloned_from_control_id: types.number(), + control_id: types.number(), + name: types.string(), + }), + z.transform((v) => { + return remap$(v, { + "binding_id": "bindingId", + "cloned_from_control_id": "clonedFromControlId", + "control_id": "controlId", + }); + }), +); + +export function cloneAndBindControlResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CloneAndBindControlResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CloneAndBindControlResponse' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/clone-and-bind-target-binding.ts b/sdks/typescript/src/generated/models/clone-and-bind-target-binding.ts new file mode 100644 index 00000000..26fb57fc --- /dev/null +++ b/sdks/typescript/src/generated/models/clone-and-bind-target-binding.ts @@ -0,0 +1,57 @@ +/* + * 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"; + +/** + * Target binding to create for a cloned control. + */ +export type CloneAndBindTargetBinding = { + /** + * Whether the created binding is active. + */ + enabled?: boolean | undefined; + /** + * Opaque external identifier within the target_type. + */ + targetId: string; + /** + * Opaque attachment kind (caller-defined; e.g. 'environment', 'session'). + */ + targetType: string; +}; + +/** @internal */ +export type CloneAndBindTargetBinding$Outbound = { + enabled: boolean; + target_id: string; + target_type: string; +}; + +/** @internal */ +export const CloneAndBindTargetBinding$outboundSchema: z.ZodMiniType< + CloneAndBindTargetBinding$Outbound, + CloneAndBindTargetBinding +> = z.pipe( + z.object({ + enabled: z._default(z.boolean(), true), + targetId: z.string(), + targetType: z.string(), + }), + z.transform((v) => { + return remap$(v, { + targetId: "target_id", + targetType: "target_type", + }); + }), +); + +export function cloneAndBindTargetBindingToJSON( + cloneAndBindTargetBinding: CloneAndBindTargetBinding, +): string { + return JSON.stringify( + CloneAndBindTargetBinding$outboundSchema.parse(cloneAndBindTargetBinding), + ); +} diff --git a/sdks/typescript/src/generated/models/control-summary.ts b/sdks/typescript/src/generated/models/control-summary.ts index 4c0b0fb3..7f74c19c 100644 --- a/sdks/typescript/src/generated/models/control-summary.ts +++ b/sdks/typescript/src/generated/models/control-summary.ts @@ -14,6 +14,10 @@ import { SDKValidationError } from "./errors/sdk-validation-error.js"; * Summary of a control for list responses. */ export type ControlSummary = { + /** + * Source control ID when this control is a clone. + */ + clonedFromControlId?: number | null | undefined; /** * Control description */ @@ -70,6 +74,7 @@ export const ControlSummary$inboundSchema: z.ZodMiniType< unknown > = z.pipe( z.object({ + cloned_from_control_id: z.optional(z.nullable(types.number())), description: z.optional(z.nullable(types.string())), enabled: z._default(types.boolean(), true), execution: z.optional(z.nullable(types.string())), @@ -85,6 +90,7 @@ export const ControlSummary$inboundSchema: z.ZodMiniType< }), z.transform((v) => { return remap$(v, { + "cloned_from_control_id": "clonedFromControlId", "step_types": "stepTypes", "template_backed": "templateBacked", "template_rendered": "templateRendered", diff --git a/sdks/typescript/src/generated/models/get-control-response.ts b/sdks/typescript/src/generated/models/get-control-response.ts index 8e65e936..58bcfcc9 100644 --- a/sdks/typescript/src/generated/models/get-control-response.ts +++ b/sdks/typescript/src/generated/models/get-control-response.ts @@ -3,6 +3,7 @@ */ 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"; @@ -28,6 +29,10 @@ export type GetControlResponseData = * Response containing control details. */ export type GetControlResponse = { + /** + * Source control ID when this control is a clone. + */ + clonedFromControlId?: number | null | undefined; /** * Control configuration data. A ControlDefinition for raw/rendered controls or an UnrenderedTemplateControl for unrendered templates. */ @@ -65,14 +70,22 @@ export function getControlResponseDataFromJSON( export const GetControlResponse$inboundSchema: z.ZodMiniType< GetControlResponse, unknown -> = z.object({ - data: smartUnion([ - ControlDefinitionOutput$inboundSchema, - UnrenderedTemplateControl$inboundSchema, - ]), - id: types.number(), - name: types.string(), -}); +> = z.pipe( + z.object({ + cloned_from_control_id: z.optional(z.nullable(types.number())), + data: smartUnion([ + ControlDefinitionOutput$inboundSchema, + UnrenderedTemplateControl$inboundSchema, + ]), + id: types.number(), + name: types.string(), + }), + z.transform((v) => { + return remap$(v, { + "cloned_from_control_id": "clonedFromControlId", + }); + }), +); export function getControlResponseFromJSON( jsonString: string, diff --git a/sdks/typescript/src/generated/models/get-control-version-response.ts b/sdks/typescript/src/generated/models/get-control-version-response.ts index d502371c..1c9871b6 100644 --- a/sdks/typescript/src/generated/models/get-control-version-response.ts +++ b/sdks/typescript/src/generated/models/get-control-version-response.ts @@ -26,7 +26,7 @@ export type GetControlVersionResponse = { */ note?: string | null | undefined; /** - * Raw persisted snapshot of the control state at this version, including metadata such as name, deleted_at, and cloned_control_id. + * Raw persisted snapshot of the control state at this version, including metadata such as name, deleted_at, and cloned_from_control_id. */ snapshot: { [k: string]: any }; /** diff --git a/sdks/typescript/src/generated/models/index.ts b/sdks/typescript/src/generated/models/index.ts index 595a9501..6fe1e875 100644 --- a/sdks/typescript/src/generated/models/index.ts +++ b/sdks/typescript/src/generated/models/index.ts @@ -12,6 +12,9 @@ export * from "./auth-mode.js"; export * from "./batch-events-request.js"; export * from "./batch-events-response.js"; export * from "./boolean-template-parameter.js"; +export * from "./clone-and-bind-control-request.js"; +export * from "./clone-and-bind-control-response.js"; +export * from "./clone-and-bind-target-binding.js"; export * from "./condition-node-input.js"; export * from "./condition-node-output.js"; export * from "./config-response.js"; diff --git a/sdks/typescript/src/generated/models/operations/clone-and-bind-control-api-v1-controls-control-id-clone-and-bind-post.ts b/sdks/typescript/src/generated/models/operations/clone-and-bind-control-api-v1-controls-control-id-clone-and-bind-post.ts new file mode 100644 index 00000000..888f3adc --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/clone-and-bind-control-api-v1-controls-control-id-clone-and-bind-post.ts @@ -0,0 +1,46 @@ +/* + * 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 * as models from "../index.js"; + +export type CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest = { + controlId: number; + body: models.CloneAndBindControlRequest; +}; + +/** @internal */ +export type CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest$Outbound = + { + control_id: number; + body: models.CloneAndBindControlRequest$Outbound; + }; + +/** @internal */ +export const CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest$outboundSchema: + z.ZodMiniType< + CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest$Outbound, + CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest + > = z.pipe( + z.object({ + controlId: z.int(), + body: models.CloneAndBindControlRequest$outboundSchema, + }), + z.transform((v) => { + return remap$(v, { + controlId: "control_id", + }); + }), + ); + +export function cloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequestToJSON( + cloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest: + CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest, +): string { + return JSON.stringify( + CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest$outboundSchema + .parse(cloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest), + ); +} diff --git a/sdks/typescript/src/generated/models/operations/index.ts b/sdks/typescript/src/generated/models/operations/index.ts index 819659f8..b031df32 100644 --- a/sdks/typescript/src/generated/models/operations/index.ts +++ b/sdks/typescript/src/generated/models/operations/index.ts @@ -5,6 +5,7 @@ export * from "./add-agent-control-api-v1-agents-agent-name-controls-control-id-post.js"; export * from "./add-agent-policy-api-v1-agents-agent-name-policies-policy-id-post.js"; export * from "./add-control-to-policy-api-v1-policies-policy-id-controls-control-id-post.js"; +export * from "./clone-and-bind-control-api-v1-controls-control-id-clone-and-bind-post.js"; export * from "./delete-agent-policy-api-v1-agents-agent-name-policy-delete.js"; export * from "./delete-control-api-v1-controls-control-id-delete.js"; export * from "./delete-control-binding-api-v1-control-bindings-binding-id-delete.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..ae66d86c 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 @@ -23,6 +23,10 @@ export type ListControlsApiV1ControlsGetRequest = { * Filter by whether the control is template-backed */ templateBacked?: boolean | null | undefined; + /** + * Filter by whether the control was cloned from another control + */ + cloned?: boolean | null | undefined; /** * Filter by step type (built-ins: 'tool', 'llm') */ @@ -48,6 +52,7 @@ export type ListControlsApiV1ControlsGetRequest$Outbound = { name?: string | null | undefined; enabled?: boolean | null | undefined; template_backed?: boolean | null | undefined; + cloned?: boolean | null | undefined; step_type?: string | null | undefined; stage?: string | null | undefined; execution?: string | null | undefined; @@ -65,6 +70,7 @@ export const ListControlsApiV1ControlsGetRequest$outboundSchema: z.ZodMiniType< name: z.optional(z.nullable(z.string())), enabled: z.optional(z.nullable(z.boolean())), templateBacked: z.optional(z.nullable(z.boolean())), + cloned: z.optional(z.nullable(z.boolean())), stepType: z.optional(z.nullable(z.string())), stage: z.optional(z.nullable(z.string())), execution: z.optional(z.nullable(z.string())), diff --git a/sdks/typescript/src/generated/sdk/controls.ts b/sdks/typescript/src/generated/sdk/controls.ts index ed3cf8db..dd8e2c44 100644 --- a/sdks/typescript/src/generated/sdk/controls.ts +++ b/sdks/typescript/src/generated/sdk/controls.ts @@ -2,6 +2,7 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ +import { controlsCloneAndBindControl } from "../funcs/controls-clone-and-bind-control.js"; import { controlsCreate } from "../funcs/controls-create.js"; import { controlsDelete } from "../funcs/controls-delete.js"; import { controlsGetData } from "../funcs/controls-get-data.js"; @@ -51,6 +52,7 @@ export class Controls extends ClientSDK { * name: Optional filter by name (partial, case-insensitive match) * enabled: Optional filter by enabled status * template_backed: Optional filter by whether the control is template-backed + * cloned: Optional filter by whether the control was cloned from another control * step_type: Optional filter by step type (built-ins: 'tool', 'llm') * stage: Optional filter by stage ('pre' or 'post') * execution: Optional filter by execution ('server' or 'sdk') @@ -239,6 +241,24 @@ export class Controls extends ClientSDK { )); } + /** + * Clone a control and bind the clone to a target + * + * @remarks + * Clone an active control and attach the clone to an opaque target. + */ + async cloneAndBindControl( + request: + operations.CloneAndBindControlApiV1ControlsControlIdCloneAndBindPostRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(controlsCloneAndBindControl( + this, + request, + options, + )); + } + /** * Get control configuration data * diff --git a/server/alembic/versions/e2b7f4a9c6d1_control_clone_lineage.py b/server/alembic/versions/e2b7f4a9c6d1_control_clone_lineage.py new file mode 100644 index 00000000..d74b9d38 --- /dev/null +++ b/server/alembic/versions/e2b7f4a9c6d1_control_clone_lineage.py @@ -0,0 +1,48 @@ +"""control clone lineage + +Revision ID: e2b7f4a9c6d1 +Revises: b6f4c2d8e9a1 +Create Date: 2026-05-19 00:00:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e2b7f4a9c6d1" +down_revision = "b6f4c2d8e9a1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "controls", + sa.Column("cloned_from_control_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_controls_cloned_from_control_id", + "controls", + "controls", + ["cloned_from_control_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index( + "idx_controls_cloned_from", + "controls", + ["cloned_from_control_id"], + ) + + +def downgrade() -> None: + op.drop_index("idx_controls_cloned_from", table_name="controls") + op.drop_constraint( + "fk_controls_cloned_from_control_id", + "controls", + type_="foreignkey", + ) + op.drop_column("controls", "cloned_from_control_id") diff --git a/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index 6e6441e9..677ebd91 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -1,10 +1,15 @@ import datetime as dt +import uuid +from copy import deepcopy +from typing import Any from agent_control_engine import list_evaluators from agent_control_models import ControlDefinition, TemplateControlInput, UnrenderedTemplateControl from agent_control_models.errors import ErrorCode, ValidationErrorItem from agent_control_models.server import ( AgentRef, + CloneAndBindControlRequest, + CloneAndBindControlResponse, ControlSummary, ControlVersionSummary, CreateControlRequest, @@ -26,7 +31,7 @@ ValidateControlDataRequest, ValidateControlDataResponse, ) -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from jsonschema_rs import ValidationError as JSONSchemaValidationError from pydantic import ValidationError from sqlalchemy import select @@ -36,7 +41,9 @@ from ..auth_framework import Operation, Principal, require_operation from ..db import get_async_db from ..errors import ( + APIError, APIValidationError, + BadRequestError, ConflictError, DatabaseError, NotFoundError, @@ -79,6 +86,29 @@ } +async def _clone_and_bind_context(request: Request) -> dict[str, Any]: + """Surface clone target identifiers to the authorization context.""" + try: + body = await request.json() + except Exception: # noqa: BLE001 malformed JSON falls through to endpoint validation + return {} + if not isinstance(body, dict): + return {} + target_binding = body.get("target_binding") + if not isinstance(target_binding, dict): + return {} + return { + "target_type": target_binding.get("target_type"), + "target_id": target_binding.get("target_id"), + } + + +def _generated_clone_name(source_name: str) -> str: + """Return a slug-safe default name for a cloned control.""" + suffix = f"-clone-{uuid.uuid4().hex[:8]}" + return f"{source_name[: 255 - len(suffix)]}{suffix}" + + def _serialize_control_data( control_data: ControlDefinition | UnrenderedTemplateControl, ) -> dict[str, object]: @@ -576,6 +606,123 @@ async def create_control( return CreateControlResponse(control_id=control.id) +@router.post( + "/{control_id}/clone-and-bind", + response_model=CloneAndBindControlResponse, + summary="Clone a control and bind the clone to a target", + response_description="Created clone and binding identifiers", +) +async def clone_and_bind_control( + control_id: int, + request: CloneAndBindControlRequest, + db: AsyncSession = Depends(get_async_db), + principal: Principal = Depends(require_operation(Operation.CONTROLS_CREATE)), + binding_principal: Principal = Depends( + require_operation( + Operation.CONTROL_BINDINGS_WRITE, + context_builder=_clone_and_bind_context, + ) + ), +) -> CloneAndBindControlResponse: + """Clone an active control and attach the clone to an opaque target.""" + if binding_principal.namespace_key != principal.namespace_key: + raise BadRequestError( + error_code=ErrorCode.VALIDATION_ERROR, + detail=( + "Control creation and target binding authorization resolved " + "to different namespaces." + ), + resource="ControlBinding", + hint=( + "Use credentials that grant control creation and target binding " + "in the same namespace." + ), + ) + + namespace_key = principal.namespace_key + control_service = ControlService(db) + bindings_service = ControlBindingsService(db) + + source = await control_service.get_active_control_or_404( + control_id, + namespace_key=namespace_key, + ) + clone_name = request.name or _generated_clone_name(source.name) + if await control_service.active_control_name_exists( + clone_name, namespace_key=namespace_key + ): + raise ConflictError( + error_code=ErrorCode.CONTROL_NAME_CONFLICT, + detail=f"Control with name '{clone_name}' already exists", + resource="Control", + resource_id=clone_name, + hint="Choose a different clone name.", + ) + + clone = control_service.create_control( + namespace_key=namespace_key, + name=clone_name, + data=deepcopy(source.data), + cloned_from_control_id=source.id, + ) + try: + await control_service.create_version( + clone, + event_type="cloned", + note=f"Cloned from control {source.id}", + ) + binding = await bindings_service.create_binding( + namespace_key=namespace_key, + target_type=request.target_binding.target_type, + target_id=request.target_binding.target_id, + control_id=clone.id, + enabled=request.target_binding.enabled, + ) + await db.commit() + except APIError: + await db.rollback() + raise + except IntegrityError as exc: + await db.rollback() + if _is_control_name_conflict(exc): + raise ConflictError( + error_code=ErrorCode.CONTROL_NAME_CONFLICT, + detail=f"Control with name '{clone_name}' already exists", + resource="Control", + resource_id=clone_name, + hint="Choose a different clone name.", + ) + _logger.error( + "Failed to clone control '%s' due to integrity error", + source.name, + exc_info=True, + ) + raise DatabaseError( + detail=f"Failed to clone control '{source.id}': database error", + resource="Control", + operation="clone_and_bind", + ) + except Exception: + await db.rollback() + _logger.error( + "Failed to clone and bind control '%s'", + source.name, + exc_info=True, + ) + raise DatabaseError( + detail=f"Failed to clone control '{source.id}': database error", + resource="Control", + operation="clone_and_bind", + ) + + return CloneAndBindControlResponse( + control_id=clone.id, + name=clone.name, + cloned_from_control_id=source.id, + binding_id=binding.id, + ) + + @router.get( "/schema", response_model=GetControlSchemaResponse, @@ -626,6 +773,7 @@ async def get_control( return GetControlResponse( id=control.id, name=control.name, + cloned_from_control_id=control.cloned_from_control_id, data=control_data, ) @@ -852,6 +1000,10 @@ async def list_controls( None, description="Filter by whether the control is template-backed", ), + cloned: bool | None = Query( + None, + description="Filter by whether the control was cloned from another control", + ), step_type: str | None = Query( None, description="Filter by step type (built-ins: 'tool', 'llm')" ), @@ -872,6 +1024,7 @@ async def list_controls( name: Optional filter by name (partial, case-insensitive match) enabled: Optional filter by enabled status template_backed: Optional filter by whether the control is template-backed + cloned: Optional filter by whether the control was cloned from another control step_type: Optional filter by step type (built-ins: 'tool', 'llm') stage: Optional filter by stage ('pre' or 'post') execution: Optional filter by execution ('server' or 'sdk') @@ -893,6 +1046,7 @@ async def list_controls( name=name, enabled=enabled, template_backed=template_backed, + cloned=cloned, step_type=step_type, stage=stage, execution=execution, @@ -914,6 +1068,7 @@ async def list_controls( ControlSummary( id=ctrl.id, name=ctrl.name, + cloned_from_control_id=ctrl.cloned_from_control_id, description=( data.get("description") or (data.get("template") or {}).get("description") diff --git a/server/src/agent_control_server/models.py b/server/src/agent_control_server/models.py index cad73c23..1014c3f3 100644 --- a/server/src/agent_control_server/models.py +++ b/server/src/agent_control_server/models.py @@ -167,6 +167,7 @@ class Control(Base): postgresql_where=text("deleted_at IS NULL"), sqlite_where=text("deleted_at IS NULL"), ), + Index("idx_controls_cloned_from", "cloned_from_control_id"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) @@ -178,6 +179,9 @@ class Control(Base): data: Mapped[dict[str, Any]] = mapped_column( JSONB, server_default=text("'{}'::jsonb"), nullable=False ) + cloned_from_control_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("controls.id", ondelete="SET NULL"), nullable=True + ) deleted_at: Mapped[dt.datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/server/src/agent_control_server/services/controls.py b/server/src/agent_control_server/services/controls.py index 6c015310..565aca42 100644 --- a/server/src/agent_control_server/services/controls.py +++ b/server/src/agent_control_server/services/controls.py @@ -102,9 +102,15 @@ def create_control( namespace_key: str, name: str, data: dict[str, Any], + cloned_from_control_id: int | None = None, ) -> Control: """Create a new pending control row.""" - control = Control(namespace_key=namespace_key, name=name, data=data) + control = Control( + namespace_key=namespace_key, + name=name, + data=data, + cloned_from_control_id=cloned_from_control_id, + ) self._db.add(control) return control @@ -427,6 +433,7 @@ async def list_controls_page( name: str | None, enabled: bool | None, template_backed: bool | None, + cloned: bool | None, step_type: str | None, stage: str | None, execution: str | None, @@ -443,6 +450,7 @@ async def list_controls_page( name=name, enabled=enabled, template_backed=template_backed, + cloned=cloned, step_type=step_type, stage=stage, execution=execution, @@ -464,6 +472,7 @@ async def list_controls_page( name=name, enabled=enabled, template_backed=template_backed, + cloned=cloned, step_type=step_type, stage=stage, execution=execution, @@ -820,6 +829,7 @@ def _apply_control_list_filters( name: str | None, enabled: bool | None, template_backed: bool | None, + cloned: bool | None, step_type: str | None, stage: str | None, execution: str | None, @@ -846,6 +856,12 @@ def _apply_control_list_filters( else: stmt = stmt.where(~Control.data.has_key("template")) + if cloned is not None: + if cloned: + stmt = stmt.where(Control.cloned_from_control_id.is_not(None)) + else: + stmt = stmt.where(Control.cloned_from_control_id.is_(None)) + has_rendered_filter = any(f is not None for f in (step_type, stage, execution, tag)) if has_rendered_filter: stmt = stmt.where(Control.data.has_key("condition")) @@ -877,12 +893,15 @@ def _apply_control_list_filters( def _build_snapshot(control: Control) -> dict[str, Any]: """Serialize the persisted control state stored in version history.""" deleted_at = control.deleted_at.isoformat() if control.deleted_at is not None else None - cloned_control_id = cast(int | None, getattr(control, "cloned_control_id", None)) + cloned_from_control_id = cast( + int | None, getattr(control, "cloned_from_control_id", None) + ) return { "name": control.name, "data": control.data, "deleted_at": deleted_at, - "cloned_control_id": cloned_control_id, + "cloned_from_control_id": cloned_from_control_id, + "cloned_control_id": cloned_from_control_id, } diff --git a/server/tests/test_control_versions.py b/server/tests/test_control_versions.py index f387a1f6..2af4fed2 100644 --- a/server/tests/test_control_versions.py +++ b/server/tests/test_control_versions.py @@ -53,6 +53,7 @@ def test_create_control_creates_initial_version_row(client: TestClient) -> None: assert version.snapshot["name"] == control_name assert version.snapshot["data"]["description"] == VALID_CONTROL_PAYLOAD["description"] assert version.snapshot["deleted_at"] is None + assert version.snapshot["cloned_from_control_id"] is None assert version.snapshot["cloned_control_id"] is None diff --git a/server/tests/test_controls_additional.py b/server/tests/test_controls_additional.py index dfbb15f5..e422ecf2 100644 --- a/server/tests/test_controls_additional.py +++ b/server/tests/test_controls_additional.py @@ -5,22 +5,29 @@ from collections.abc import AsyncGenerator from copy import deepcopy from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from agent_control_evaluators import RegexEvaluatorConfig from agent_control_models import ConditionNode +from agent_control_models.errors import ErrorCode from fastapi.testclient import TestClient -from sqlalchemy import text +from sqlalchemy import select, text from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from agent_control_server.auth_framework import Principal +from agent_control_server.auth_framework import Operation, Principal, set_authorizer from agent_control_server.db import get_async_db from agent_control_server.endpoints import controls as controls_module from agent_control_server.main import app -from agent_control_server.models import DEFAULT_NAMESPACE_KEY, Control +from agent_control_server.models import ( + DEFAULT_NAMESPACE_KEY, + Control, + ControlBinding, + ControlVersion, +) from .conftest import engine from .utils import VALID_CONTROL_PAYLOAD @@ -60,6 +67,205 @@ def _set_control_data(client: TestClient, control_id: int, data: dict) -> None: assert resp.status_code == 200, resp.text +def test_clone_and_bind_creates_cloned_control_binding_and_version( + client: TestClient, +) -> None: + source_id, source_name = _create_control(client) + + resp = client.post( + f"/api/v1/controls/{source_id}/clone-and-bind", + json={ + "target_binding": { + "target_type": "log_stream", + "target_id": "logstream-123", + "enabled": False, + } + }, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + clone_id = body["control_id"] + binding_id = body["binding_id"] + assert clone_id != source_id + assert body["name"].startswith(f"{source_name}-clone-") + assert body["cloned_from_control_id"] == source_id + + with Session(engine) as session: + source = session.get_one(Control, source_id) + clone = session.get_one(Control, clone_id) + binding = session.execute( + select(ControlBinding).where(ControlBinding.id == binding_id) + ).scalar_one() + version = session.execute( + select(ControlVersion).where(ControlVersion.control_id == clone_id) + ).scalar_one() + + assert clone.namespace_key == source.namespace_key + assert clone.data == source.data + assert clone.cloned_from_control_id == source_id + assert binding.control_id == clone_id + assert binding.target_type == "log_stream" + assert binding.target_id == "logstream-123" + assert binding.enabled is False + assert version.version_num == 1 + assert version.event_type == "cloned" + assert version.note == f"Cloned from control {source_id}" + assert version.snapshot["cloned_from_control_id"] == source_id + assert version.snapshot["cloned_control_id"] == source_id + + get_resp = client.get(f"/api/v1/controls/{clone_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["cloned_from_control_id"] == source_id + + +def test_list_controls_filters_by_cloned_state(client: TestClient) -> None: + source_id, _ = _create_control(client, name=f"Root-{uuid.uuid4()}") + clone_name = f"Clone-{uuid.uuid4()}" + clone_resp = client.post( + f"/api/v1/controls/{source_id}/clone-and-bind", + json={ + "name": clone_name, + "target_binding": { + "target_type": "log_stream", + "target_id": "logstream-456", + }, + }, + ) + assert clone_resp.status_code == 200, clone_resp.text + clone_id = clone_resp.json()["control_id"] + + root_resp = client.get("/api/v1/controls", params={"cloned": False, "limit": 100}) + assert root_resp.status_code == 200 + root_ids = {control["id"] for control in root_resp.json()["controls"]} + assert source_id in root_ids + assert clone_id not in root_ids + + clone_list_resp = client.get( + "/api/v1/controls", params={"cloned": True, "limit": 100} + ) + assert clone_list_resp.status_code == 200 + cloned_controls = clone_list_resp.json()["controls"] + cloned_ids = {control["id"] for control in cloned_controls} + assert clone_id in cloned_ids + assert source_id not in cloned_ids + listed_clone = next(control for control in cloned_controls if control["id"] == clone_id) + assert listed_clone["cloned_from_control_id"] == source_id + + +def test_clone_and_bind_returns_conflict_for_duplicate_clone_name( + client: TestClient, +) -> None: + _, existing_name = _create_control(client) + source_id, _ = _create_control(client) + + resp = client.post( + f"/api/v1/controls/{source_id}/clone-and-bind", + json={ + "name": existing_name, + "target_binding": { + "target_type": "log_stream", + "target_id": "logstream-789", + }, + }, + ) + + assert resp.status_code == 409 + assert resp.json()["error_code"] == "CONTROL_NAME_CONFLICT" + + +def test_clone_and_bind_rolls_back_clone_when_binding_fails( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_id, _ = _create_control(client) + clone_name = f"CloneRollback-{uuid.uuid4()}" + + async def fail_create_binding(*args: Any, **kwargs: Any) -> None: + raise controls_module.BadRequestError( + error_code=ErrorCode.CONTROL_BINDING_INCOMPATIBLE, + detail="Binding failed after clone creation.", + resource="ControlBinding", + ) + + monkeypatch.setattr( + controls_module.ControlBindingsService, + "create_binding", + fail_create_binding, + ) + + resp = client.post( + f"/api/v1/controls/{source_id}/clone-and-bind", + json={ + "name": clone_name, + "target_binding": { + "target_type": "log_stream", + "target_id": "logstream-rollback", + }, + }, + ) + + assert resp.status_code == 400 + with Session(engine) as session: + clone = session.execute( + select(Control).where(Control.name == clone_name) + ).scalar_one_or_none() + assert clone is None + + +def test_clone_and_bind_rejects_auth_namespace_mismatch(client: TestClient) -> None: + source_id, _ = _create_control(client) + + class MismatchedNamespaceAuthorizer: + async def authorize( + self, + request: Any, + operation: Operation, + context: dict[str, Any] | None = None, + ) -> Principal: + namespace_key = ( + "other-namespace" + if operation == Operation.CONTROL_BINDINGS_WRITE + else DEFAULT_NAMESPACE_KEY + ) + return Principal(namespace_key=namespace_key, is_admin=True) + + set_authorizer(MismatchedNamespaceAuthorizer()) + + resp = client.post( + f"/api/v1/controls/{source_id}/clone-and-bind", + json={ + "target_binding": { + "target_type": "log_stream", + "target_id": "logstream-mismatch", + }, + }, + ) + + assert resp.status_code == 400 + assert resp.json()["error_code"] == "VALIDATION_ERROR" + + +def test_clone_and_bind_context_tolerates_invalid_body_shapes( + client: TestClient, +) -> None: + resp = client.post( + "/api/v1/controls/1/clone-and-bind", + content="{", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 422 + + list_resp = client.post("/api/v1/controls/1/clone-and-bind", json=[]) + assert list_resp.status_code == 422 + + bad_target_resp = client.post( + "/api/v1/controls/1/clone-and-bind", + json={"target_binding": "not-an-object"}, + ) + assert bad_target_resp.status_code == 422 + + @pytest.mark.parametrize( "constraint_name", ["idx_controls_name_active", "idx_controls_namespace_name_active"],