diff --git a/models/src/agent_control_models/__init__.py b/models/src/agent_control_models/__init__.py index 32e9b997..aa8e75f3 100644 --- a/models/src/agent_control_models/__init__.py +++ b/models/src/agent_control_models/__init__.py @@ -101,6 +101,16 @@ ValidateControlDataRequest, ValidateControlDataResponse, ) +from .target import ( + AttachTargetControlRequest, + CreateTargetRequest, + CreateTargetResponse, + ListTargetControlsResponse, + ListTargetsResponse, + TargetControlSummary, + TargetSummary, + ToggleTargetControlRequest, +) __all__ = [ # Health @@ -191,4 +201,13 @@ "StatsResponse", "StatsTotals", "TimeseriesBucket", + # Target models + "AttachTargetControlRequest", + "CreateTargetRequest", + "CreateTargetResponse", + "ListTargetControlsResponse", + "ListTargetsResponse", + "TargetControlSummary", + "TargetSummary", + "ToggleTargetControlRequest", ] diff --git a/models/src/agent_control_models/errors.py b/models/src/agent_control_models/errors.py index d51b0638..c40bc4b4 100644 --- a/models/src/agent_control_models/errors.py +++ b/models/src/agent_control_models/errors.py @@ -61,6 +61,8 @@ class ErrorCode(StrEnum): POLICY_NOT_FOUND = "POLICY_NOT_FOUND" CONTROL_NOT_FOUND = "CONTROL_NOT_FOUND" EVALUATOR_NOT_FOUND = "EVALUATOR_NOT_FOUND" + TARGET_NOT_FOUND = "TARGET_NOT_FOUND" + TARGET_CONTROL_NOT_FOUND = "TARGET_CONTROL_NOT_FOUND" # Conflict Errors (3xx pattern) AGENT_NAME_CONFLICT = "AGENT_NAME_CONFLICT" @@ -71,6 +73,7 @@ class ErrorCode(StrEnum): CONTROL_TEMPLATE_CONFLICT = "CONTROL_TEMPLATE_CONFLICT" EVALUATOR_IN_USE = "EVALUATOR_IN_USE" SCHEMA_INCOMPATIBLE = "SCHEMA_INCOMPATIBLE" + TARGET_CONFLICT = "TARGET_CONFLICT" # Validation Errors (4xx pattern) VALIDATION_ERROR = "VALIDATION_ERROR" @@ -365,6 +368,8 @@ def make_error_type(error_code: ErrorCode) -> str: ErrorCode.POLICY_NOT_FOUND: "Policy Not Found", ErrorCode.CONTROL_NOT_FOUND: "Control Not Found", ErrorCode.EVALUATOR_NOT_FOUND: "Evaluator Not Found", + ErrorCode.TARGET_NOT_FOUND: "Target Not Found", + ErrorCode.TARGET_CONTROL_NOT_FOUND: "Target Control Not Found", # Conflict errors ErrorCode.AGENT_NAME_CONFLICT: "Agent Name Already Exists", ErrorCode.POLICY_NAME_CONFLICT: "Policy Name Already Exists", @@ -374,6 +379,7 @@ def make_error_type(error_code: ErrorCode) -> str: ErrorCode.CONTROL_TEMPLATE_CONFLICT: "Control Template Conflict", ErrorCode.EVALUATOR_IN_USE: "Evaluator In Use", ErrorCode.SCHEMA_INCOMPATIBLE: "Schema Incompatible", + ErrorCode.TARGET_CONFLICT: "Target Already Exists", # Validation errors ErrorCode.VALIDATION_ERROR: "Validation Error", ErrorCode.INVALID_CONFIG: "Invalid Configuration", diff --git a/models/src/agent_control_models/target.py b/models/src/agent_control_models/target.py new file mode 100644 index 00000000..7e678c18 --- /dev/null +++ b/models/src/agent_control_models/target.py @@ -0,0 +1,98 @@ +"""Pydantic models for target management APIs. + +Targets are typed, tenant-scoped, attachable objects. ``target_type`` is an +opaque string supplied by the caller (e.g. ``environment``); the server +treats it as data. The field is named ``target_type`` rather than ``type`` +to avoid shadowing Python's builtin and keep greps for the field specific. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from .base import BaseModel + + +class CreateTargetRequest(BaseModel): + """Request body for creating a new target.""" + + target_type: str = Field( + ..., + min_length=1, + max_length=64, + description="Opaque target kind (e.g. 'environment').", + ) + external_id: str = Field( + ..., + min_length=1, + max_length=255, + description="Stable caller-supplied identifier for the target.", + ) + name: str | None = Field( + default=None, + max_length=255, + description="Optional display name for the target.", + ) + data: dict[str, Any] = Field( + default_factory=dict, + description="Optional target metadata payload.", + ) + + +class CreateTargetResponse(BaseModel): + """Response returned after creating a target.""" + + target_id: int = Field(..., description="Identifier of the created target row.") + + +class TargetSummary(BaseModel): + """Full target record returned from get/list endpoints.""" + + id: int = Field(..., description="Internal target ID.") + tenant_id: str = Field(..., description="Owning tenant.") + target_type: str = Field(..., description="Opaque target kind.") + external_id: str = Field(..., description="Caller-supplied stable identifier.") + name: str | None = Field(default=None, description="Optional display name.") + data: dict[str, Any] = Field(default_factory=dict, description="Target metadata payload.") + created_at: str = Field(..., description="ISO 8601 timestamp when the target was created.") + + +class ListTargetsResponse(BaseModel): + """Response for listing targets.""" + + targets: list[TargetSummary] = Field(..., description="Targets visible to the current tenant.") + + +class AttachTargetControlRequest(BaseModel): + """Optional body for attaching a control to a target.""" + + enabled: bool = Field( + default=True, + description="Whether the attachment starts enabled. Defaults to true.", + ) + + +class ToggleTargetControlRequest(BaseModel): + """Body for toggling an existing target-control attachment's enabled flag.""" + + enabled: bool = Field(..., description="New enabled state for the attachment.") + + +class TargetControlSummary(BaseModel): + """A single control attached to a target.""" + + id: int = Field(..., description="target_controls row identifier.") + control_id: int = Field(..., description="Attached control ID.") + enabled: bool = Field(..., description="Whether the attachment is enabled.") + + +class ListTargetControlsResponse(BaseModel): + """Response for listing controls attached to a target.""" + + target_id: int = Field(..., description="Target whose controls are returned.") + controls: list[TargetControlSummary] = Field( + default_factory=list, + description="Controls attached to the target.", + ) diff --git a/sdks/typescript/overlays/method-names.overlay.yaml b/sdks/typescript/overlays/method-names.overlay.yaml index 1b36908e..0b7a90a2 100644 --- a/sdks/typescript/overlays/method-names.overlay.yaml +++ b/sdks/typescript/overlays/method-names.overlay.yaml @@ -205,6 +205,46 @@ actions: x-speakeasy-group: policies x-speakeasy-name-override: removeControl + - target: $["paths"]["/api/v1/targets"]["get"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: list + + - target: $["paths"]["/api/v1/targets"]["post"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: create + + - target: $["paths"]["/api/v1/targets/{target_id}"]["get"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: get + + - target: $["paths"]["/api/v1/targets/{target_id}"]["delete"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: delete + + - target: $["paths"]["/api/v1/targets/{target_id}/controls"]["get"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: listControls + + - target: $["paths"]["/api/v1/targets/{target_id}/controls/{control_id}"]["post"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: attachControl + + - target: $["paths"]["/api/v1/targets/{target_id}/controls/{control_id}"]["patch"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: toggleControl + + - target: $["paths"]["/api/v1/targets/{target_id}/controls/{control_id}"]["delete"] + update: + x-speakeasy-group: targets + x-speakeasy-name-override: detachControl + - target: $["paths"]["/health"]["get"] update: x-speakeasy-group: system diff --git a/sdks/typescript/src/generated/funcs/targets-attach-control.ts b/sdks/typescript/src/generated/funcs/targets-attach-control.ts new file mode 100644 index 00000000..dad5be82 --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-attach-control.ts @@ -0,0 +1,193 @@ +/* + * 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"; + +/** + * Attach a control to a target + * + * @remarks + * Attach a control to a target idempotently. The body is optional; when + * omitted, the attachment defaults to ``enabled=true``. + */ +export function targetsAttachControl( + client: AgentControlSDKCore, + request: + operations.AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest, + options?: RequestOptions, +): APIPromise< + Result< + models.TargetControlSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: + operations.AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest, + options?: RequestOptions, +): Promise< + [ + Result< + models.TargetControlSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations + .AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest$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", + }), + target_id: encodeSimple("target_id", payload.target_id, { + explode: false, + charEncoding: "percent", + }), + }; + + const path = pathToFunc("/api/v1/targets/{target_id}/controls/{control_id}")( + 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: + "attach_target_control_api_v1_targets__target_id__controls__control_id__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.TargetControlSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.TargetControlSummary$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/targets-create.ts b/sdks/typescript/src/generated/funcs/targets-create.ts new file mode 100644 index 00000000..4d358642 --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-create.ts @@ -0,0 +1,172 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AgentControlSDKCore } from "../core.js"; +import { encodeJSON } 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 { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Create a target + * + * @remarks + * Create a new target scoped to the effective tenant. + * + * The combination ``(tenant_id, target_type, external_id)`` must be unique. + */ +export function targetsCreate( + client: AgentControlSDKCore, + request: models.CreateTargetRequest, + options?: RequestOptions, +): APIPromise< + Result< + models.CreateTargetResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: models.CreateTargetRequest, + options?: RequestOptions, +): Promise< + [ + Result< + models.CreateTargetResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => z.parse(models.CreateTargetRequest$outboundSchema, value), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = encodeJSON("body", payload, { explode: true }); + + const path = pathToFunc("/api/v1/targets")(); + + 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: "create_target_api_v1_targets_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.CreateTargetResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(201, models.CreateTargetResponse$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/targets-delete.ts b/sdks/typescript/src/generated/funcs/targets-delete.ts new file mode 100644 index 00000000..bf49c2d5 --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-delete.ts @@ -0,0 +1,180 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AgentControlSDKCore } from "../core.js"; +import { 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 operations from "../models/operations/index.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Delete a target + * + * @remarks + * Delete a target. Attached ``target_controls`` rows cascade automatically. + */ +export function targetsDelete( + client: AgentControlSDKCore, + request: operations.DeleteTargetApiV1TargetsTargetIdDeleteRequest, + options?: RequestOptions, +): APIPromise< + Result< + void, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: operations.DeleteTargetApiV1TargetsTargetIdDeleteRequest, + options?: RequestOptions, +): Promise< + [ + Result< + void, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations.DeleteTargetApiV1TargetsTargetIdDeleteRequest$outboundSchema, + value, + ), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = null; + + const pathParams = { + target_id: encodeSimple("target_id", payload.target_id, { + explode: false, + charEncoding: "percent", + }), + }; + + const path = pathToFunc("/api/v1/targets/{target_id}")(pathParams); + + const headers = new Headers(compactMap({ + 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: "delete_target_api_v1_targets__target_id__delete", + 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: "DELETE", + 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< + void, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.nil(204, z.void()), + 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/targets-detach-control.ts b/sdks/typescript/src/generated/funcs/targets-detach-control.ts new file mode 100644 index 00000000..85a14a1c --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-detach-control.ts @@ -0,0 +1,191 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AgentControlSDKCore } from "../core.js"; +import { 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 operations from "../models/operations/index.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Detach a control from a target + * + * @remarks + * Detach a control from a target. 404 if the target is out of tenant scope + * or the attachment does not exist. + */ +export function targetsDetachControl( + client: AgentControlSDKCore, + request: + operations.DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest, + options?: RequestOptions, +): APIPromise< + Result< + void, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: + operations.DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest, + options?: RequestOptions, +): Promise< + [ + Result< + void, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations + .DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest$outboundSchema, + value, + ), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = null; + + const pathParams = { + control_id: encodeSimple("control_id", payload.control_id, { + explode: false, + charEncoding: "percent", + }), + target_id: encodeSimple("target_id", payload.target_id, { + explode: false, + charEncoding: "percent", + }), + }; + + const path = pathToFunc("/api/v1/targets/{target_id}/controls/{control_id}")( + pathParams, + ); + + const headers = new Headers(compactMap({ + 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: + "detach_target_control_api_v1_targets__target_id__controls__control_id__delete", + 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: "DELETE", + 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< + void, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.nil(204, z.void()), + 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/targets-get.ts b/sdks/typescript/src/generated/funcs/targets-get.ts new file mode 100644 index 00000000..3fbc28f1 --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-get.ts @@ -0,0 +1,178 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AgentControlSDKCore } from "../core.js"; +import { 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"; + +/** + * Get a target by ID + */ +export function targetsGet( + client: AgentControlSDKCore, + request: operations.GetTargetApiV1TargetsTargetIdGetRequest, + options?: RequestOptions, +): APIPromise< + Result< + models.TargetSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: operations.GetTargetApiV1TargetsTargetIdGetRequest, + options?: RequestOptions, +): Promise< + [ + Result< + models.TargetSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations.GetTargetApiV1TargetsTargetIdGetRequest$outboundSchema, + value, + ), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = null; + + const pathParams = { + target_id: encodeSimple("target_id", payload.target_id, { + explode: false, + charEncoding: "percent", + }), + }; + + const path = pathToFunc("/api/v1/targets/{target_id}")(pathParams); + + const headers = new Headers(compactMap({ + 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: "get_target_api_v1_targets__target_id__get", + 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: "GET", + 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.TargetSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.TargetSummary$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/targets-list-controls.ts b/sdks/typescript/src/generated/funcs/targets-list-controls.ts new file mode 100644 index 00000000..31e75401 --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-list-controls.ts @@ -0,0 +1,185 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AgentControlSDKCore } from "../core.js"; +import { 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"; + +/** + * List controls attached to a target + * + * @remarks + * List all controls attached to a target. + */ +export function targetsListControls( + client: AgentControlSDKCore, + request: + operations.ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest, + options?: RequestOptions, +): APIPromise< + Result< + models.ListTargetControlsResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: + operations.ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest, + options?: RequestOptions, +): Promise< + [ + Result< + models.ListTargetControlsResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations + .ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest$outboundSchema, + value, + ), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = null; + + const pathParams = { + target_id: encodeSimple("target_id", payload.target_id, { + explode: false, + charEncoding: "percent", + }), + }; + + const path = pathToFunc("/api/v1/targets/{target_id}/controls")(pathParams); + + const headers = new Headers(compactMap({ + 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: + "list_controls_for_target_api_v1_targets__target_id__controls_get", + 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: "GET", + 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.ListTargetControlsResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.ListTargetControlsResponse$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/targets-list.ts b/sdks/typescript/src/generated/funcs/targets-list.ts new file mode 100644 index 00000000..8e4b4753 --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-list.ts @@ -0,0 +1,179 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AgentControlSDKCore } from "../core.js"; +import { encodeFormQuery } 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"; + +/** + * List targets visible to the current tenant + * + * @remarks + * List targets for the effective tenant, optionally filtering by target_type. + */ +export function targetsList( + client: AgentControlSDKCore, + request?: operations.ListTargetsApiV1TargetsGetRequest | undefined, + options?: RequestOptions, +): APIPromise< + Result< + models.ListTargetsResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request?: operations.ListTargetsApiV1TargetsGetRequest | undefined, + options?: RequestOptions, +): Promise< + [ + Result< + models.ListTargetsResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + z.optional(operations.ListTargetsApiV1TargetsGetRequest$outboundSchema), + value, + ), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = null; + + const path = pathToFunc("/api/v1/targets")(); + + const query = encodeFormQuery({ + "target_type": payload?.target_type, + }); + + const headers = new Headers(compactMap({ + 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: "list_targets_api_v1_targets_get", + 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: "GET", + baseURL: options?.serverURL, + path: path, + headers: headers, + query: query, + 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.ListTargetsResponse, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.ListTargetsResponse$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/targets-toggle-control.ts b/sdks/typescript/src/generated/funcs/targets-toggle-control.ts new file mode 100644 index 00000000..2a24533e --- /dev/null +++ b/sdks/typescript/src/generated/funcs/targets-toggle-control.ts @@ -0,0 +1,192 @@ +/* + * 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"; + +/** + * Toggle a target_control attachment's enabled flag + * + * @remarks + * Enable or disable an existing target-control attachment. + */ +export function targetsToggleControl( + client: AgentControlSDKCore, + request: + operations.ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest, + options?: RequestOptions, +): APIPromise< + Result< + models.TargetControlSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + request: + operations.ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest, + options?: RequestOptions, +): Promise< + [ + Result< + models.TargetControlSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => + z.parse( + operations + .ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest$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", + }), + target_id: encodeSimple("target_id", payload.target_id, { + explode: false, + charEncoding: "percent", + }), + }; + + const path = pathToFunc("/api/v1/targets/{target_id}/controls/{control_id}")( + 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: + "toggle_target_control_api_v1_targets__target_id__controls__control_id__patch", + 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: "PATCH", + 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.TargetControlSummary, + | errors.HTTPValidationError + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.TargetControlSummary$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/models/attach-target-control-request.ts b/sdks/typescript/src/generated/models/attach-target-control-request.ts new file mode 100644 index 00000000..f9f5a497 --- /dev/null +++ b/sdks/typescript/src/generated/models/attach-target-control-request.ts @@ -0,0 +1,36 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; + +/** + * Optional body for attaching a control to a target. + */ +export type AttachTargetControlRequest = { + /** + * Whether the attachment starts enabled. Defaults to true. + */ + enabled?: boolean | undefined; +}; + +/** @internal */ +export type AttachTargetControlRequest$Outbound = { + enabled: boolean; +}; + +/** @internal */ +export const AttachTargetControlRequest$outboundSchema: z.ZodMiniType< + AttachTargetControlRequest$Outbound, + AttachTargetControlRequest +> = z.object({ + enabled: z._default(z.boolean(), true), +}); + +export function attachTargetControlRequestToJSON( + attachTargetControlRequest: AttachTargetControlRequest, +): string { + return JSON.stringify( + AttachTargetControlRequest$outboundSchema.parse(attachTargetControlRequest), + ); +} diff --git a/sdks/typescript/src/generated/models/create-target-request.ts b/sdks/typescript/src/generated/models/create-target-request.ts new file mode 100644 index 00000000..f8c6447b --- /dev/null +++ b/sdks/typescript/src/generated/models/create-target-request.ts @@ -0,0 +1,63 @@ +/* + * 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"; + +/** + * Request body for creating a new target. + */ +export type CreateTargetRequest = { + /** + * Optional target metadata payload. + */ + data?: { [k: string]: any } | undefined; + /** + * Stable caller-supplied identifier for the target. + */ + externalId: string; + /** + * Optional display name for the target. + */ + name?: string | null | undefined; + /** + * Opaque target kind (e.g. 'environment'). + */ + targetType: string; +}; + +/** @internal */ +export type CreateTargetRequest$Outbound = { + data?: { [k: string]: any } | undefined; + external_id: string; + name?: string | null | undefined; + target_type: string; +}; + +/** @internal */ +export const CreateTargetRequest$outboundSchema: z.ZodMiniType< + CreateTargetRequest$Outbound, + CreateTargetRequest +> = z.pipe( + z.object({ + data: z.optional(z.record(z.string(), z.any())), + externalId: z.string(), + name: z.optional(z.nullable(z.string())), + targetType: z.string(), + }), + z.transform((v) => { + return remap$(v, { + externalId: "external_id", + targetType: "target_type", + }); + }), +); + +export function createTargetRequestToJSON( + createTargetRequest: CreateTargetRequest, +): string { + return JSON.stringify( + CreateTargetRequest$outboundSchema.parse(createTargetRequest), + ); +} diff --git a/sdks/typescript/src/generated/models/create-target-response.ts b/sdks/typescript/src/generated/models/create-target-response.ts new file mode 100644 index 00000000..983a840c --- /dev/null +++ b/sdks/typescript/src/generated/models/create-target-response.ts @@ -0,0 +1,45 @@ +/* + * 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 returned after creating a target. + */ +export type CreateTargetResponse = { + /** + * Identifier of the created target row. + */ + targetId: number; +}; + +/** @internal */ +export const CreateTargetResponse$inboundSchema: z.ZodMiniType< + CreateTargetResponse, + unknown +> = z.pipe( + z.object({ + target_id: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "target_id": "targetId", + }); + }), +); + +export function createTargetResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CreateTargetResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CreateTargetResponse' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/index.ts b/sdks/typescript/src/generated/models/index.ts index 6d0e1764..796edd8a 100644 --- a/sdks/typescript/src/generated/models/index.ts +++ b/sdks/typescript/src/generated/models/index.ts @@ -8,6 +8,7 @@ export * from "./agent-ref.js"; export * from "./agent-summary.js"; export * from "./agent.js"; export * from "./assoc-response.js"; +export * from "./attach-target-control-request.js"; export * from "./auth-mode.js"; export * from "./batch-events-request.js"; export * from "./batch-events-response.js"; @@ -31,6 +32,8 @@ export * from "./create-control-request.js"; export * from "./create-control-response.js"; export * from "./create-policy-request.js"; export * from "./create-policy-response.js"; +export * from "./create-target-request.js"; +export * from "./create-target-response.js"; export * from "./delete-control-response.js"; export * from "./delete-policy-response.js"; export * from "./enum-template-parameter.js"; @@ -62,6 +65,8 @@ export * from "./json-value-output1.js"; export * from "./list-agents-response.js"; export * from "./list-controls-response.js"; export * from "./list-evaluators-response.js"; +export * from "./list-target-controls-response.js"; +export * from "./list-targets-response.js"; export * from "./login-request.js"; export * from "./login-response.js"; export * from "./pagination-info.js"; @@ -85,12 +90,15 @@ 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-control-summary.js"; +export * from "./target-summary.js"; export * from "./template-control-input.js"; export * from "./template-definition-input.js"; export * from "./template-definition-output.js"; export * from "./template-parameter-definition.js"; export * from "./template-value.js"; export * from "./timeseries-bucket.js"; +export * from "./toggle-target-control-request.js"; export * from "./unrendered-template-control.js"; export * from "./validate-control-data-request.js"; export * from "./validate-control-data-response.js"; diff --git a/sdks/typescript/src/generated/models/list-target-controls-response.ts b/sdks/typescript/src/generated/models/list-target-controls-response.ts new file mode 100644 index 00000000..5e085575 --- /dev/null +++ b/sdks/typescript/src/generated/models/list-target-controls-response.ts @@ -0,0 +1,54 @@ +/* + * 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"; +import { + TargetControlSummary, + TargetControlSummary$inboundSchema, +} from "./target-control-summary.js"; + +/** + * Response for listing controls attached to a target. + */ +export type ListTargetControlsResponse = { + /** + * Controls attached to the target. + */ + controls?: Array | undefined; + /** + * Target whose controls are returned. + */ + targetId: number; +}; + +/** @internal */ +export const ListTargetControlsResponse$inboundSchema: z.ZodMiniType< + ListTargetControlsResponse, + unknown +> = z.pipe( + z.object({ + controls: types.optional(z.array(TargetControlSummary$inboundSchema)), + target_id: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "target_id": "targetId", + }); + }), +); + +export function listTargetControlsResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListTargetControlsResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListTargetControlsResponse' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/list-targets-response.ts b/sdks/typescript/src/generated/models/list-targets-response.ts new file mode 100644 index 00000000..5d6858be --- /dev/null +++ b/sdks/typescript/src/generated/models/list-targets-response.ts @@ -0,0 +1,40 @@ +/* + * 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 { SDKValidationError } from "./errors/sdk-validation-error.js"; +import { + TargetSummary, + TargetSummary$inboundSchema, +} from "./target-summary.js"; + +/** + * Response for listing targets. + */ +export type ListTargetsResponse = { + /** + * Targets visible to the current tenant. + */ + targets: Array; +}; + +/** @internal */ +export const ListTargetsResponse$inboundSchema: z.ZodMiniType< + ListTargetsResponse, + unknown +> = z.object({ + targets: z.array(TargetSummary$inboundSchema), +}); + +export function listTargetsResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListTargetsResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListTargetsResponse' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/operations/attach-target-control-api-v1-targets-target-id-controls-control-id-post.ts b/sdks/typescript/src/generated/models/operations/attach-target-control-api-v1-targets-target-id-controls-control-id-post.ts new file mode 100644 index 00000000..9d91fb5b --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/attach-target-control-api-v1-targets-target-id-controls-control-id-post.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 * as models from "../index.js"; + +export type AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest = + { + targetId: number; + controlId: number; + body?: models.AttachTargetControlRequest | null | undefined; + }; + +/** @internal */ +export type AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest$Outbound = + { + target_id: number; + control_id: number; + body?: models.AttachTargetControlRequest$Outbound | null | undefined; + }; + +/** @internal */ +export const AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest$outboundSchema: + z.ZodMiniType< + AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest$Outbound, + AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest + > = z.pipe( + z.object({ + targetId: z.int(), + controlId: z.int(), + body: z.optional( + z.nullable(models.AttachTargetControlRequest$outboundSchema), + ), + }), + z.transform((v) => { + return remap$(v, { + targetId: "target_id", + controlId: "control_id", + }); + }), + ); + +export function attachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequestToJSON( + attachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest: + AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest, +): string { + return JSON.stringify( + AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest$outboundSchema + .parse( + attachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest, + ), + ); +} diff --git a/sdks/typescript/src/generated/models/operations/delete-target-api-v1-targets-target-id-delete.ts b/sdks/typescript/src/generated/models/operations/delete-target-api-v1-targets-target-id-delete.ts new file mode 100644 index 00000000..a2603b6b --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/delete-target-api-v1-targets-target-id-delete.ts @@ -0,0 +1,42 @@ +/* + * 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"; + +export type DeleteTargetApiV1TargetsTargetIdDeleteRequest = { + targetId: number; +}; + +/** @internal */ +export type DeleteTargetApiV1TargetsTargetIdDeleteRequest$Outbound = { + target_id: number; +}; + +/** @internal */ +export const DeleteTargetApiV1TargetsTargetIdDeleteRequest$outboundSchema: + z.ZodMiniType< + DeleteTargetApiV1TargetsTargetIdDeleteRequest$Outbound, + DeleteTargetApiV1TargetsTargetIdDeleteRequest + > = z.pipe( + z.object({ + targetId: z.int(), + }), + z.transform((v) => { + return remap$(v, { + targetId: "target_id", + }); + }), + ); + +export function deleteTargetApiV1TargetsTargetIdDeleteRequestToJSON( + deleteTargetApiV1TargetsTargetIdDeleteRequest: + DeleteTargetApiV1TargetsTargetIdDeleteRequest, +): string { + return JSON.stringify( + DeleteTargetApiV1TargetsTargetIdDeleteRequest$outboundSchema.parse( + deleteTargetApiV1TargetsTargetIdDeleteRequest, + ), + ); +} diff --git a/sdks/typescript/src/generated/models/operations/detach-target-control-api-v1-targets-target-id-controls-control-id-delete.ts b/sdks/typescript/src/generated/models/operations/detach-target-control-api-v1-targets-target-id-controls-control-id-delete.ts new file mode 100644 index 00000000..c87fb83d --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/detach-target-control-api-v1-targets-target-id-controls-control-id-delete.ts @@ -0,0 +1,49 @@ +/* + * 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"; + +export type DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest = + { + targetId: number; + controlId: number; + }; + +/** @internal */ +export type DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest$Outbound = + { + target_id: number; + control_id: number; + }; + +/** @internal */ +export const DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest$outboundSchema: + z.ZodMiniType< + DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest$Outbound, + DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest + > = z.pipe( + z.object({ + targetId: z.int(), + controlId: z.int(), + }), + z.transform((v) => { + return remap$(v, { + targetId: "target_id", + controlId: "control_id", + }); + }), + ); + +export function detachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequestToJSON( + detachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest: + DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest, +): string { + return JSON.stringify( + DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest$outboundSchema + .parse( + detachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest, + ), + ); +} diff --git a/sdks/typescript/src/generated/models/operations/get-target-api-v1-targets-target-id-get.ts b/sdks/typescript/src/generated/models/operations/get-target-api-v1-targets-target-id-get.ts new file mode 100644 index 00000000..5411d2ed --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/get-target-api-v1-targets-target-id-get.ts @@ -0,0 +1,42 @@ +/* + * 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"; + +export type GetTargetApiV1TargetsTargetIdGetRequest = { + targetId: number; +}; + +/** @internal */ +export type GetTargetApiV1TargetsTargetIdGetRequest$Outbound = { + target_id: number; +}; + +/** @internal */ +export const GetTargetApiV1TargetsTargetIdGetRequest$outboundSchema: + z.ZodMiniType< + GetTargetApiV1TargetsTargetIdGetRequest$Outbound, + GetTargetApiV1TargetsTargetIdGetRequest + > = z.pipe( + z.object({ + targetId: z.int(), + }), + z.transform((v) => { + return remap$(v, { + targetId: "target_id", + }); + }), + ); + +export function getTargetApiV1TargetsTargetIdGetRequestToJSON( + getTargetApiV1TargetsTargetIdGetRequest: + GetTargetApiV1TargetsTargetIdGetRequest, +): string { + return JSON.stringify( + GetTargetApiV1TargetsTargetIdGetRequest$outboundSchema.parse( + getTargetApiV1TargetsTargetIdGetRequest, + ), + ); +} diff --git a/sdks/typescript/src/generated/models/operations/index.ts b/sdks/typescript/src/generated/models/operations/index.ts index 0e630534..4017a974 100644 --- a/sdks/typescript/src/generated/models/operations/index.ts +++ b/sdks/typescript/src/generated/models/operations/index.ts @@ -5,8 +5,11 @@ 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 "./attach-target-control-api-v1-targets-target-id-controls-control-id-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-target-api-v1-targets-target-id-delete.js"; +export * from "./detach-target-control-api-v1-targets-target-id-controls-control-id-delete.js"; export * from "./get-agent-api-v1-agents-agent-name-get.js"; export * from "./get-agent-evaluator-api-v1-agents-agent-name-evaluators-evaluator-name-get.js"; export * from "./get-agent-policies-api-v1-agents-agent-name-policies-get.js"; @@ -15,11 +18,14 @@ export * from "./get-control-api-v1-controls-control-id-get.js"; export * from "./get-control-data-api-v1-controls-control-id-data-get.js"; export * from "./get-control-stats-api-v1-observability-stats-controls-control-id-get.js"; export * from "./get-stats-api-v1-observability-stats-get.js"; +export * from "./get-target-api-v1-targets-target-id-get.js"; export * from "./list-agent-controls-api-v1-agents-agent-name-controls-get.js"; export * from "./list-agent-evaluators-api-v1-agents-agent-name-evaluators-get.js"; export * from "./list-agents-api-v1-agents-get.js"; export * from "./list-controls-api-v1-controls-get.js"; +export * from "./list-controls-for-target-api-v1-targets-target-id-controls-get.js"; export * from "./list-policy-controls-api-v1-policies-policy-id-controls-get.js"; +export * from "./list-targets-api-v1-targets-get.js"; export * from "./patch-agent-api-v1-agents-agent-name-patch.js"; export * from "./patch-control-api-v1-controls-control-id-patch.js"; export * from "./remove-agent-control-api-v1-agents-agent-name-controls-control-id-delete.js"; @@ -28,3 +34,4 @@ export * from "./remove-all-agent-policies-api-v1-agents-agent-name-policies-del export * from "./remove-control-from-policy-api-v1-policies-policy-id-controls-control-id-delete.js"; export * from "./set-agent-policy-api-v1-agents-agent-name-policy-policy-id-post.js"; export * from "./set-control-data-api-v1-controls-control-id-data-put.js"; +export * from "./toggle-target-control-api-v1-targets-target-id-controls-control-id-patch.js"; diff --git a/sdks/typescript/src/generated/models/operations/list-controls-for-target-api-v1-targets-target-id-controls-get.ts b/sdks/typescript/src/generated/models/operations/list-controls-for-target-api-v1-targets-target-id-controls-get.ts new file mode 100644 index 00000000..1772024d --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/list-controls-for-target-api-v1-targets-target-id-controls-get.ts @@ -0,0 +1,42 @@ +/* + * 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"; + +export type ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest = { + targetId: number; +}; + +/** @internal */ +export type ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest$Outbound = + { + target_id: number; + }; + +/** @internal */ +export const ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest$outboundSchema: + z.ZodMiniType< + ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest$Outbound, + ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest + > = z.pipe( + z.object({ + targetId: z.int(), + }), + z.transform((v) => { + return remap$(v, { + targetId: "target_id", + }); + }), + ); + +export function listControlsForTargetApiV1TargetsTargetIdControlsGetRequestToJSON( + listControlsForTargetApiV1TargetsTargetIdControlsGetRequest: + ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest, +): string { + return JSON.stringify( + ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest$outboundSchema + .parse(listControlsForTargetApiV1TargetsTargetIdControlsGetRequest), + ); +} diff --git a/sdks/typescript/src/generated/models/operations/list-targets-api-v1-targets-get.ts b/sdks/typescript/src/generated/models/operations/list-targets-api-v1-targets-get.ts new file mode 100644 index 00000000..579acd00 --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/list-targets-api-v1-targets-get.ts @@ -0,0 +1,40 @@ +/* + * 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"; + +export type ListTargetsApiV1TargetsGetRequest = { + targetType?: string | null | undefined; +}; + +/** @internal */ +export type ListTargetsApiV1TargetsGetRequest$Outbound = { + target_type?: string | null | undefined; +}; + +/** @internal */ +export const ListTargetsApiV1TargetsGetRequest$outboundSchema: z.ZodMiniType< + ListTargetsApiV1TargetsGetRequest$Outbound, + ListTargetsApiV1TargetsGetRequest +> = z.pipe( + z.object({ + targetType: z.optional(z.nullable(z.string())), + }), + z.transform((v) => { + return remap$(v, { + targetType: "target_type", + }); + }), +); + +export function listTargetsApiV1TargetsGetRequestToJSON( + listTargetsApiV1TargetsGetRequest: ListTargetsApiV1TargetsGetRequest, +): string { + return JSON.stringify( + ListTargetsApiV1TargetsGetRequest$outboundSchema.parse( + listTargetsApiV1TargetsGetRequest, + ), + ); +} diff --git a/sdks/typescript/src/generated/models/operations/toggle-target-control-api-v1-targets-target-id-controls-control-id-patch.ts b/sdks/typescript/src/generated/models/operations/toggle-target-control-api-v1-targets-target-id-controls-control-id-patch.ts new file mode 100644 index 00000000..7e445e5f --- /dev/null +++ b/sdks/typescript/src/generated/models/operations/toggle-target-control-api-v1-targets-target-id-controls-control-id-patch.ts @@ -0,0 +1,53 @@ +/* + * 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 ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest = + { + targetId: number; + controlId: number; + body: models.ToggleTargetControlRequest; + }; + +/** @internal */ +export type ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest$Outbound = + { + target_id: number; + control_id: number; + body: models.ToggleTargetControlRequest$Outbound; + }; + +/** @internal */ +export const ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest$outboundSchema: + z.ZodMiniType< + ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest$Outbound, + ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest + > = z.pipe( + z.object({ + targetId: z.int(), + controlId: z.int(), + body: models.ToggleTargetControlRequest$outboundSchema, + }), + z.transform((v) => { + return remap$(v, { + targetId: "target_id", + controlId: "control_id", + }); + }), + ); + +export function toggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequestToJSON( + toggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest: + ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest, +): string { + return JSON.stringify( + ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest$outboundSchema + .parse( + toggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest, + ), + ); +} diff --git a/sdks/typescript/src/generated/models/target-control-summary.ts b/sdks/typescript/src/generated/models/target-control-summary.ts new file mode 100644 index 00000000..b26d9b34 --- /dev/null +++ b/sdks/typescript/src/generated/models/target-control-summary.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 { 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"; + +/** + * A single control attached to a target. + */ +export type TargetControlSummary = { + /** + * Attached control ID. + */ + controlId: number; + /** + * Whether the attachment is enabled. + */ + enabled: boolean; + /** + * target_controls row identifier. + */ + id: number; +}; + +/** @internal */ +export const TargetControlSummary$inboundSchema: z.ZodMiniType< + TargetControlSummary, + unknown +> = z.pipe( + z.object({ + control_id: types.number(), + enabled: types.boolean(), + id: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "control_id": "controlId", + }); + }), +); + +export function targetControlSummaryFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TargetControlSummary$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TargetControlSummary' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/target-summary.ts b/sdks/typescript/src/generated/models/target-summary.ts new file mode 100644 index 00000000..8da1a3f4 --- /dev/null +++ b/sdks/typescript/src/generated/models/target-summary.ts @@ -0,0 +1,78 @@ +/* + * 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"; + +/** + * Full target record returned from get/list endpoints. + */ +export type TargetSummary = { + /** + * ISO 8601 timestamp when the target was created. + */ + createdAt: string; + /** + * Target metadata payload. + */ + data?: { [k: string]: any } | undefined; + /** + * Caller-supplied stable identifier. + */ + externalId: string; + /** + * Internal target ID. + */ + id: number; + /** + * Optional display name. + */ + name?: string | null | undefined; + /** + * Opaque target kind. + */ + targetType: string; + /** + * Owning tenant. + */ + tenantId: string; +}; + +/** @internal */ +export const TargetSummary$inboundSchema: z.ZodMiniType< + TargetSummary, + unknown +> = z.pipe( + z.object({ + created_at: types.string(), + data: types.optional(z.record(z.string(), z.any())), + external_id: types.string(), + id: types.number(), + name: z.optional(z.nullable(types.string())), + target_type: types.string(), + tenant_id: types.string(), + }), + z.transform((v) => { + return remap$(v, { + "created_at": "createdAt", + "external_id": "externalId", + "target_type": "targetType", + "tenant_id": "tenantId", + }); + }), +); + +export function targetSummaryFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TargetSummary$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TargetSummary' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/toggle-target-control-request.ts b/sdks/typescript/src/generated/models/toggle-target-control-request.ts new file mode 100644 index 00000000..950dafa6 --- /dev/null +++ b/sdks/typescript/src/generated/models/toggle-target-control-request.ts @@ -0,0 +1,36 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; + +/** + * Body for toggling an existing target-control attachment's enabled flag. + */ +export type ToggleTargetControlRequest = { + /** + * New enabled state for the attachment. + */ + enabled: boolean; +}; + +/** @internal */ +export type ToggleTargetControlRequest$Outbound = { + enabled: boolean; +}; + +/** @internal */ +export const ToggleTargetControlRequest$outboundSchema: z.ZodMiniType< + ToggleTargetControlRequest$Outbound, + ToggleTargetControlRequest +> = z.object({ + enabled: z.boolean(), +}); + +export function toggleTargetControlRequestToJSON( + toggleTargetControlRequest: ToggleTargetControlRequest, +): string { + return JSON.stringify( + ToggleTargetControlRequest$outboundSchema.parse(toggleTargetControlRequest), + ); +} diff --git a/sdks/typescript/src/generated/sdk/sdk.ts b/sdks/typescript/src/generated/sdk/sdk.ts index 288dce89..c1d6978f 100644 --- a/sdks/typescript/src/generated/sdk/sdk.ts +++ b/sdks/typescript/src/generated/sdk/sdk.ts @@ -10,6 +10,7 @@ import { Evaluators } from "./evaluators.js"; import { Observability } from "./observability.js"; import { Policies } from "./policies.js"; import { System } from "./system.js"; +import { Targets } from "./targets.js"; export class AgentControlSDK extends ClientSDK { private _system?: System; @@ -46,4 +47,9 @@ export class AgentControlSDK extends ClientSDK { get policies(): Policies { return (this._policies ??= new Policies(this._options)); } + + private _targets?: Targets; + get targets(): Targets { + return (this._targets ??= new Targets(this._options)); + } } diff --git a/sdks/typescript/src/generated/sdk/targets.ts b/sdks/typescript/src/generated/sdk/targets.ts new file mode 100644 index 00000000..a0ad287e --- /dev/null +++ b/sdks/typescript/src/generated/sdk/targets.ts @@ -0,0 +1,159 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import { targetsAttachControl } from "../funcs/targets-attach-control.js"; +import { targetsCreate } from "../funcs/targets-create.js"; +import { targetsDelete } from "../funcs/targets-delete.js"; +import { targetsDetachControl } from "../funcs/targets-detach-control.js"; +import { targetsGet } from "../funcs/targets-get.js"; +import { targetsListControls } from "../funcs/targets-list-controls.js"; +import { targetsList } from "../funcs/targets-list.js"; +import { targetsToggleControl } from "../funcs/targets-toggle-control.js"; +import { ClientSDK, RequestOptions } from "../lib/sdks.js"; +import * as models from "../models/index.js"; +import * as operations from "../models/operations/index.js"; +import { unwrapAsync } from "../types/fp.js"; + +export class Targets extends ClientSDK { + /** + * List targets visible to the current tenant + * + * @remarks + * List targets for the effective tenant, optionally filtering by target_type. + */ + async list( + request?: operations.ListTargetsApiV1TargetsGetRequest | undefined, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsList( + this, + request, + options, + )); + } + + /** + * Create a target + * + * @remarks + * Create a new target scoped to the effective tenant. + * + * The combination ``(tenant_id, target_type, external_id)`` must be unique. + */ + async create( + request: models.CreateTargetRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsCreate( + this, + request, + options, + )); + } + + /** + * Delete a target + * + * @remarks + * Delete a target. Attached ``target_controls`` rows cascade automatically. + */ + async delete( + request: operations.DeleteTargetApiV1TargetsTargetIdDeleteRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsDelete( + this, + request, + options, + )); + } + + /** + * Get a target by ID + */ + async get( + request: operations.GetTargetApiV1TargetsTargetIdGetRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsGet( + this, + request, + options, + )); + } + + /** + * List controls attached to a target + * + * @remarks + * List all controls attached to a target. + */ + async listControls( + request: + operations.ListControlsForTargetApiV1TargetsTargetIdControlsGetRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsListControls( + this, + request, + options, + )); + } + + /** + * Detach a control from a target + * + * @remarks + * Detach a control from a target. 404 if the target is out of tenant scope + * or the attachment does not exist. + */ + async detachControl( + request: + operations.DetachTargetControlApiV1TargetsTargetIdControlsControlIdDeleteRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsDetachControl( + this, + request, + options, + )); + } + + /** + * Toggle a target_control attachment's enabled flag + * + * @remarks + * Enable or disable an existing target-control attachment. + */ + async toggleControl( + request: + operations.ToggleTargetControlApiV1TargetsTargetIdControlsControlIdPatchRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsToggleControl( + this, + request, + options, + )); + } + + /** + * Attach a control to a target + * + * @remarks + * Attach a control to a target idempotently. The body is optional; when + * omitted, the attachment defaults to ``enabled=true``. + */ + async attachControl( + request: + operations.AttachTargetControlApiV1TargetsTargetIdControlsControlIdPostRequest, + options?: RequestOptions, + ): Promise { + return unwrapAsync(targetsAttachControl( + this, + request, + options, + )); + } +} diff --git a/server/src/agent_control_server/endpoints/targets.py b/server/src/agent_control_server/endpoints/targets.py new file mode 100644 index 00000000..022d107a --- /dev/null +++ b/server/src/agent_control_server/endpoints/targets.py @@ -0,0 +1,325 @@ +"""REST endpoints for target management. + +Surface area: targets CRUD plus attach/detach/toggle/list of controls on a +target. Runtime control resolution from targets is handled separately. + +Tenant context is resolved via the ``get_tenant_id`` dependency, which reads +an optional ``X-Tenant-Id`` header and falls back to ``DEFAULT_TENANT_ID`` +when absent, so callers that do not supply a tenant land on the default. +""" + +from __future__ import annotations + +from agent_control_models import ( + AttachTargetControlRequest, + CreateTargetRequest, + CreateTargetResponse, + ListTargetControlsResponse, + ListTargetsResponse, + TargetControlSummary, + TargetSummary, + ToggleTargetControlRequest, +) +from agent_control_models.errors import ErrorCode +from fastapi import APIRouter, Depends +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..auth import require_admin_key +from ..db import get_async_db +from ..errors import ConflictError, DatabaseError, NotFoundError +from ..logging_utils import get_logger +from ..models import Target +from ..services import targets as targets_service +from ..tenancy import get_tenant_id + +router = APIRouter(prefix="/targets", tags=["targets"]) + +_logger = get_logger(__name__) + + +def _to_summary(target: Target) -> TargetSummary: + return TargetSummary( + id=target.id, + tenant_id=target.tenant_id, + target_type=target.target_type, + external_id=target.external_id, + name=target.name, + data=dict(target.data or {}), + created_at=target.created_at.isoformat() if target.created_at else "", + ) + + +async def _get_target_or_404( + *, tenant_id: str, target_id: int, db: AsyncSession +) -> Target: + target = await targets_service.get_target_by_id( + tenant_id=tenant_id, target_id=target_id, db=db + ) + if target is None: + raise NotFoundError( + error_code=ErrorCode.TARGET_NOT_FOUND, + detail=f"Target with ID '{target_id}' not found", + resource="Target", + resource_id=str(target_id), + hint="Verify the target ID and tenant scope.", + ) + return target + + +@router.post( + "", + dependencies=[Depends(require_admin_key)], + response_model=CreateTargetResponse, + status_code=201, + summary="Create a target", + response_description="Created target ID", +) +async def create_target( + request: CreateTargetRequest, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> CreateTargetResponse: + """Create a new target scoped to the effective tenant. + + The combination ``(tenant_id, target_type, external_id)`` must be unique. + """ + try: + target = await targets_service.create_target( + tenant_id=tenant_id, + target_type=request.target_type, + external_id=request.external_id, + name=request.name, + data=dict(request.data or {}), + db=db, + ) + except IntegrityError: + await db.rollback() + raise ConflictError( + error_code=ErrorCode.TARGET_CONFLICT, + detail=( + f"Target with target_type='{request.target_type}' and " + f"external_id='{request.external_id}' already exists in this tenant." + ), + resource="Target", + resource_id=request.external_id, + hint="Use a different external_id or update the existing target.", + ) + except Exception: + await db.rollback() + _logger.error( + "Failed to create target (type=%s, external_id=%s)", + request.target_type, + request.external_id, + exc_info=True, + ) + raise DatabaseError( + detail="Failed to create target: database error", + resource="Target", + operation="create", + ) + return CreateTargetResponse(target_id=target.id) + + +@router.get( + "", + response_model=ListTargetsResponse, + summary="List targets visible to the current tenant", + response_description="Targets, optionally filtered by target_type", +) +async def list_targets( + target_type: str | None = None, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> ListTargetsResponse: + """List targets for the effective tenant, optionally filtering by target_type.""" + rows = await targets_service.list_targets( + tenant_id=tenant_id, target_type=target_type, db=db + ) + return ListTargetsResponse(targets=[_to_summary(row) for row in rows]) + + +@router.get( + "/{target_id}", + response_model=TargetSummary, + summary="Get a target by ID", + response_description="Target details", +) +async def get_target( + target_id: int, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> TargetSummary: + target = await _get_target_or_404(tenant_id=tenant_id, target_id=target_id, db=db) + return _to_summary(target) + + +@router.delete( + "/{target_id}", + dependencies=[Depends(require_admin_key)], + status_code=204, + summary="Delete a target", + response_description="Empty response on success", +) +async def delete_target( + target_id: int, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> None: + """Delete a target. Attached ``target_controls`` rows cascade automatically.""" + removed = await targets_service.delete_target( + tenant_id=tenant_id, target_id=target_id, db=db + ) + if not removed: + raise NotFoundError( + error_code=ErrorCode.TARGET_NOT_FOUND, + detail=f"Target with ID '{target_id}' not found", + resource="Target", + resource_id=str(target_id), + hint="Verify the target ID and tenant scope.", + ) + + +@router.post( + "/{target_id}/controls/{control_id}", + dependencies=[Depends(require_admin_key)], + response_model=TargetControlSummary, + summary="Attach a control to a target", + response_description="Attachment row details", +) +async def attach_target_control( + target_id: int, + control_id: int, + request: AttachTargetControlRequest | None = None, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> TargetControlSummary: + """Attach a control to a target idempotently. The body is optional; when + omitted, the attachment defaults to ``enabled=true``. + """ + await _get_target_or_404(tenant_id=tenant_id, target_id=target_id, db=db) + if not await targets_service.control_exists(control_id=control_id, db=db): + raise NotFoundError( + error_code=ErrorCode.CONTROL_NOT_FOUND, + detail=f"Control with ID '{control_id}' not found", + resource="Control", + resource_id=str(control_id), + hint="Verify the control ID is correct and the control has been created.", + ) + + enabled = request.enabled if request is not None else True + result = await targets_service.attach_control_to_target( + target_id=target_id, control_id=control_id, enabled=enabled, db=db + ) + # On a no-op (attachment already existed), return the stored state so the + # client sees the current server-side truth rather than the request value. + stored_enabled = enabled + if not result.created: + existing = await targets_service.set_target_control_enabled( + target_id=target_id, + control_id=control_id, + enabled=enabled, + db=db, + ) + # If the attachment vanished between the two calls, surface 404. + if existing is None: + raise NotFoundError( + error_code=ErrorCode.TARGET_CONTROL_NOT_FOUND, + detail="Target control attachment disappeared during retry", + resource="TargetControl", + hint="Retry the request.", + ) + stored_enabled = existing.enabled + return TargetControlSummary( + id=result.attachment_id, control_id=control_id, enabled=stored_enabled + ) + + +@router.delete( + "/{target_id}/controls/{control_id}", + dependencies=[Depends(require_admin_key)], + status_code=204, + summary="Detach a control from a target", + response_description="Empty response on success", +) +async def detach_target_control( + target_id: int, + control_id: int, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> None: + """Detach a control from a target. 404 if the target is out of tenant scope + or the attachment does not exist. + """ + await _get_target_or_404(tenant_id=tenant_id, target_id=target_id, db=db) + removed = await targets_service.detach_control_from_target( + target_id=target_id, control_id=control_id, db=db + ) + if not removed: + raise NotFoundError( + error_code=ErrorCode.TARGET_CONTROL_NOT_FOUND, + detail=( + f"Control '{control_id}' is not attached to target '{target_id}'" + ), + resource="TargetControl", + hint="Verify the target and control IDs.", + ) + + +@router.patch( + "/{target_id}/controls/{control_id}", + dependencies=[Depends(require_admin_key)], + response_model=TargetControlSummary, + summary="Toggle a target_control attachment's enabled flag", + response_description="Updated attachment row", +) +async def toggle_target_control( + target_id: int, + control_id: int, + request: ToggleTargetControlRequest, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> TargetControlSummary: + """Enable or disable an existing target-control attachment.""" + await _get_target_or_404(tenant_id=tenant_id, target_id=target_id, db=db) + attachment = await targets_service.set_target_control_enabled( + target_id=target_id, + control_id=control_id, + enabled=request.enabled, + db=db, + ) + if attachment is None: + raise NotFoundError( + error_code=ErrorCode.TARGET_CONTROL_NOT_FOUND, + detail=( + f"Control '{control_id}' is not attached to target '{target_id}'" + ), + resource="TargetControl", + hint="Attach the control to the target first.", + ) + return TargetControlSummary( + id=attachment.id, control_id=attachment.control_id, enabled=attachment.enabled + ) + + +@router.get( + "/{target_id}/controls", + response_model=ListTargetControlsResponse, + summary="List controls attached to a target", + response_description="Attachments for the target", +) +async def list_controls_for_target( + target_id: int, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_async_db), +) -> ListTargetControlsResponse: + """List all controls attached to a target.""" + await _get_target_or_404(tenant_id=tenant_id, target_id=target_id, db=db) + rows = await targets_service.list_target_controls(target_id=target_id, db=db) + return ListTargetControlsResponse( + target_id=target_id, + controls=[ + TargetControlSummary(id=row.id, control_id=row.control_id, enabled=row.enabled) + for row in rows + ], + ) diff --git a/server/src/agent_control_server/main.py b/server/src/agent_control_server/main.py index 7f3ac718..558367c3 100644 --- a/server/src/agent_control_server/main.py +++ b/server/src/agent_control_server/main.py @@ -26,6 +26,7 @@ from .endpoints.observability import router as observability_router from .endpoints.policies import router as policy_router from .endpoints.system import router as system_router +from .endpoints.targets import router as target_router from .errors import ( APIError, api_error_handler, @@ -224,6 +225,11 @@ async def attach_version_header(request, call_next): # type: ignore[no-untyped- prefix=api_v1_prefix, dependencies=[Depends(require_api_key)], ) +app.include_router( + target_router, + prefix=api_v1_prefix, + dependencies=[Depends(require_api_key)], +) # Observability routes (already has auth dependency in router) app.include_router( diff --git a/server/src/agent_control_server/services/targets.py b/server/src/agent_control_server/services/targets.py new file mode 100644 index 00000000..4299e3ec --- /dev/null +++ b/server/src/agent_control_server/services/targets.py @@ -0,0 +1,209 @@ +"""Service layer for target management operations. + +Responsibilities: + +- Tenant-scoped CRUD on ``targets``. +- Attachment lifecycle on ``target_controls`` (attach, detach, toggle, list). +- Map domain-level outcomes (created, conflict, not_found, no_op) onto plain + return types so endpoint handlers can choose HTTP semantics. + +This layer does not resolve the request-scoped tenant itself; endpoints +inject the resolved ``tenant_id``. Runtime control resolution from +``target_controls`` is handled in a separate change. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Control, Target, TargetControl + + +@dataclass(frozen=True) +class AttachResult: + """Outcome of attaching a control to a target.""" + + attachment_id: int + created: bool + + +async def get_target_by_id( + *, tenant_id: str, target_id: int, db: AsyncSession +) -> Target | None: + """Return the target row if it exists and belongs to the given tenant.""" + stmt = select(Target).where(Target.id == target_id, Target.tenant_id == tenant_id) + result = await db.execute(stmt) + return result.scalars().first() + + +async def list_targets( + *, + tenant_id: str, + target_type: str | None, + db: AsyncSession, +) -> Sequence[Target]: + """List targets for the tenant, optionally filtered by target_type.""" + stmt = select(Target).where(Target.tenant_id == tenant_id) + if target_type is not None: + stmt = stmt.where(Target.target_type == target_type) + stmt = stmt.order_by(Target.id.desc()) + result = await db.execute(stmt) + return result.scalars().all() + + +async def create_target( + *, + tenant_id: str, + target_type: str, + external_id: str, + name: str | None, + data: dict[str, Any], + db: AsyncSession, +) -> Target: + """Create a new target. Raises IntegrityError on uniqueness violation.""" + target = Target( + tenant_id=tenant_id, + target_type=target_type, + external_id=external_id, + name=name, + data=data, + ) + db.add(target) + await db.commit() + await db.refresh(target) + return target + + +async def delete_target( + *, tenant_id: str, target_id: int, db: AsyncSession +) -> bool: + """Delete a target scoped to the tenant. Returns True if a row was removed.""" + stmt = ( + delete(Target) + .where(Target.id == target_id, Target.tenant_id == tenant_id) + .returning(Target.id) + ) + result = await db.execute(stmt) + removed = result.first() is not None + await db.commit() + return removed + + +async def attach_control_to_target( + *, + target_id: int, + control_id: int, + enabled: bool, + db: AsyncSession, +) -> AttachResult: + """Idempotently attach a control to a target. + + Returns the attachment ID and whether the row was newly created. A second + call with the same target/control pair is a no-op and returns ``created`` + as ``False`` without mutating the ``enabled`` flag; use the toggle + operation to change it. + """ + stmt = ( + pg_insert(TargetControl) + .values(target_id=target_id, control_id=control_id, enabled=enabled) + .on_conflict_do_nothing(index_elements=["target_id", "control_id"]) + .returning(TargetControl.id) + ) + result = await db.execute(stmt) + inserted_id = result.scalar_one_or_none() + if inserted_id is not None: + await db.commit() + return AttachResult(attachment_id=inserted_id, created=True) + + # Row already existed; look it up to return the stable attachment_id. + await db.commit() + existing_stmt = select(TargetControl.id).where( + TargetControl.target_id == target_id, + TargetControl.control_id == control_id, + ) + existing_id = (await db.execute(existing_stmt)).scalar_one() + return AttachResult(attachment_id=existing_id, created=False) + + +async def detach_control_from_target( + *, + target_id: int, + control_id: int, + db: AsyncSession, +) -> bool: + """Detach a control from a target. Returns True if a row was removed.""" + stmt = ( + delete(TargetControl) + .where( + TargetControl.target_id == target_id, + TargetControl.control_id == control_id, + ) + .returning(TargetControl.id) + ) + result = await db.execute(stmt) + removed = result.first() is not None + await db.commit() + return removed + + +async def set_target_control_enabled( + *, + target_id: int, + control_id: int, + enabled: bool, + db: AsyncSession, +) -> TargetControl | None: + """Set the enabled flag on an existing attachment. Returns the row or None.""" + stmt = select(TargetControl).where( + TargetControl.target_id == target_id, + TargetControl.control_id == control_id, + ) + result = await db.execute(stmt) + attachment = result.scalars().first() + if attachment is None: + return None + attachment.enabled = enabled + await db.commit() + await db.refresh(attachment) + return attachment + + +async def list_target_controls( + *, target_id: int, db: AsyncSession +) -> Sequence[TargetControl]: + """List attachments for a target, ordered by creation.""" + stmt = ( + select(TargetControl) + .where(TargetControl.target_id == target_id) + .order_by(TargetControl.id.asc()) + ) + result = await db.execute(stmt) + return result.scalars().all() + + +async def control_exists(*, control_id: int, db: AsyncSession) -> bool: + """Return whether a control with the given ID exists.""" + result = await db.execute(select(Control.id).where(Control.id == control_id)) + return result.first() is not None + + +__all__ = [ + "AttachResult", + "attach_control_to_target", + "control_exists", + "create_target", + "delete_target", + "detach_control_from_target", + "get_target_by_id", + "list_target_controls", + "list_targets", + "set_target_control_enabled", + "IntegrityError", +] diff --git a/server/src/agent_control_server/tenancy.py b/server/src/agent_control_server/tenancy.py new file mode 100644 index 00000000..ca9e3b5d --- /dev/null +++ b/server/src/agent_control_server/tenancy.py @@ -0,0 +1,36 @@ +"""Tenant resolution for request handlers. + +OSS deployments typically run as a single synthetic tenant. This module +exposes a minimal dependency that reads an optional ``X-Tenant-Id`` header +and falls back to ``DEFAULT_TENANT_ID`` when absent. A richer resolver +(e.g. reading from authenticated context) can be added later; the +dependency signature is the stable contract callers should depend on. +""" + +from __future__ import annotations + +from fastapi import Header + +from .models import DEFAULT_TENANT_ID + +TENANT_HEADER_NAME = "X-Tenant-Id" + + +def get_tenant_id( + x_tenant_id: str | None = Header( + default=None, + alias=TENANT_HEADER_NAME, + # Tenant is a cross-cutting infrastructure concern. Keep it out of + # per-operation OpenAPI so SDK callers set it via the client (a + # default request header) rather than passing it into every call. + include_in_schema=False, + ), +) -> str: + """Return the effective tenant for this request. + + Callers that omit the header land on the default tenant. + """ + if x_tenant_id is None: + return DEFAULT_TENANT_ID + trimmed = x_tenant_id.strip() + return trimmed if trimmed else DEFAULT_TENANT_ID diff --git a/server/tests/test_targets_endpoints.py b/server/tests/test_targets_endpoints.py new file mode 100644 index 00000000..37b3a7f1 --- /dev/null +++ b/server/tests/test_targets_endpoints.py @@ -0,0 +1,304 @@ +"""Endpoint tests for target management APIs.""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from .utils import VALID_CONTROL_PAYLOAD + +API_PREFIX = "/api/v1" +TENANT_HEADER = "X-Tenant-Id" + + +def _unique(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:10]}" + + +def _create_control(client: TestClient) -> int: + """Create a control via the standard endpoint and return its ID.""" + resp = client.put( + f"{API_PREFIX}/controls", + json={"name": _unique("ctrl"), "data": VALID_CONTROL_PAYLOAD}, + ) + assert resp.status_code == 200, resp.text + return int(resp.json()["control_id"]) + + +def _create_target( + client: TestClient, + *, + target_type: str = "environment", + external_id: str | None = None, + name: str | None = None, + data: dict | None = None, + tenant: str | None = None, +) -> int: + """Create a target via the API and return its ID.""" + body: dict[str, object] = { + "target_type": target_type, + "external_id": external_id or _unique("ext"), + } + if name is not None: + body["name"] = name + if data is not None: + body["data"] = data + headers = {TENANT_HEADER: tenant} if tenant else {} + resp = client.post(f"{API_PREFIX}/targets", json=body, headers=headers) + assert resp.status_code == 201, resp.text + return int(resp.json()["target_id"]) + + +# --------------------------------------------------------------------------- +# Create / list / get / delete +# --------------------------------------------------------------------------- + + +def test_create_target_happy_path(client: TestClient) -> None: + external_id = _unique("ls") + resp = client.post( + f"{API_PREFIX}/targets", + json={ + "target_type": "environment", + "external_id": external_id, + "name": "production", + "data": {"foo": "bar"}, + }, + ) + assert resp.status_code == 201, resp.text + payload = resp.json() + assert isinstance(payload["target_id"], int) + + get_resp = client.get(f"{API_PREFIX}/targets/{payload['target_id']}") + assert get_resp.status_code == 200 + body = get_resp.json() + assert body["target_type"] == "environment" + assert body["external_id"] == external_id + assert body["name"] == "production" + assert body["tenant_id"] == "default-tenant" + assert body["data"] == {"foo": "bar"} + + +def test_create_target_rejects_duplicate_external_id_per_tenant( + client: TestClient, +) -> None: + external_id = _unique("ls") + first = client.post( + f"{API_PREFIX}/targets", + json={"target_type": "environment", "external_id": external_id}, + ) + assert first.status_code == 201 + second = client.post( + f"{API_PREFIX}/targets", + json={"target_type": "environment", "external_id": external_id}, + ) + assert second.status_code == 409 + assert second.json()["error_code"] == "TARGET_CONFLICT" + + +def test_same_external_id_allowed_across_tenants(client: TestClient) -> None: + external_id = _unique("ls") + first = client.post( + f"{API_PREFIX}/targets", + json={"target_type": "environment", "external_id": external_id}, + headers={TENANT_HEADER: "tenant-a"}, + ) + second = client.post( + f"{API_PREFIX}/targets", + json={"target_type": "environment", "external_id": external_id}, + headers={TENANT_HEADER: "tenant-b"}, + ) + assert first.status_code == 201 + assert second.status_code == 201 + + +def test_list_targets_filters_by_tenant(client: TestClient) -> None: + _create_target(client, tenant="tenant-a") + _create_target(client, tenant="tenant-a") + _create_target(client, tenant="tenant-b") + + resp_a = client.get( + f"{API_PREFIX}/targets", headers={TENANT_HEADER: "tenant-a"} + ) + assert resp_a.status_code == 200 + assert len(resp_a.json()["targets"]) == 2 + + resp_b = client.get( + f"{API_PREFIX}/targets", headers={TENANT_HEADER: "tenant-b"} + ) + assert len(resp_b.json()["targets"]) == 1 + + +def test_list_targets_filters_by_target_type(client: TestClient) -> None: + _create_target(client, target_type="environment") + _create_target(client, target_type="dataset") + resp = client.get(f"{API_PREFIX}/targets", params={"target_type": "environment"}) + assert resp.status_code == 200 + returned_types = {t["target_type"] for t in resp.json()["targets"]} + assert returned_types == {"environment"} + + +def test_get_target_cross_tenant_returns_404(client: TestClient) -> None: + target_id = _create_target(client, tenant="tenant-a") + resp = client.get( + f"{API_PREFIX}/targets/{target_id}", + headers={TENANT_HEADER: "tenant-b"}, + ) + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TARGET_NOT_FOUND" + + +def test_delete_target_happy_path(client: TestClient) -> None: + target_id = _create_target(client) + resp = client.delete(f"{API_PREFIX}/targets/{target_id}") + assert resp.status_code == 204 + get_resp = client.get(f"{API_PREFIX}/targets/{target_id}") + assert get_resp.status_code == 404 + + +def test_delete_target_not_found(client: TestClient) -> None: + resp = client.delete(f"{API_PREFIX}/targets/999999") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TARGET_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# Attach / detach / toggle target_controls +# --------------------------------------------------------------------------- + + +def test_attach_control_to_target(client: TestClient) -> None: + target_id = _create_target(client) + control_id = _create_control(client) + resp = client.post( + f"{API_PREFIX}/targets/{target_id}/controls/{control_id}" + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["control_id"] == control_id + assert body["enabled"] is True + + +def test_attach_control_with_enabled_false(client: TestClient) -> None: + target_id = _create_target(client) + control_id = _create_control(client) + resp = client.post( + f"{API_PREFIX}/targets/{target_id}/controls/{control_id}", + json={"enabled": False}, + ) + assert resp.status_code == 200 + assert resp.json()["enabled"] is False + + +def test_attach_control_is_idempotent(client: TestClient) -> None: + target_id = _create_target(client) + control_id = _create_control(client) + first = client.post(f"{API_PREFIX}/targets/{target_id}/controls/{control_id}") + second = client.post(f"{API_PREFIX}/targets/{target_id}/controls/{control_id}") + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["id"] == second.json()["id"] + + +def test_attach_control_target_not_found(client: TestClient) -> None: + control_id = _create_control(client) + resp = client.post(f"{API_PREFIX}/targets/999999/controls/{control_id}") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TARGET_NOT_FOUND" + + +def test_attach_control_control_not_found(client: TestClient) -> None: + target_id = _create_target(client) + resp = client.post(f"{API_PREFIX}/targets/{target_id}/controls/999999") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "CONTROL_NOT_FOUND" + + +def test_detach_control(client: TestClient) -> None: + target_id = _create_target(client) + control_id = _create_control(client) + client.post(f"{API_PREFIX}/targets/{target_id}/controls/{control_id}") + resp = client.delete(f"{API_PREFIX}/targets/{target_id}/controls/{control_id}") + assert resp.status_code == 204 + + +def test_detach_control_not_attached(client: TestClient) -> None: + target_id = _create_target(client) + control_id = _create_control(client) + resp = client.delete(f"{API_PREFIX}/targets/{target_id}/controls/{control_id}") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TARGET_CONTROL_NOT_FOUND" + + +def test_toggle_enabled(client: TestClient) -> None: + target_id = _create_target(client) + control_id = _create_control(client) + client.post(f"{API_PREFIX}/targets/{target_id}/controls/{control_id}") + resp = client.patch( + f"{API_PREFIX}/targets/{target_id}/controls/{control_id}", + json={"enabled": False}, + ) + assert resp.status_code == 200 + assert resp.json()["enabled"] is False + + +def test_toggle_missing_attachment_returns_404(client: TestClient) -> None: + target_id = _create_target(client) + control_id = _create_control(client) + resp = client.patch( + f"{API_PREFIX}/targets/{target_id}/controls/{control_id}", + json={"enabled": False}, + ) + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TARGET_CONTROL_NOT_FOUND" + + +def test_list_target_controls(client: TestClient) -> None: + target_id = _create_target(client) + control_a = _create_control(client) + control_b = _create_control(client) + client.post(f"{API_PREFIX}/targets/{target_id}/controls/{control_a}") + client.post( + f"{API_PREFIX}/targets/{target_id}/controls/{control_b}", + json={"enabled": False}, + ) + + resp = client.get(f"{API_PREFIX}/targets/{target_id}/controls") + assert resp.status_code == 200 + body = resp.json() + assert body["target_id"] == target_id + returned = {(c["control_id"], c["enabled"]) for c in body["controls"]} + assert returned == {(control_a, True), (control_b, False)} + + +# --------------------------------------------------------------------------- +# Tenant header handling +# --------------------------------------------------------------------------- + + +def test_missing_tenant_header_falls_back_to_default_tenant(client: TestClient) -> None: + target_id = _create_target(client) + resp = client.get(f"{API_PREFIX}/targets/{target_id}") + assert resp.status_code == 200 + assert resp.json()["tenant_id"] == "default-tenant" + + +def test_whitespace_tenant_header_treated_as_default(client: TestClient) -> None: + target_id = _create_target(client, tenant=" ") + resp = client.get(f"{API_PREFIX}/targets/{target_id}") + assert resp.status_code == 200 + assert resp.json()["tenant_id"] == "default-tenant" + + +@pytest.mark.parametrize("missing_admin", [True]) +def test_create_target_requires_admin_key( + missing_admin: bool, non_admin_client: TestClient +) -> None: + """Creation requires admin; a plain API key is rejected.""" + resp = non_admin_client.post( + f"{API_PREFIX}/targets", + json={"target_type": "environment", "external_id": _unique("ls")}, + ) + assert resp.status_code == 403