diff --git a/alembic/versions/d3f5a7c9e1b4_add_guardrail_credentials.py b/alembic/versions/d3f5a7c9e1b4_add_guardrail_credentials.py new file mode 100644 index 0000000000..09be415ff3 --- /dev/null +++ b/alembic/versions/d3f5a7c9e1b4_add_guardrail_credentials.py @@ -0,0 +1,49 @@ +"""Hold a guardrail definition in Otari rather than in a sidecar's YAML. + +A row is the definition: the ``any_guardrail`` class plus the arguments that +build and call it, named by the profile a caller sends. + +Two columns for the constructor arguments rather than one column per argument. +The guardrails a hosted API reaches carry between zero and three secret +constructor arguments each, so typing them would mean chasing every guardrail +upstream adds; instead the non-secret ones stay plain and every secret goes into +one map encrypted as a single string. + +Nothing on the request path reads it yet. A downgrade drops the table and loses +the stored definitions with it. + +Revision ID: d3f5a7c9e1b4 +Revises: b2d4f6a8c0e2 +Create Date: 2026-09-16 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "d3f5a7c9e1b4" +down_revision: str | Sequence[str] | None = "b2d4f6a8c0e2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "guardrail_credentials", + sa.Column("name", sa.String(), nullable=False), + sa.Column("guardrail_name", sa.String(), nullable=False), + sa.Column("create_kwargs", sa.JSON(), nullable=False), + sa.Column("encrypted_create_secrets", sa.Text(), nullable=True), + sa.Column("validate_kwargs", sa.JSON(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint("name"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("guardrail_credentials") diff --git a/docs/guardrails.md b/docs/guardrails.md index 6f7e436d26..5ff3f87814 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -154,6 +154,76 @@ The catalog reaches no service, so unlike the profiles read it has no unavailable state. It is on the operator gate, because it is the picker behind a form that stores a vendor credential for the whole deployment. +### Storing a guardrail definition + +`/api/v1/guardrail-credentials` is where a choice from that catalog is saved. +A row names the guardrail, carries the arguments that build and call it, and is +itself named by the `profile` a caller would send. Operator-gated, and never +mounted in hybrid mode, like the provider and search-tool stores it is modeled +on. + +```bash +curl -X POST http://localhost:8000/api/v1/guardrail-credentials \ + -H "Authorization: Bearer $OTARI_MASTER_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "prompt-injection", + "guardrail_name": "lakera_guard", + "create_kwargs": {"api_key": "lak-...", "endpoint": "https://api.lakera.ai/v2/guard"} + }' +``` + +Send the constructor arguments as one `create_kwargs` map, secret and plain +together. Otari splits them by the catalog's own `secret` flag: the plain half +is stored as it is, and every secret goes into one map encrypted with +`OTARI_SECRET_KEY`. Guardrails carry between zero and three credentials each, so +the map is what lets one shape serve all of them. + +A response never carries a credential. It reports which ones the row holds, by +name and masked: + +```json +{ + "name": "prompt-injection", + "guardrail_name": "lakera_guard", + "create_kwargs": {"endpoint": "https://api.lakera.ai/v2/guard"}, + "create_secrets": {"api_key": "***"}, + "enabled": true, + "decryptable": true +} +``` + +`PATCH /api/v1/guardrail-credentials/{name}` leaves out what you leave out. A +sent `create_kwargs` replaces the whole map, and `***` in it keeps the stored +credential of that name, so an editor that loads a row, changes the endpoint and +submits the whole object does not overwrite the key it was never shown. A new +value rotates that credential, and one you leave out is cleared. + +A definition is held to what the catalog says its guardrail accepts, so four +things are refused with a 400 rather than stored: a guardrail Otari cannot run, +an argument the guardrail does not take, a required argument that nothing else +supplies, and an argument that is a live Python object. The last is the +`storable: false` flag in the catalog. Bedrock's `boto3_session` and watsonx's +`api_client` are already-built clients holding a connection and refreshed +tokens, so no row can hold one; configure those two with +`aws_access_key_id` and `aws_secret_access_key`, and with `api_key` and `url`, +instead. + +A required argument that names an environment variable may be left out, because +the deployment can supply it that way. Otari does not check whether the variable +is set: that belongs to the process that builds the guardrail, not to the one +storing the row. + +`decryptable: false` means the credentials were written under an +`OTARI_SECRET_KEY` this deployment no longer has. The row is listed rather than +hidden so an operator can repair it, either by restoring the old key or by +re-entering the credentials. `POST /api/v1/guardrail-credentials/reencrypt` is +the guardrail half of a key rotation; run it beside the provider and search-tool +endpoints of the same name. + +Nothing on the request path reads these rows yet, so storing a definition does +not change how a request behaves. + ### How the layers compose Three layers can name a guardrail: the caller's request, the caller's diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 83225e88f5..cf734530bf 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -3751,6 +3751,56 @@ "title": "CreateBudgetRequest", "type": "object" }, + "CreateGuardrailCredentialRequest": { + "description": "Store a guardrail definition. Secret arguments are encrypted and never returned.", + "example": { + "create_kwargs": { + "api_key": "lak-...", + "endpoint": "https://api.lakera.ai/v2/guard" + }, + "guardrail_name": "lakera_guard", + "name": "prompt-injection" + }, + "properties": { + "create_kwargs": { + "additionalProperties": true, + "description": "Constructor arguments, secret and plain together. They are split by the catalog's own secret flag; the secret half is encrypted before it is stored.", + "title": "Create Kwargs", + "type": "object" + }, + "enabled": { + "default": true, + "description": "A disabled definition is kept but does not run.", + "title": "Enabled", + "type": "boolean" + }, + "guardrail_name": { + "description": "The guardrail to build, as listed by GET /tool-settings/guardrails/catalog.", + "title": "Guardrail Name", + "type": "string" + }, + "name": { + "description": "The profile name a caller sends. One path segment, so it cannot contain '/'.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[^/]+$", + "title": "Name", + "type": "string" + }, + "validate_kwargs": { + "additionalProperties": true, + "description": "Per-call arguments sent with the text on every check.", + "title": "Validate Kwargs", + "type": "object" + } + }, + "required": [ + "name", + "guardrail_name" + ], + "title": "CreateGuardrailCredentialRequest", + "type": "object" + }, "CreateKeyRequest": { "description": "Request model for creating a new API key.", "properties": { @@ -11147,6 +11197,27 @@ "title": "RecordedPool", "type": "object" }, + "ReencryptGuardrailCredentialsResponse": { + "description": "Result of re-encrypting stored guardrail credentials with the primary secret key.", + "properties": { + "reencrypted": { + "description": "Number of stored credential maps re-encrypted.", + "title": "Reencrypted", + "type": "integer" + }, + "unreadable": { + "description": "Number of maps left untouched because they could not be decrypted.", + "title": "Unreadable", + "type": "integer" + } + }, + "required": [ + "reencrypted", + "unreadable" + ], + "title": "ReencryptGuardrailCredentialsResponse", + "type": "object" + }, "ReencryptProviderCredentialsResponse": { "description": "Result of re-encrypting stored provider keys with the primary secret key.", "properties": { @@ -12808,6 +12879,78 @@ "title": "SignupResponse", "type": "object" }, + "StoredGuardrailSchema": { + "description": "A stored guardrail definition. Credentials are never returned, only their names.", + "properties": { + "create_kwargs": { + "additionalProperties": true, + "description": "The non-secret constructor arguments, as stored.", + "title": "Create Kwargs", + "type": "object" + }, + "create_secrets": { + "additionalProperties": { + "type": "string" + }, + "description": "The stored credentials by name, each masked. Empty when the stored map cannot be read.", + "title": "Create Secrets", + "type": "object" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "decryptable": { + "default": true, + "description": "False when the stored credentials cannot be read with the current OTARI_SECRET_KEY. The definition is intact; re-enter its credentials or restore the key that wrote them.", + "title": "Decryptable", + "type": "boolean" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "guardrail_name": { + "title": "Guardrail Name", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, + "validate_kwargs": { + "additionalProperties": true, + "description": "The per-call arguments, with credential-shaped entries masked.", + "title": "Validate Kwargs", + "type": "object" + } + }, + "required": [ + "name", + "guardrail_name", + "enabled" + ], + "title": "StoredGuardrailSchema", + "type": "object" + }, "StoredProviderResponse": { "description": "A runtime-stored provider. The API key is never returned, only ``last4``.", "properties": { @@ -13535,6 +13678,79 @@ "title": "UpdateBudgetRequest", "type": "object" }, + "UpdateGuardrailCredentialRequest": { + "description": "Update a stored definition. Omitted fields keep their stored value.", + "example": { + "create_kwargs": { + "api_key": "***", + "endpoint": "https://api.lakera.ai/v2/guard" + }, + "enabled": false + }, + "properties": { + "create_kwargs": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Replaces the whole map when sent. A value of '***' keeps the stored credential of that name, a new value rotates it, and a credential left out is cleared.", + "title": "Create Kwargs" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "expected_updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optimistic concurrency: if set, the update 412s unless it matches the stored updated_at.", + "title": "Expected Updated At" + }, + "guardrail_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Guardrail Name" + }, + "validate_kwargs": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Validate Kwargs" + } + }, + "title": "UpdateGuardrailCredentialRequest", + "type": "object" + }, "UpdateKeyRequest": { "description": "Request model for updating a key.", "properties": { @@ -20497,6 +20713,270 @@ ] } }, + "/api/v1/guardrail-credentials": { + "get": { + "description": "List every stored guardrail definition.\n\nCredentials are never returned. Each row reports which credentials it holds\nby name and whether they can still be read with the current\n``OTARI_SECRET_KEY``; a row that cannot be read is listed rather than\nhidden, because the operator is the person who can fix it.", + "operationId": "guardrail-credentials-list_stored_guardrails", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StoredGuardrailSchema" + }, + "title": "Response Guardrail-Credentials-List Stored Guardrails", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "List Stored Guardrails", + "tags": [ + "guardrail-credentials" + ] + }, + "post": { + "description": "Store a guardrail definition.\n\nThe definition is held to what the catalog says its guardrail accepts: an\nunknown guardrail, an argument it does not take, a live object that cannot\nbe written down, and a required argument nothing else supplies are each a\n400 naming the rule. Storing a credential requires ``OTARI_SECRET_KEY``.", + "operationId": "guardrail-credentials-create_stored_guardrail", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGuardrailCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredGuardrailSchema" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Create Stored Guardrail", + "tags": [ + "guardrail-credentials" + ] + } + }, + "/api/v1/guardrail-credentials/reencrypt": { + "post": { + "description": "Re-encrypt stored guardrail credentials with the primary OTARI_SECRET_KEY.\n\nThe guardrail half of the key-rotation procedure; run it alongside\n``POST /api/v1/provider-credentials/reencrypt`` and\n``POST /api/v1/search-tools/reencrypt``. Maps that cannot be decrypted are\nleft untouched and must be recovered by re-entering that guardrail's\ncredentials.", + "operationId": "guardrail-credentials-reencrypt_stored_guardrail_credentials", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReencryptGuardrailCredentialsResponse" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Reencrypt Stored Guardrail Credentials", + "tags": [ + "guardrail-credentials" + ] + } + }, + "/api/v1/guardrail-credentials/{name}": { + "delete": { + "description": "Delete a stored guardrail definition, credentials and all.", + "operationId": "guardrail-credentials-delete_stored_guardrail", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Delete Stored Guardrail", + "tags": [ + "guardrail-credentials" + ] + }, + "get": { + "description": "Read one stored guardrail definition. Credentials are returned by name only.", + "operationId": "guardrail-credentials-get_stored_guardrail", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredGuardrailSchema" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Get Stored Guardrail", + "tags": [ + "guardrail-credentials" + ] + }, + "patch": { + "description": "Update a stored guardrail definition. Omitted fields are left as they are.\n\nThe row is locked ``FOR UPDATE`` so the ``expected_updated_at`` check and\nthe write it guards are atomic. The definition as it will be *after* the\nupdate is validated, so a change that would leave it unbuildable (clearing a\nrequired argument, or moving to a guardrail that does not take an argument\nthe row carries) is refused rather than stored.", + "operationId": "guardrail-credentials-update_stored_guardrail", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateGuardrailCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredGuardrailSchema" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Update Stored Guardrail", + "tags": [ + "guardrail-credentials" + ] + } + }, "/api/v1/health": { "get": { "description": "General health check endpoint.\n\nReturns basic health status. For infrastructure monitoring,\nuse /health/readiness or /health/liveness instead.", diff --git a/docs/public/otari.postman_collection.json b/docs/public/otari.postman_collection.json index 9e8a0152d7..68daed85bb 100644 --- a/docs/public/otari.postman_collection.json +++ b/docs/public/otari.postman_collection.json @@ -1909,6 +1909,178 @@ ], "name": "files" }, + { + "item": [ + { + "name": "List Stored Guardrails", + "request": { + "description": "List every stored guardrail definition.\n\nCredentials are never returned. Each row reports which credentials it holds\nby name and whether they can still be read with the current\n``OTARI_SECRET_KEY``; a row that cannot be read is listed rather than\nhidden, because the operator is the person who can fix it.", + "header": [], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "guardrail-credentials" + ], + "raw": "{{baseUrl}}/api/v1/guardrail-credentials" + } + } + }, + { + "name": "Create Stored Guardrail", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"create_kwargs\": {\n \"api_key\": \"lak-...\",\n \"endpoint\": \"https://api.lakera.ai/v2/guard\"\n },\n \"guardrail_name\": \"lakera_guard\",\n \"name\": \"prompt-injection\"\n}" + }, + "description": "Store a guardrail definition.\n\nThe definition is held to what the catalog says its guardrail accepts: an\nunknown guardrail, an argument it does not take, a live object that cannot\nbe written down, and a required argument nothing else supplies are each a\n400 naming the rule. Storing a credential requires ``OTARI_SECRET_KEY``.", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "guardrail-credentials" + ], + "raw": "{{baseUrl}}/api/v1/guardrail-credentials" + } + } + }, + { + "name": "Reencrypt Stored Guardrail Credentials", + "request": { + "description": "Re-encrypt stored guardrail credentials with the primary OTARI_SECRET_KEY.\n\nThe guardrail half of the key-rotation procedure; run it alongside\n``POST /api/v1/provider-credentials/reencrypt`` and\n``POST /api/v1/search-tools/reencrypt``. Maps that cannot be decrypted are\nleft untouched and must be recovered by re-entering that guardrail's\ncredentials.", + "header": [], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "guardrail-credentials", + "reencrypt" + ], + "raw": "{{baseUrl}}/api/v1/guardrail-credentials/reencrypt" + } + } + }, + { + "name": "Get Stored Guardrail", + "request": { + "description": "Read one stored guardrail definition. Credentials are returned by name only.", + "header": [], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "guardrail-credentials", + ":name" + ], + "raw": "{{baseUrl}}/api/v1/guardrail-credentials/:name", + "variable": [ + { + "description": "path parameter", + "key": "name", + "value": "" + } + ] + } + } + }, + { + "name": "Update Stored Guardrail", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"create_kwargs\": {\n \"api_key\": \"***\",\n \"endpoint\": \"https://api.lakera.ai/v2/guard\"\n },\n \"enabled\": false\n}" + }, + "description": "Update a stored guardrail definition. Omitted fields are left as they are.\n\nThe row is locked ``FOR UPDATE`` so the ``expected_updated_at`` check and\nthe write it guards are atomic. The definition as it will be *after* the\nupdate is validated, so a change that would leave it unbuildable (clearing a\nrequired argument, or moving to a guardrail that does not take an argument\nthe row carries) is refused rather than stored.", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "guardrail-credentials", + ":name" + ], + "raw": "{{baseUrl}}/api/v1/guardrail-credentials/:name", + "variable": [ + { + "description": "path parameter", + "key": "name", + "value": "" + } + ] + } + } + }, + { + "name": "Delete Stored Guardrail", + "request": { + "description": "Delete a stored guardrail definition, credentials and all.", + "header": [], + "method": "DELETE", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "guardrail-credentials", + ":name" + ], + "raw": "{{baseUrl}}/api/v1/guardrail-credentials/:name", + "variable": [ + { + "description": "path parameter", + "key": "name", + "value": "" + } + ] + } + } + } + ], + "name": "guardrail-credentials" + }, { "item": [ { diff --git a/scripts/sdk_codegen/sdk-endpoints.txt b/scripts/sdk_codegen/sdk-endpoints.txt index 689a4795cd..01575d16b0 100644 --- a/scripts/sdk_codegen/sdk-endpoints.txt +++ b/scripts/sdk_codegen/sdk-endpoints.txt @@ -233,6 +233,13 @@ POST /api/v1/search-tools # not yet wrapped PATCH /api/v1/search-tools/{name} # not yet wrapped DELETE /api/v1/search-tools/{name} # not yet wrapped POST /api/v1/search-tools/reencrypt # not yet wrapped +# Guardrail credentials (stored guardrail definitions, operator-only) +GET /api/v1/guardrail-credentials # not yet wrapped +POST /api/v1/guardrail-credentials # not yet wrapped +GET /api/v1/guardrail-credentials/{name} # not yet wrapped +PATCH /api/v1/guardrail-credentials/{name} # not yet wrapped +DELETE /api/v1/guardrail-credentials/{name} # not yet wrapped +POST /api/v1/guardrail-credentials/reencrypt # not yet wrapped # Settings POST /api/v1/settings/master-key/rotate # not yet wrapped # Tenancy-scoped budgets: an operator surface with no dashboard page yet either, diff --git a/src/gateway/api/main.py b/src/gateway/api/main.py index 17b889293e..8d81a68f57 100644 --- a/src/gateway/api/main.py +++ b/src/gateway/api/main.py @@ -20,6 +20,7 @@ chat, embeddings, files, + guardrail_credentials, health, hooks, hosted_mode, @@ -262,6 +263,10 @@ def _register_core_routers(api: APIRouter, config: GatewayConfig, enabled_featur api.include_router(tool_settings.operator_router) api.include_router(tool_settings.reader_router) api.include_router(search_tools.router) + # The write target for the guardrail picker the tool-settings operator + # router serves, so it sits beside the other credential stores rather than + # with the tenant-scoped organization_guardrails router above. + api.include_router(guardrail_credentials.router) api.include_router(tools.router) # Enabled features, mounted as core routes: no capability gate, because a # listed feature is part of this build. Management plane only, after the diff --git a/src/gateway/api/routes/guardrail_credentials.py b/src/gateway/api/routes/guardrail_credentials.py new file mode 100644 index 0000000000..527a149562 --- /dev/null +++ b/src/gateway/api/routes/guardrail_credentials.py @@ -0,0 +1,343 @@ +"""Stored guardrail definitions for the dashboard (``/api/v1/guardrail-credentials``). + +The write target for the picker at ``GET /api/v1/tool-settings/guardrails/catalog``. +That endpoint lists the guardrails this gateway can run itself and, for each +one, the constructor and per-call arguments it takes; these endpoints store one +of those choices together with the values filled in beside it, so a guardrail is +defined in Otari rather than in a sidecar's YAML. + +Nothing on the request path reads these rows yet. + +Deliberately the same shape as ``/api/v1/search-tools`` and +``/api/v1/provider-credentials``: rows keyed by name, the credentials encrypted +at rest and never returned, a tri-state PATCH, an optimistic-concurrency check +under a row lock, and a re-encryption endpoint for ``OTARI_SECRET_KEY`` +rotation. Operator-gated and never mounted in hybrid, as those two are. + +A response carries the *names* of the stored secrets and never a value, which is +where this parts company with its siblings' ``last4``: a guardrail may hold +several credentials, so which ones are set is the useful answer and the last +four characters of a map are not one. +""" + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.api.deps import get_db, require_deployment_operator +from gateway.exceptions.guardrail_credentials import ( + GuardrailCredentialError, + GuardrailCredentialExistsError, + GuardrailCredentialNotFoundError, +) +from gateway.models.guardrails import GuardrailCredential +from gateway.services.guardrail_credential_service import ( + UNSET, + create_guardrail_credential, + delete_guardrail_credential, + get_guardrail_credential, + get_guardrail_credential_for_update, + list_guardrail_credentials, + reencrypt_guardrail_credentials, + stored_secret_names, + update_guardrail_credential, +) +from gateway.services.secret_box import SecretBoxUnavailableError, SecretDecryptionError + +router = APIRouter( + prefix="/guardrail-credentials", + tags=["guardrail-credentials"], + dependencies=[Depends(require_deployment_operator)], +) + + +class StoredGuardrailSchema(BaseModel): + """A stored guardrail definition. Credentials are never returned, only their names.""" + + name: str + guardrail_name: str + create_kwargs: dict[str, Any] = Field( + default_factory=dict, description="The non-secret constructor arguments, as stored." + ) + create_secrets: dict[str, str] = Field( + default_factory=dict, + description="The stored credentials by name, each masked. Empty when the stored map cannot be read.", + ) + validate_kwargs: dict[str, Any] = Field( + default_factory=dict, description="The per-call arguments, with credential-shaped entries masked." + ) + enabled: bool + created_at: str | None = None + updated_at: str | None = None + decryptable: bool = Field( + default=True, + description=( + "False when the stored credentials cannot be read with the current OTARI_SECRET_KEY. " + "The definition is intact; re-enter its credentials or restore the key that wrote them." + ), + ) + + @classmethod + def from_model(cls, row: GuardrailCredential) -> "StoredGuardrailSchema": + names, decryptable = stored_secret_names(row) + return cls(**row.to_public_dict(secret_names=names), decryptable=decryptable) + + +class CreateGuardrailCredentialRequest(BaseModel): + """Store a guardrail definition. Secret arguments are encrypted and never returned.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "prompt-injection", + "guardrail_name": "lakera_guard", + "create_kwargs": {"api_key": "lak-...", "endpoint": "https://api.lakera.ai/v2/guard"}, + } + } + ) + + name: str = Field( + min_length=1, + max_length=128, + # One path segment: the row is addressed as /{name}, and neither a bare + # slash nor an encoded one reaches that route, so a name carrying either + # would store a row no read, update or delete could ever name again. + pattern=r"^[^/]+$", + description="The profile name a caller sends. One path segment, so it cannot contain '/'.", + ) + guardrail_name: str = Field( + description="The guardrail to build, as listed by GET /tool-settings/guardrails/catalog." + ) + create_kwargs: dict[str, Any] = Field( + default_factory=dict, + description=( + "Constructor arguments, secret and plain together. They are split by the catalog's own " + "secret flag; the secret half is encrypted before it is stored." + ), + ) + validate_kwargs: dict[str, Any] = Field( + default_factory=dict, description="Per-call arguments sent with the text on every check." + ) + enabled: bool = Field(default=True, description="A disabled definition is kept but does not run.") + + +class UpdateGuardrailCredentialRequest(BaseModel): + """Update a stored definition. Omitted fields keep their stored value.""" + + model_config = ConfigDict( + json_schema_extra={ + # A partial update, since every field here is optional and a derived + # example would send placeholders the endpoint refuses. The '***' + # stands for the stored credential the editor was never shown. + "example": { + "create_kwargs": {"api_key": "***", "endpoint": "https://api.lakera.ai/v2/guard"}, + "enabled": False, + } + } + ) + + guardrail_name: str | None = None + create_kwargs: dict[str, Any] | None = Field( + default=None, + description=( + "Replaces the whole map when sent. A value of '***' keeps the stored credential of that " + "name, a new value rotates it, and a credential left out is cleared." + ), + ) + validate_kwargs: dict[str, Any] | None = None + enabled: bool | None = None + expected_updated_at: str | None = Field( + default=None, + description="Optimistic concurrency: if set, the update 412s unless it matches the stored updated_at.", + ) + + +class ReencryptGuardrailCredentialsResponse(BaseModel): + """Result of re-encrypting stored guardrail credentials with the primary secret key.""" + + reencrypted: int = Field(description="Number of stored credential maps re-encrypted.") + unreadable: int = Field( + description="Number of maps left untouched because they could not be decrypted." + ) + + +def _bad_request(exc: Exception) -> HTTPException: + """Every validation refusal answers 400 with the rule that was broken. + + The messages come from the service and name a parameter, a guardrail or a + requirement, never a submitted value: this router is operator-only, but the + error-detail boundary holds here as it does everywhere. + """ + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + + +def _not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(GuardrailCredentialNotFoundError(name)) + ) + + +def _database_error() -> HTTPException: + """A refused write is a 500 saying only that, as in the sibling stores. + + The service has already rolled back by the time this is raised, so nothing + here touches the session. + """ + return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error") + + +@router.get("") +async def list_stored_guardrails( + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[StoredGuardrailSchema]: + """List every stored guardrail definition. + + Credentials are never returned. Each row reports which credentials it holds + by name and whether they can still be read with the current + ``OTARI_SECRET_KEY``; a row that cannot be read is listed rather than + hidden, because the operator is the person who can fix it. + """ + return [StoredGuardrailSchema.from_model(row) for row in await list_guardrail_credentials(db)] + + +@router.post("/reencrypt") +async def reencrypt_stored_guardrail_credentials( + db: Annotated[AsyncSession, Depends(get_db)], +) -> ReencryptGuardrailCredentialsResponse: + """Re-encrypt stored guardrail credentials with the primary OTARI_SECRET_KEY. + + The guardrail half of the key-rotation procedure; run it alongside + ``POST /api/v1/provider-credentials/reencrypt`` and + ``POST /api/v1/search-tools/reencrypt``. Maps that cannot be decrypted are + left untouched and must be recovered by re-entering that guardrail's + credentials. + """ + try: + reencrypted, unreadable = await reencrypt_guardrail_credentials(db) + except SecretBoxUnavailableError as exc: + await db.rollback() + raise _bad_request(exc) from None + except SQLAlchemyError: + raise _database_error() from None + return ReencryptGuardrailCredentialsResponse(reencrypted=reencrypted, unreadable=unreadable) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_stored_guardrail( + request: CreateGuardrailCredentialRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> StoredGuardrailSchema: + """Store a guardrail definition. + + The definition is held to what the catalog says its guardrail accepts: an + unknown guardrail, an argument it does not take, a live object that cannot + be written down, and a required argument nothing else supplies are each a + 400 naming the rule. Storing a credential requires ``OTARI_SECRET_KEY``. + """ + name = request.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A guardrail name cannot be blank.") + if await get_guardrail_credential(db, name) is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(GuardrailCredentialExistsError(name))) + + try: + row = await create_guardrail_credential( + db, + name=name, + guardrail_name=request.guardrail_name, + create_kwargs=request.create_kwargs, + validate_kwargs=request.validate_kwargs, + enabled=request.enabled, + ) + except GuardrailCredentialExistsError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from None + except (GuardrailCredentialError, SecretBoxUnavailableError) as exc: + await db.rollback() + raise _bad_request(exc) from None + except SQLAlchemyError: + raise _database_error() from None + + return StoredGuardrailSchema.from_model(row) + + +@router.get("/{name}") +async def get_stored_guardrail( + name: str, + db: Annotated[AsyncSession, Depends(get_db)], +) -> StoredGuardrailSchema: + """Read one stored guardrail definition. Credentials are returned by name only.""" + row = await get_guardrail_credential(db, name) + if row is None: + raise _not_found(name) + return StoredGuardrailSchema.from_model(row) + + +@router.patch("/{name}") +async def update_stored_guardrail( + name: str, + request: UpdateGuardrailCredentialRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> StoredGuardrailSchema: + """Update a stored guardrail definition. Omitted fields are left as they are. + + The row is locked ``FOR UPDATE`` so the ``expected_updated_at`` check and + the write it guards are atomic. The definition as it will be *after* the + update is validated, so a change that would leave it unbuildable (clearing a + required argument, or moving to a guardrail that does not take an argument + the row carries) is refused rather than stored. + """ + row = await get_guardrail_credential_for_update(db, name) + if row is None: + raise _not_found(name) + if request.expected_updated_at is not None: + current = row.updated_at.isoformat() if row.updated_at else None + if current != request.expected_updated_at: + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail="This guardrail was modified since you loaded it; reload and retry.", + ) + + sent = request.model_fields_set + + def supplied(field: str) -> Any: + """The value the caller sent, or UNSET when they sent nothing for this field. + + An omitted field and an explicit null both keep the stored value. None of + these four is nullable, so there is no third state for a null to mean. + """ + value = getattr(request, field) + return value if field in sent and value is not None else UNSET + + try: + updated = await update_guardrail_credential( + db, + row=row, + guardrail_name=supplied("guardrail_name"), + create_kwargs=supplied("create_kwargs"), + validate_kwargs=supplied("validate_kwargs"), + enabled=supplied("enabled"), + ) + except (GuardrailCredentialError, SecretBoxUnavailableError, SecretDecryptionError) as exc: + await db.rollback() + raise _bad_request(exc) from None + except SQLAlchemyError: + raise _database_error() from None + + return StoredGuardrailSchema.from_model(updated) + + +@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_stored_guardrail( + name: str, + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """Delete a stored guardrail definition, credentials and all.""" + try: + deleted = await delete_guardrail_credential(db, name) + except SQLAlchemyError: + raise _database_error() from None + if not deleted: + raise _not_found(name) diff --git a/src/gateway/api/routes/hybrid_mode.py b/src/gateway/api/routes/hybrid_mode.py index 599a2f34ba..016ad06cee 100644 --- a/src/gateway/api/routes/hybrid_mode.py +++ b/src/gateway/api/routes/hybrid_mode.py @@ -68,6 +68,12 @@ async def providers_disabled() -> None: _raise_disabled() +@router.api_route("/guardrail-credentials/{path:path}", methods=_METHODS) +@router.api_route("/guardrail-credentials", methods=_METHODS) +async def guardrail_credentials_disabled() -> None: + _raise_disabled() + + @router.api_route("/pricing/{path:path}", methods=_METHODS) @router.api_route("/pricing", methods=_METHODS) async def pricing_disabled() -> None: diff --git a/src/gateway/exceptions/guardrail_credentials.py b/src/gateway/exceptions/guardrail_credentials.py new file mode 100644 index 0000000000..374f005cef --- /dev/null +++ b/src/gateway/exceptions/guardrail_credentials.py @@ -0,0 +1,89 @@ +"""The ways a stored guardrail definition can be wrong. + +Every member names a rule the guardrail catalog states, not a rule written here, +so the store can only ever refuse what the picker would not have offered. The +one exception is the pair at the bottom, which are about the row rather than its +arguments. + +No ``status_code`` on the class. That convention belongs to +``services/tenancy/errors.py``, where one registered handler renders a family +too large to wrap in a try block per route; this family has one caller, and +``api/routes/guardrail_credentials.py`` maps it the way +``api/routes/search_tools.py`` maps its own. A message here is shown to a +deployment operator verbatim, so it names the parameter and the alternative and +never the value. +""" + + +class GuardrailCredentialError(Exception): + """A stored guardrail definition could not be written as asked.""" + + +class UnknownGuardrailError(GuardrailCredentialError): + """The named guardrail is not one this gateway can build and call itself. + + Either the name is not an ``any_guardrail`` class at all, or it is one that + works by holding model weights in the process running it, which belongs in + the guardrails service rather than here. + """ + + def __init__(self, guardrail_name: str) -> None: + super().__init__( + f"'{guardrail_name}' is not a guardrail this gateway can run. " + f"Choose one the guardrail catalog lists." + ) + + +class UnknownGuardrailParameterError(GuardrailCredentialError): + """An argument name the guardrail does not take. + + No guardrail accepts ``**kwargs``, so this would fail when the guardrail was + built. It is refused at write time because a secret under an unexpected name + would otherwise be stored in the plain column, the catalog having no ``secret`` + flag to classify it by. + """ + + def __init__(self, guardrail_name: str, parameter: str, stage: str) -> None: + super().__init__(f"'{guardrail_name}' takes no {stage} argument called '{parameter}'.") + + +class UnstorableGuardrailParameterError(GuardrailCredentialError): + """An argument that is a live object rather than a value. + + Upstream types two secrets as JSON because they are already-built Python + objects holding an open connection and refreshed tokens. No row can hold + one, encrypted or not, so the write is refused rather than silently dropped, + which would leave an operator believing a session was saved. + """ + + def __init__(self, guardrail_name: str, parameter: str, alternatives: str) -> None: + super().__init__( + f"'{parameter}' is a live object that cannot be stored. " + f"Configure '{guardrail_name}' with {alternatives} instead." + ) + + +class MissingGuardrailParameterError(GuardrailCredentialError): + """A required argument was not supplied and nothing else can supply it. + + ``requirement`` is a whole sentence rather than a name, because a one-of + constraint has no single name to give: it comes from the catalog's + requirement group, whose wording is upstream's own. + """ + + def __init__(self, guardrail_name: str, requirement: str) -> None: + super().__init__(f"'{guardrail_name}' cannot be stored: {requirement}") + + +class GuardrailCredentialNotFoundError(GuardrailCredentialError): + """No stored guardrail goes by that name.""" + + def __init__(self, name: str) -> None: + super().__init__(f"No stored guardrail '{name}'.") + + +class GuardrailCredentialExistsError(GuardrailCredentialError): + """A stored guardrail already goes by that name.""" + + def __init__(self, name: str) -> None: + super().__init__(f"A stored guardrail '{name}' already exists; use PATCH to update it.") diff --git a/src/gateway/models/guardrails.py b/src/gateway/models/guardrails.py index bc5f166b3e..b7f7f67dd0 100644 --- a/src/gateway/models/guardrails.py +++ b/src/gateway/models/guardrails.py @@ -12,20 +12,22 @@ which exposes ``POST /validate``), and strips the field before forwarding the request upstream. Omit the field entirely → no guardrail runs. -Also holds the organization guardrail tables. +Also holds the stored guardrail definitions and the organization guardrail tables. """ from __future__ import annotations import uuid +from collections.abc import Collection from datetime import UTC, datetime from typing import Any, Literal from pydantic import BaseModel, Field -from sqlalchemy import JSON, ForeignKey, Text, UniqueConstraint, Uuid +from sqlalchemy import JSON, DateTime, ForeignKey, Text, UniqueConstraint, Uuid from sqlalchemy.orm import Mapped, mapped_column from gateway.models.base import Base, UtcDateTime +from gateway.models.secret_fields import REDACTED_VALUE, redact_secret_like_values GuardrailDirection = Literal["input", "output"] @@ -81,6 +83,65 @@ class GuardrailConfig(BaseModel): merged on top of the profile's own ``validate_kwargs`` server-side.""" +class GuardrailCredential(Base): + """A guardrail defined in Otari rather than in a sidecar's YAML. + + ``name`` is the ``profile`` a caller sends, and ``guardrail_name`` is the + ``any_guardrail`` class the catalog offered. The arguments that build it are + split across two columns rather than typed as their own, because the + guardrails a hosted API reaches do not share a secret shape: Bedrock carries + three secret constructor arguments, watsonx two, most one, and two carry + none. A column per credential would have to chase every guardrail upstream + adds. So every secret goes into one ``{name: value}`` map encrypted as a + single string, and the split is made by the catalog's own ``secret`` flag + (``services/guardrail_credential_service.split_create_kwargs``), which needs + no per-guardrail knowledge. + + Nothing on the request path reads this yet. Standalone and hosted only, + never the hybrid platform path. + """ + + __tablename__ = "guardrail_credentials" + + name: Mapped[str] = mapped_column(primary_key=True) + guardrail_name: Mapped[str] = mapped_column() + create_kwargs: Mapped[dict[str, Any]] = mapped_column("create_kwargs", JSON, default=dict) + encrypted_create_secrets: Mapped[str | None] = mapped_column(Text, default=None) + validate_kwargs: Mapped[dict[str, Any]] = mapped_column("validate_kwargs", JSON, default=dict) + enabled: Mapped[bool] = mapped_column(default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + def to_public_dict(self, *, secret_names: Collection[str] = ()) -> dict[str, Any]: + """Serialize for the API. Never includes a secret, only the names of the stored ones. + + ``secret_names`` comes from the caller, because the service is the only + layer that may decrypt. Passing none is what a row whose map will not + decrypt reports, so an unreadable credential costs the operator the names + and not the listing. + + ``validate_kwargs`` is masked by key name the way + ``organization_guardrails`` masks its own: a guardrail class can take its + vendor key as a per-call argument, so the column held in clear is as much + a credential as the encrypted one. That mask reaches top-level keys only, + a gap this inherits along with the pattern and #1125 tracks. + """ + return { + "name": self.name, + "guardrail_name": self.guardrail_name, + "create_kwargs": dict(self.create_kwargs or {}), + "create_secrets": {name: REDACTED_VALUE for name in sorted(secret_names)}, + "validate_kwargs": redact_secret_like_values(self.validate_kwargs) or {}, + "enabled": self.enabled, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + class OrganizationGuardrail(Base): """A guardrail an organization runs over the requests of its workspaces. diff --git a/src/gateway/repositories/guardrail_credentials_repository.py b/src/gateway/repositories/guardrail_credentials_repository.py new file mode 100644 index 0000000000..9fef45e24f --- /dev/null +++ b/src/gateway/repositories/guardrail_credentials_repository.py @@ -0,0 +1,65 @@ +"""Reads and writes over ``guardrail_credentials``. + +Module-level functions rather than a :class:`BaseRepository` subclass: that +generic is built around the SQLModel tenancy tables and their +``Create``/``Update`` schemas, and this is a declarative ``Base`` entity keyed by +a string whose writes are assembled by the service after it has split and +encrypted them. + +Every write here flushes and never commits, as the repository layer does +everywhere: staging makes the change visible to the rest of the transaction +while the commit boundary stays with the service, which is the layer that knows +when a unit of work is complete. +""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.models.guardrails import GuardrailCredential + +MAX_GUARDRAIL_CREDENTIALS = 500 +"""Ceiling on one listing. The table is operator-authored and nothing like this +size, so the bound is here to keep the read from growing unbounded rather than to +paginate anything a deployment has today.""" + + +async def list_guardrail_credentials( + db: AsyncSession, *, limit: int = MAX_GUARDRAIL_CREDENTIALS +) -> list[GuardrailCredential]: + """Stored guardrails ordered by name, bounded by ``limit``.""" + stmt = select(GuardrailCredential).order_by(GuardrailCredential.name).limit(limit) + return list((await db.execute(stmt)).scalars().all()) + + +async def list_encrypted_guardrail_credentials(db: AsyncSession) -> list[GuardrailCredential]: + """Only the rows that carry a secret map, for a re-encryption pass.""" + stmt = select(GuardrailCredential).where(GuardrailCredential.encrypted_create_secrets.is_not(None)) + return list((await db.execute(stmt)).scalars().all()) + + +async def get_guardrail_credential(db: AsyncSession, name: str) -> GuardrailCredential | None: + """The stored guardrail called ``name``, or ``None``.""" + return await db.get(GuardrailCredential, name) + + +async def get_guardrail_credential_for_update(db: AsyncSession, name: str) -> GuardrailCredential | None: + """Like :func:`get_guardrail_credential`, but locks the row ``FOR UPDATE``. + + So an optimistic-concurrency check and the write it guards run under one row + lock, as the provider and search-tool stores do. + """ + stmt = select(GuardrailCredential).where(GuardrailCredential.name == name).with_for_update() + return (await db.execute(stmt)).scalar_one_or_none() + + +async def add_guardrail_credential(db: AsyncSession, row: GuardrailCredential) -> GuardrailCredential: + """Stage a new row and flush it, so a unique-name collision surfaces here.""" + db.add(row) + await db.flush() + return row + + +async def delete_guardrail_credential(db: AsyncSession, row: GuardrailCredential) -> None: + """Stage the row's removal and flush it.""" + await db.delete(row) + await db.flush() diff --git a/src/gateway/services/guardrail_catalog.py b/src/gateway/services/guardrail_catalog.py index d0e53d0378..589b080aa4 100644 --- a/src/gateway/services/guardrail_catalog.py +++ b/src/gateway/services/guardrail_catalog.py @@ -398,3 +398,21 @@ def build_builtin_guardrail_catalog() -> BuiltInGuardrailCatalog: key=lambda spec: spec.display_name.casefold(), ) ) + + +def builtin_guardrail_spec(guardrail_name: str) -> BuiltInGuardrailSpec | None: + """One guardrail's row by the name a stored definition selects, or None. + + The lookup the guardrail store validates against, so that what may be + written is exactly what the picker offered rather than a second list that + would drift from it. ``None`` therefore covers two cases a caller need not + tell apart: a name that is no any-guardrail class, and one that is a class + this gateway cannot build because it would hold model weights. + + Uncached deliberately. It rebuilds the catalog, which is cheap and does no + I/O, and a cache here would outlive a test that swaps the registry under it. + """ + for spec in build_builtin_guardrail_catalog().guardrails: + if spec.guardrail_name == guardrail_name: + return spec + return None diff --git a/src/gateway/services/guardrail_credential_service.py b/src/gateway/services/guardrail_credential_service.py new file mode 100644 index 0000000000..bbc09850ae --- /dev/null +++ b/src/gateway/services/guardrail_credential_service.py @@ -0,0 +1,408 @@ +"""Stored guardrail definitions: validate against the catalog, encrypt, persist. + +The write side of the picker `services/guardrail_catalog.py` publishes. A +definition names a guardrail the catalog offers and carries the arguments that +build and call it; this module holds the submitted arguments to what the catalog +says that guardrail accepts, splits the secrets out, and encrypts them. + +Every rule is read off the catalog rather than written here. A list of names, of +required arguments or of which ones are credentials could only ever drift from +the picker it exists to accept, and the drift would show up as a form offering a +field the store refuses. So the store is wrong exactly when the catalog is. + +There is no in-memory overlay and no refresher, unlike the sibling credential +stores whose shape this otherwise follows: nothing on the request path reads +these rows, so there is nothing to keep warm. This module commits its own writes, +the layering the rest of the codebase uses and the one #1127 records those two as +missing. + +Encryption and decryption happen only here, and only where the result needs a +plaintext. Nothing below this module sees one and nothing above it does either: a +response carries the names of the stored secrets and never a value, and the log +lines carry names and counts. +""" + +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any, Final + +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.exceptions.guardrail_credentials import ( + GuardrailCredentialExistsError, + MissingGuardrailParameterError, + UnknownGuardrailError, + UnknownGuardrailParameterError, + UnstorableGuardrailParameterError, +) +from gateway.log_config import logger +from gateway.models.guardrails import GuardrailCredential +from gateway.models.secret_fields import REDACTED_VALUE, restore_redacted_values +from gateway.repositories import guardrail_credentials_repository as repository +from gateway.services.guardrail_catalog import ( + BuiltInGuardrailSpec, + GuardrailParameterSpec, + builtin_guardrail_spec, +) +from gateway.services.secret_box import ( + SecretBoxUnavailableError, + SecretDecryptionError, + decrypt_secret, + encrypt_secret, +) + + +class _Unset: + """Sentinel type: 'this field was not provided', distinct from an explicit None.""" + + +# A field left at UNSET keeps its stored value. The siblings in +# ``search_tool_store_service`` and ``provider_store_service`` use the same one. +UNSET: Final = _Unset() + + +def _require_spec(guardrail_name: str) -> BuiltInGuardrailSpec: + spec = builtin_guardrail_spec(guardrail_name) + if spec is None: + raise UnknownGuardrailError(guardrail_name) + return spec + + +def _storable_secret_names(parameters: list[GuardrailParameterSpec]) -> list[str]: + """The credential arguments among ``parameters`` that a row can actually hold. + + Taken from one stage's parameters rather than the whole spec, so the + alternatives a refusal offers belong to the stage the refused argument was + sent for. + """ + return [parameter.name for parameter in parameters if parameter.secret and parameter.storable] + + +def _check_stage( + spec: BuiltInGuardrailSpec, + stage: str, + parameters: list[GuardrailParameterSpec], + submitted: dict[str, Any], +) -> None: + """Every submitted name is one the guardrail takes, and one a row can hold.""" + by_name = {parameter.name: parameter for parameter in parameters} + for name in submitted: + parameter = by_name.get(name) + if parameter is None: + raise UnknownGuardrailParameterError(spec.guardrail_name, name, stage) + if not parameter.storable: + alternatives = _storable_secret_names(parameters) + raise UnstorableGuardrailParameterError( + spec.guardrail_name, + name, + " and ".join(f"'{other}'" for other in alternatives) if alternatives else "a value it can store", + ) + + +def _supplied(create_kwargs: dict[str, Any], name: str) -> bool: + """Whether ``name`` carries a value, treating an explicit null as absent. + + A key alone is not a value: ``{"api_key": null}`` would otherwise satisfy a + required argument and store a definition that cannot build. Zero and false + are values, so the test is against ``None`` rather than falsiness. + """ + return create_kwargs.get(name) is not None + + +def _check_required(spec: BuiltInGuardrailSpec, create_kwargs: dict[str, Any]) -> None: + """A required constructor argument is supplied, unless something else supplies it. + + The carve-out is ``env_var``. ``GuardrailParameterSpec.required`` folds in + upstream's effectively-required flag, so an argument a deployment sets + through the environment still reads required; demanding it here would refuse + a row that would have worked. Whether the variable is actually set is not + consulted, because that is a property of the process that builds the + guardrail rather than of the one writing the row. + """ + for parameter in spec.create_parameters: + if not parameter.required or parameter.env_var or _supplied(create_kwargs, parameter.name): + continue + raise MissingGuardrailParameterError( + spec.guardrail_name, f"'{parameter.name}' is required and nothing else supplies it." + ) + + +def _check_requirement_groups(spec: BuiltInGuardrailSpec, create_kwargs: dict[str, Any]) -> None: + """One-of constraints no single argument's required flag can express. + + Skipped where the group names environment variables, for the reason + :func:`_check_required` gives. Every group the catalog carries today names + one, so this refuses nothing yet; it is here so a future group that names + none is caught at write time rather than at build time. + """ + for group in spec.requirement_groups: + if group.env_vars or any(_supplied(create_kwargs, name) for name in group.parameters): + continue + raise MissingGuardrailParameterError(spec.guardrail_name, group.description) + + +def validate_guardrail_kwargs( + guardrail_name: str, + *, + create_kwargs: dict[str, Any], + validate_kwargs: dict[str, Any], +) -> None: + """Hold a definition to what the catalog says its guardrail accepts. + + Raises the matching :mod:`gateway.exceptions.guardrail_credentials` member, + each of which the route answers with a 400 naming the rule that was broken. + """ + spec = _require_spec(guardrail_name) + _check_stage(spec, "create", spec.create_parameters, create_kwargs) + _check_stage(spec, "validate", spec.validate_parameters, validate_kwargs) + _check_required(spec, create_kwargs) + _check_requirement_groups(spec, create_kwargs) + + +def split_create_kwargs(guardrail_name: str, submitted: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Divide constructor arguments into the plain half and the secret half. + + By the catalog's ``secret`` flag alone, so a guardrail upstream adds splits + correctly without an edit here. Validate first: an argument no spec declares + has no flag to be classified by, and would otherwise land in the plain half. + """ + spec = _require_spec(guardrail_name) + secret_names = {parameter.name for parameter in spec.create_parameters if parameter.secret} + plain = {name: value for name, value in submitted.items() if name not in secret_names} + secrets = {name: value for name, value in submitted.items() if name in secret_names} + return plain, secrets + + +def decrypt_create_secrets(row: GuardrailCredential) -> dict[str, Any]: + """The row's secret arguments in clear, or ``{}`` when it stores none. + + Raises ``SecretBoxUnavailableError`` or ``SecretDecryptionError`` when a + stored map cannot be read; the caller decides whether that is fatal. + """ + if not row.encrypted_create_secrets: + return {} + loaded = json.loads(decrypt_secret(row.encrypted_create_secrets)) + return loaded if isinstance(loaded, dict) else {} + + +def stored_secret_names(row: GuardrailCredential) -> tuple[frozenset[str], bool]: + """Which secrets the row holds, and whether they could be read at all. + + A row encrypted under a key the deployment no longer has reports no names + and ``False``, so a listing shows it as needing attention instead of + failing. That is the ``_is_decryptable`` posture of the provider store, with + the names the read needed anyway. + """ + try: + return frozenset(decrypt_create_secrets(row)), True + except (SecretBoxUnavailableError, SecretDecryptionError, ValueError): + return frozenset(), False + + +def _encrypted(secrets: dict[str, Any]) -> str | None: + """One encrypted string for the whole map, or None when there is nothing to hold. + + ``sort_keys`` so the ciphertext of an unchanged map does not depend on the + order the caller sent its arguments in. + """ + if not secrets: + return None + return encrypt_secret(json.dumps(secrets, sort_keys=True)) + + +@asynccontextmanager +async def _write(db: AsyncSession) -> AsyncIterator[None]: + """Roll back whatever the block staged when the database refuses it. + + Wraps the whole write rather than the commit alone, because a repository + call flushes: the insert can fail before a commit is ever reached, and the + session has to be left usable either way. The rollback belongs here because + this is the layer that owns the transaction; the route's part is turning + what comes out into a status, the way the sibling stores do. + """ + try: + yield + except SQLAlchemyError: + await db.rollback() + raise + + +async def list_guardrail_credentials(db: AsyncSession) -> list[GuardrailCredential]: + """Every stored guardrail, ordered by name.""" + return await repository.list_guardrail_credentials(db) + + +async def get_guardrail_credential(db: AsyncSession, name: str) -> GuardrailCredential | None: + """The stored guardrail called ``name``, or ``None``.""" + return await repository.get_guardrail_credential(db, name) + + +async def get_guardrail_credential_for_update(db: AsyncSession, name: str) -> GuardrailCredential | None: + """The stored guardrail called ``name``, locked for the write that follows.""" + return await repository.get_guardrail_credential_for_update(db, name) + + +async def create_guardrail_credential( + db: AsyncSession, + *, + name: str, + guardrail_name: str, + create_kwargs: dict[str, Any], + validate_kwargs: dict[str, Any], + enabled: bool = True, +) -> GuardrailCredential: + """Store a new guardrail definition. + + Validation and encryption both run before anything is staged, so a refused + definition and a deployment with no ``OTARI_SECRET_KEY`` each leave the + session untouched. + """ + validate_guardrail_kwargs(guardrail_name, create_kwargs=create_kwargs, validate_kwargs=validate_kwargs) + plain, secrets = split_create_kwargs(guardrail_name, create_kwargs) + row = GuardrailCredential( + name=name, + guardrail_name=guardrail_name, + create_kwargs=plain, + validate_kwargs=dict(validate_kwargs), + encrypted_create_secrets=_encrypted(secrets), + enabled=enabled, + ) + + try: + async with _write(db): + await repository.add_guardrail_credential(db, row) + await db.commit() + except IntegrityError: + # The route's pre-check races the insert; the primary key is what + # actually decides, as it does in the sibling credential stores. The + # flush raises it before the commit, which is inside the block above, so + # the session is already clean by the time this runs. + raise GuardrailCredentialExistsError(name) from None + + await db.refresh(row) + logger.info("Stored guardrail '%s' (%s) with %d secret(s)", name, guardrail_name, len(secrets)) + return row + + +def _merged_create_kwargs( + row: GuardrailCredential, + target_guardrail: str, + create_kwargs: dict[str, Any] | _Unset, +) -> dict[str, Any] | None: + """The constructor arguments to store, or ``None`` to keep the stored ones. + + The stored map is decrypted only where the answer depends on it. A + replacement carrying no ``***`` stands on its own, and an update that moves + neither the arguments nor the guardrail leaves the ciphertext untouched. So a + deployment whose key no longer reads a row can still disable it, edit its + per-call arguments, and repair it by sending the credentials again, none of + which needs the value it cannot read. + + Raises ``SecretDecryptionError`` in the two cases that do need it: a ``***`` + has nothing to stand for without the stored value, and re-splitting under a + new guardrail needs the whole definition, where dropping the half that will + not decrypt would turn a key problem into silent data loss. + """ + if isinstance(create_kwargs, _Unset): + if target_guardrail == row.guardrail_name: + return None + return {**row.create_kwargs, **decrypt_create_secrets(row)} + if REDACTED_VALUE not in create_kwargs.values(): + return dict(create_kwargs) + return restore_redacted_values(create_kwargs, decrypt_create_secrets(row)) or {} + + +async def update_guardrail_credential( + db: AsyncSession, + *, + row: GuardrailCredential, + guardrail_name: str | _Unset = UNSET, + create_kwargs: dict[str, Any] | _Unset = UNSET, + validate_kwargs: dict[str, Any] | _Unset = UNSET, + enabled: bool | _Unset = UNSET, +) -> GuardrailCredential: + """Update a stored definition. A field left at ``UNSET`` keeps its stored value. + + ``create_kwargs`` when sent replaces the whole map, against the stored + arguments merged back together: a value of ``***`` keeps the stored secret, + a new value rotates it, and a secret the caller left out is cleared. That is + what makes an editor that loads a row, changes the endpoint and submits the + whole object safe, since it was never shown the key it is echoing back. + + Changing ``guardrail_name`` without sending ``create_kwargs`` re-splits the + stored arguments under the new class, so the plain and secret halves can + never be left classified by a guardrail the row no longer names. + """ + target_guardrail = row.guardrail_name if isinstance(guardrail_name, _Unset) else guardrail_name + spec = _require_spec(target_guardrail) + + if isinstance(validate_kwargs, _Unset): + target_validate = dict(row.validate_kwargs or {}) + else: + target_validate = restore_redacted_values(validate_kwargs, row.validate_kwargs) or {} + _check_stage(spec, "validate", spec.validate_parameters, target_validate) + + merged = _merged_create_kwargs(row, target_guardrail, create_kwargs) + secrets: dict[str, Any] | None = None + if merged is not None: + _check_stage(spec, "create", spec.create_parameters, merged) + _check_required(spec, merged) + _check_requirement_groups(spec, merged) + row.create_kwargs, secrets = split_create_kwargs(target_guardrail, merged) + row.encrypted_create_secrets = _encrypted(secrets) + + row.guardrail_name = target_guardrail + row.validate_kwargs = target_validate + if not isinstance(enabled, _Unset): + row.enabled = enabled + + async with _write(db): + await db.commit() + await db.refresh(row) + logger.info( + "Updated stored guardrail '%s' (%s), %s", + row.name, + target_guardrail, + "credentials untouched" if secrets is None else f"{len(secrets)} secret(s)", + ) + return row + + +async def delete_guardrail_credential(db: AsyncSession, name: str) -> bool: + """Delete a stored guardrail. Returns whether it existed.""" + row = await repository.get_guardrail_credential(db, name) + if row is None: + return False + async with _write(db): + await repository.delete_guardrail_credential(db, row) + await db.commit() + logger.info("Deleted stored guardrail '%s'", name) + return True + + +async def reencrypt_guardrail_credentials(db: AsyncSession) -> tuple[int, int]: + """Re-encrypt stored secret maps with the current primary OTARI_SECRET_KEY. + + Returns ``(reencrypted, unreadable)``. The guardrail half of the rotation + procedure; run it alongside the provider and search-tool endpoints. A map + that cannot be decrypted with the configured key set is left untouched and + counted, so the operator can recover it by re-entering that guardrail's + credentials. + """ + reencrypted = 0 + unreadable = 0 + async with _write(db): + for row in await repository.list_encrypted_guardrail_credentials(db): + if row.encrypted_create_secrets is None: + continue + try: + plaintext = decrypt_secret(row.encrypted_create_secrets) + except SecretDecryptionError: + unreadable += 1 + continue + row.encrypted_create_secrets = encrypt_secret(plaintext) + reencrypted += 1 + await db.commit() + return reencrypted, unreadable diff --git a/tests/integration/test_deployment_operator_gate.py b/tests/integration/test_deployment_operator_gate.py index 8605a5e3d8..b69892d86c 100644 --- a/tests/integration/test_deployment_operator_gate.py +++ b/tests/integration/test_deployment_operator_gate.py @@ -54,6 +54,7 @@ ("GET", f"{API_ROOT}/models/discoverable"), ("GET", f"{API_ROOT}/provider-credentials"), ("GET", f"{API_ROOT}/search-tools"), + ("GET", f"{API_ROOT}/guardrail-credentials"), ("GET", f"{API_ROOT}/settings"), ("GET", f"{API_ROOT}/settings/mail"), ("GET", f"{API_ROOT}/settings/maintenance-mode"), diff --git a/tests/integration/test_guardrail_credentials_api.py b/tests/integration/test_guardrail_credentials_api.py new file mode 100644 index 0000000000..cf7762a6df --- /dev/null +++ b/tests/integration/test_guardrail_credentials_api.py @@ -0,0 +1,427 @@ +"""Integration tests for the /api/v1/guardrail-credentials CRUD endpoints. + +A guardrail profile used to be a key in a YAML file inside the guardrails +container, so the picker at GET /tool-settings/guardrails/catalog had nowhere to +write to. These cover the route in: credentials are write-only, a definition is +held to what the catalog says its guardrail accepts, and an editor that echoes +the mask back keeps the key it was never shown. +""" + +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import SQLAlchemyError + +from gateway.core.config import API_ROOT, GatewayConfig +from gateway.services.secret_box import generate_secret_key + +_LAKERA_KEY = "lak-live-notreal-9876" +_ENDPOINT = "https://api.lakera.ai/v2/guard" + + +@pytest.fixture +def test_config(postgres_url: str) -> GatewayConfig: + return GatewayConfig( + database_url=postgres_url, + master_key="test-master-key", + host="127.0.0.1", + port=8000, + auto_migrate=False, + require_pricing=False, + ) + + +@pytest.fixture(autouse=True) +def _secret_key(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + yield + + +def _create(client: TestClient, headers: dict[str, str], **body: Any) -> Any: + payload: dict[str, Any] = { + "name": "prompt-injection", + "guardrail_name": "lakera_guard", + "create_kwargs": {"api_key": _LAKERA_KEY, "endpoint": _ENDPOINT}, + **body, + } + return client.post(f"{API_ROOT}/guardrail-credentials", json=payload, headers=headers) + + +def test_requires_master_key(client: TestClient) -> None: + assert client.get(f"{API_ROOT}/guardrail-credentials").status_code == 401 + assert client.post(f"{API_ROOT}/guardrail-credentials", json={}).status_code == 401 + assert client.get(f"{API_ROOT}/guardrail-credentials/x").status_code == 401 + assert client.patch(f"{API_ROOT}/guardrail-credentials/x", json={}).status_code == 401 + assert client.delete(f"{API_ROOT}/guardrail-credentials/x").status_code == 401 + assert client.post(f"{API_ROOT}/guardrail-credentials/reencrypt").status_code == 401 + + +def test_create_lists_and_never_returns_the_credential( + client: TestClient, master_key_header: dict[str, str] +) -> None: + """The secret goes in, its name comes back, and the value never does.""" + resp = _create(client, master_key_header) + assert resp.status_code == 201, resp.text + body = resp.json() + + assert body["guardrail_name"] == "lakera_guard" + assert body["create_kwargs"] == {"endpoint": _ENDPOINT} + assert body["create_secrets"] == {"api_key": "***"} + assert body["enabled"] is True + assert body["decryptable"] is True + assert _LAKERA_KEY not in resp.text + + listed = client.get(f"{API_ROOT}/guardrail-credentials", headers=master_key_header) + assert listed.status_code == 200 + assert [row["name"] for row in listed.json()] == ["prompt-injection"] + assert _LAKERA_KEY not in listed.text + + one = client.get(f"{API_ROOT}/guardrail-credentials/prompt-injection", headers=master_key_header) + assert one.status_code == 200 + assert one.json()["create_secrets"] == {"api_key": "***"} + assert _LAKERA_KEY not in one.text + + +def test_the_credential_never_reaches_a_log_line( + client: TestClient, master_key_header: dict[str, str], caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level("DEBUG"): + assert _create(client, master_key_header).status_code == 201 + + assert _LAKERA_KEY not in caplog.text + + +def test_echoing_the_mask_back_keeps_the_stored_credential( + client: TestClient, master_key_header: dict[str, str] +) -> None: + """How the dashboard form saves: it loads a row, edits one field, submits all of it. + + It was never shown the key, so it sends the mask for it. Taking that + literally would overwrite a live credential with three asterisks. + """ + assert _create(client, master_key_header).status_code == 201 + + resp = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={"create_kwargs": {"api_key": "***", "endpoint": "https://guard.example.invalid"}}, + headers=master_key_header, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["create_kwargs"] == {"endpoint": "https://guard.example.invalid"} + assert resp.json()["create_secrets"] == {"api_key": "***"} + + # The stored key is still the original one, which only a re-encryption pass + # can show from the outside: it reports one readable map rather than none. + rotated = client.post(f"{API_ROOT}/guardrail-credentials/reencrypt", headers=master_key_header) + assert rotated.json() == {"reencrypted": 1, "unreadable": 0} + + +def test_a_credential_left_out_is_cleared(client: TestClient, master_key_header: dict[str, str]) -> None: + """``create_kwargs`` replaces the whole map, so omission means removal.""" + assert _create(client, master_key_header).status_code == 201 + + resp = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={"create_kwargs": {"endpoint": _ENDPOINT}}, + headers=master_key_header, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["create_secrets"] == {} + + +def test_changing_the_guardrail_resplits_the_stored_arguments( + client: TestClient, master_key_header: dict[str, str] +) -> None: + """``endpoint`` is plain for Lakera and plain for Alinia; ``api_key`` stays secret. + + The point is that the split is redone under the new class rather than + carried over, so it can never be left classified by a guardrail the row no + longer names. + """ + assert _create(client, master_key_header).status_code == 201 + + resp = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={"guardrail_name": "alinia", "create_kwargs": {"api_key": "ali-1", "endpoint": _ENDPOINT}}, + headers=master_key_header, + ) + # Alinia additionally requires detection_config, which nothing supplies. + assert resp.status_code == 400 + assert "detection_config" in resp.json()["detail"] + + resp = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={ + "guardrail_name": "alinia", + "create_kwargs": {"api_key": "ali-1", "endpoint": _ENDPOINT, "detection_config": {"a": 1}}, + }, + headers=master_key_header, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["guardrail_name"] == "alinia" + assert resp.json()["create_kwargs"] == {"endpoint": _ENDPOINT, "detection_config": {"a": 1}} + assert resp.json()["create_secrets"] == {"api_key": "***"} + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + ({"guardrail_name": "not_a_guardrail"}, "not a guardrail this gateway can run"), + ({"guardrail_name": "llama_guard"}, "not a guardrail this gateway can run"), + ({"create_kwargs": {"api_key": "k", "nope": 1}}, "no create argument"), + ( + { + "guardrail_name": "bedrock_guardrails", + "create_kwargs": {"guardrail_identifier": "gr-1", "boto3_session": {}}, + }, + "live object that cannot be stored", + ), + ( + {"guardrail_name": "alinia", "create_kwargs": {"api_key": "k", "endpoint": _ENDPOINT}}, + "detection_config", + ), + ], +) +def test_a_definition_the_catalog_refuses_is_a_400( + client: TestClient, master_key_header: dict[str, str], body: dict[str, Any], expected: str +) -> None: + resp = _create(client, master_key_header, **body) + + assert resp.status_code == 400, resp.text + assert expected in resp.json()["detail"] + assert client.get(f"{API_ROOT}/guardrail-credentials", headers=master_key_header).json() == [] + + +def test_a_name_that_is_not_one_path_segment_is_refused( + client: TestClient, master_key_header: dict[str, str] +) -> None: + """A stored '/' would be a row no route could address again. + + Neither ``/guardrail-credentials/team/prompt`` nor the ``%2F`` spelling + reaches ``/{name}``, so the row would be readable, editable and deletable by + nobody. Refusing at write time is the only place that can be prevented. + """ + assert _create(client, master_key_header, name="team/prompt").status_code == 422 + assert client.get(f"{API_ROOT}/guardrail-credentials", headers=master_key_header).json() == [] + + +def test_a_duplicate_name_is_a_409(client: TestClient, master_key_header: dict[str, str]) -> None: + assert _create(client, master_key_header).status_code == 201 + + resp = _create(client, master_key_header) + assert resp.status_code == 409 + assert "already exists" in resp.json()["detail"] + + +def test_an_unknown_name_is_a_404(client: TestClient, master_key_header: dict[str, str]) -> None: + assert client.get(f"{API_ROOT}/guardrail-credentials/nope", headers=master_key_header).status_code == 404 + assert ( + client.patch(f"{API_ROOT}/guardrail-credentials/nope", json={}, headers=master_key_header).status_code == 404 + ) + assert client.delete(f"{API_ROOT}/guardrail-credentials/nope", headers=master_key_header).status_code == 404 + + +def test_a_stale_expected_updated_at_is_a_412(client: TestClient, master_key_header: dict[str, str]) -> None: + assert _create(client, master_key_header).status_code == 201 + + resp = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={"enabled": False, "expected_updated_at": "2020-01-01T00:00:00+00:00"}, + headers=master_key_header, + ) + assert resp.status_code == 412 + + +def test_a_matching_expected_updated_at_succeeds(client: TestClient, master_key_header: dict[str, str]) -> None: + created = _create(client, master_key_header).json() + + resp = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={"enabled": False, "expected_updated_at": created["updated_at"]}, + headers=master_key_header, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["enabled"] is False + # Disabling keeps the credential, which is the whole point of the flag. + assert resp.json()["create_secrets"] == {"api_key": "***"} + + +def test_delete_removes_it(client: TestClient, master_key_header: dict[str, str]) -> None: + assert _create(client, master_key_header).status_code == 201 + + path = f"{API_ROOT}/guardrail-credentials/prompt-injection" + assert client.delete(path, headers=master_key_header).status_code == 204 + assert client.get(path, headers=master_key_header).status_code == 404 + + +def test_an_unreadable_credential_is_flagged_rather_than_hidden( + client: TestClient, master_key_header: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + """A key the deployment no longer has must not take the whole listing down. + + The operator is the one person who can fix it, so the row is listed with no + credential names and ``decryptable: false``, and a re-encryption pass counts + it as unreadable rather than silently rewriting it. + """ + assert _create(client, master_key_header).status_code == 201 + + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + + listed = client.get(f"{API_ROOT}/guardrail-credentials", headers=master_key_header) + assert listed.status_code == 200 + assert listed.json()[0]["decryptable"] is False + assert listed.json()[0]["create_secrets"] == {} + assert listed.json()[0]["create_kwargs"] == {"endpoint": _ENDPOINT} + + assert client.post(f"{API_ROOT}/guardrail-credentials/reencrypt", headers=master_key_header).json() == { + "reencrypted": 0, + "unreadable": 1, + } + + +def test_a_row_whose_key_is_gone_can_still_be_disabled( + client: TestClient, master_key_header: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Disabling touches neither the arguments nor the guardrail, so it reads no secret. + + ``enabled`` exists to stop a guardrail without losing the configuration it + took to set up, which a lost key must not take away. + """ + assert _create(client, master_key_header).status_code == 201 + + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + + patched = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", json={"enabled": False}, headers=master_key_header + ) + assert patched.status_code == 200 + assert patched.json()["enabled"] is False + # The unreadable map was carried over rather than rewritten under the new key. + assert patched.json()["decryptable"] is False + + +def test_resending_the_credentials_repairs_a_row_whose_key_is_gone( + client: TestClient, master_key_header: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + """The documented recovery: a replacement carrying no mask needs no old key.""" + assert _create(client, master_key_header).status_code == 201 + + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + + patched = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={"create_kwargs": {"api_key": "lak-live-notreal-0001", "endpoint": _ENDPOINT}}, + headers=master_key_header, + ) + assert patched.status_code == 200 + assert patched.json()["decryptable"] is True + assert patched.json()["create_secrets"] == {"api_key": "***"} + + +def test_a_mask_with_no_readable_value_behind_it_is_refused( + client: TestClient, master_key_header: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + """``***`` stands for the stored value, so it needs the key that wrote it.""" + assert _create(client, master_key_header).status_code == 201 + + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + + refused = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", + json={"create_kwargs": {"api_key": "***", "endpoint": _ENDPOINT}}, + headers=master_key_header, + ) + assert refused.status_code == 400 + + +def test_a_rotation_reencrypts_under_the_new_primary_key( + client: TestClient, master_key_header: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + """``OTARI_SECRET_KEY="new,old"`` is the documented rotation; the pass moves the row.""" + assert _create(client, master_key_header).status_code == 201 + + old = generate_secret_key() + monkeypatch.setenv("OTARI_SECRET_KEY", old) + assert _create(client, master_key_header, name="second").status_code == 201 + + new = generate_secret_key() + monkeypatch.setenv("OTARI_SECRET_KEY", f"{new},{old}") + + # One row was written under a key that is no longer in the set at all. + assert client.post(f"{API_ROOT}/guardrail-credentials/reencrypt", headers=master_key_header).json() == { + "reencrypted": 1, + "unreadable": 1, + } + + monkeypatch.setenv("OTARI_SECRET_KEY", new) + listed = {row["name"]: row for row in client.get( + f"{API_ROOT}/guardrail-credentials", headers=master_key_header + ).json()} + assert listed["second"]["decryptable"] is True + assert listed["prompt-injection"]["decryptable"] is False + + +def test_a_refused_commit_is_a_500_and_not_a_crash( + client: TestClient, master_key_header: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Every write runs inside one rollback block, so every write answers the same way. + + The detail says only "Database error", as the sibling stores' does: what the + database objected to is not something a response may carry. + """ + assert _create(client, master_key_header).status_code == 201 + + async def _refuse(*_args: object, **_kwargs: object) -> None: + raise SQLAlchemyError("refused") + + monkeypatch.setattr("sqlalchemy.ext.asyncio.AsyncSession.commit", _refuse) + + created = _create(client, master_key_header, name="second") + patched = client.patch( + f"{API_ROOT}/guardrail-credentials/prompt-injection", json={"enabled": False}, headers=master_key_header + ) + deleted = client.delete(f"{API_ROOT}/guardrail-credentials/prompt-injection", headers=master_key_header) + reencrypted = client.post(f"{API_ROOT}/guardrail-credentials/reencrypt", headers=master_key_header) + + for response in (created, patched, deleted, reencrypted): + assert response.status_code == 500 + assert response.json()["detail"] == "Database error" + + +def test_a_refused_flush_is_a_500_too( + client: TestClient, master_key_header: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + """A repository call flushes, so the insert can fail before a commit is reached. + + The rollback has to cover that window as well, or the route answers from a + session still holding the failed insert. + """ + + async def _refuse(*_args: object, **_kwargs: object) -> None: + raise SQLAlchemyError("refused") + + monkeypatch.setattr("sqlalchemy.ext.asyncio.AsyncSession.flush", _refuse) + + created = _create(client, master_key_header) + assert created.status_code == 500 + assert created.json()["detail"] == "Database error" + + +def test_a_guardrail_with_no_credential_stores_fine( + client: TestClient, master_key_header: dict[str, str] +) -> None: + """``any_llm`` takes no constructor arguments at all, so the map is empty.""" + resp = _create( + client, + master_key_header, + name="judge", + guardrail_name="any_llm", + create_kwargs={}, + validate_kwargs={"policy": "no medical advice"}, + ) + + assert resp.status_code == 201, resp.text + assert resp.json()["create_secrets"] == {} + assert resp.json()["validate_kwargs"] == {"policy": "no medical advice"} diff --git a/tests/integration/test_hybrid_mode_surface.py b/tests/integration/test_hybrid_mode_surface.py index a1a6c9e43c..d650fef766 100644 --- a/tests/integration/test_hybrid_mode_surface.py +++ b/tests/integration/test_hybrid_mode_surface.py @@ -96,6 +96,11 @@ def test_hybrid_mode_disables_dashboard_management_endpoints(monkeypatch: pytest f"{API_ROOT}/settings/mail", f"{API_ROOT}/aliases", f"{API_ROOT}/providers", + # A stored guardrail definition holds a vendor credential for the + # whole deployment, and a hybrid gateway has no local table to keep + # one in. Its own router, so re-mounting it would not show up in any + # other entry here. + f"{API_ROOT}/guardrail-credentials", f"{API_ROOT}/pricing", f"{API_ROOT}/organizations/me", f"{API_ROOT}/workspaces", diff --git a/tests/unit/test_guardrail_credential_service.py b/tests/unit/test_guardrail_credential_service.py new file mode 100644 index 0000000000..a64a144687 --- /dev/null +++ b/tests/unit/test_guardrail_credential_service.py @@ -0,0 +1,205 @@ +"""The rules a stored guardrail definition is held to, and the secret split. + +Everything here is pure: the catalog is a property of the installed +``any_guardrail`` and needs no database and no network. The session-bound half of +the service is covered by ``tests/integration/test_guardrail_credentials_api.py``. + +The guardrails named below are chosen for the shape each one proves, not as a +list worth keeping in step with upstream: Lakera for one env-backed secret, +Bedrock for several secrets and an unstorable one, watsonx for requirement +groups, Alinia for a required argument nothing else can supply, and AnyLlm for a +guardrail with no constructor arguments at all. +""" + +import sys + +import pytest + +from gateway.exceptions.guardrail_credentials import ( + MissingGuardrailParameterError, + UnknownGuardrailError, + UnknownGuardrailParameterError, + UnstorableGuardrailParameterError, +) +from gateway.models.guardrails import GuardrailCredential +from gateway.services.guardrail_credential_service import ( + decrypt_create_secrets, + split_create_kwargs, + stored_secret_names, + validate_guardrail_kwargs, +) +from gateway.services.secret_box import encrypt_secret, generate_secret_key + +_LAKERA = {"api_key": "lak-secret", "endpoint": "https://api.lakera.ai/v2/guard"} + + +def test_a_secret_and_a_plain_argument_go_to_different_halves() -> None: + """The split is the catalog's ``secret`` flag and nothing else.""" + plain, secrets = split_create_kwargs("lakera_guard", _LAKERA) + + assert plain == {"endpoint": "https://api.lakera.ai/v2/guard"} + assert secrets == {"api_key": "lak-secret"} + + +def test_several_secrets_share_one_map() -> None: + """Bedrock has two storable ones; nothing about the shape is per-guardrail.""" + plain, secrets = split_create_kwargs( + "bedrock_guardrails", + { + "guardrail_identifier": "gr-1", + "aws_access_key_id": "AKIA", + "aws_secret_access_key": "shh", + }, + ) + + assert plain == {"guardrail_identifier": "gr-1"} + assert secrets == {"aws_access_key_id": "AKIA", "aws_secret_access_key": "shh"} + + +def test_a_guardrail_with_no_constructor_arguments_splits_to_nothing() -> None: + assert split_create_kwargs("any_llm", {}) == ({}, {}) + + +def test_a_guardrail_the_catalog_does_not_offer_is_refused() -> None: + with pytest.raises(UnknownGuardrailError): + validate_guardrail_kwargs("not_a_guardrail", create_kwargs={}, validate_kwargs={}) + + +def test_a_guardrail_that_would_load_model_weights_is_refused() -> None: + """``llama_guard`` is a real any-guardrail class and still not one this stores. + + It runs by holding weights in the process, so the catalog does not offer it + and the store must not accept it either. Refusing through the same catalog + lookup is what keeps the two from disagreeing. + """ + with pytest.raises(UnknownGuardrailError): + validate_guardrail_kwargs("llama_guard", create_kwargs={}, validate_kwargs={}) + + +def test_an_argument_the_guardrail_does_not_take_is_refused() -> None: + """No guardrail accepts ``**kwargs``, so this would fail when it was built.""" + with pytest.raises(UnknownGuardrailParameterError, match="no create argument"): + validate_guardrail_kwargs("lakera_guard", create_kwargs={**_LAKERA, "nope": 1}, validate_kwargs={}) + + +def test_an_unknown_per_call_argument_is_refused_too() -> None: + with pytest.raises(UnknownGuardrailParameterError, match="no validate argument"): + validate_guardrail_kwargs("patronus", create_kwargs={"evaluators": []}, validate_kwargs={"nope": 1}) + + +def test_a_live_object_cannot_be_stored() -> None: + """``boto3_session`` is a real Bedrock argument, so only the storable flag stops it.""" + with pytest.raises(UnstorableGuardrailParameterError) as caught: + validate_guardrail_kwargs( + "bedrock_guardrails", + create_kwargs={"guardrail_identifier": "gr-1", "boto3_session": {}}, + validate_kwargs={}, + ) + + assert "aws_access_key_id" in str(caught.value) + assert "aws_secret_access_key" in str(caught.value) + + +def test_the_other_live_object_is_refused_and_its_neighbor_is_not() -> None: + with pytest.raises(UnstorableGuardrailParameterError): + validate_guardrail_kwargs("watsonx_guardian", create_kwargs={"api_client": {}}, validate_kwargs={}) + + validate_guardrail_kwargs("watsonx_guardian", create_kwargs={"api_key": "k"}, validate_kwargs={}) + + +def test_a_required_argument_nothing_else_supplies_must_be_given() -> None: + """Alinia's ``detection_config`` is required and names no environment variable.""" + with pytest.raises(MissingGuardrailParameterError, match="detection_config"): + validate_guardrail_kwargs( + "alinia", + create_kwargs={"api_key": "k", "endpoint": "https://example.invalid"}, + validate_kwargs={}, + ) + + +def test_a_null_does_not_satisfy_a_required_argument() -> None: + """A key is not a value. ``{"detection_config": null}`` would build nothing.""" + with pytest.raises(MissingGuardrailParameterError, match="detection_config"): + validate_guardrail_kwargs( + "alinia", + create_kwargs={"api_key": "k", "endpoint": "https://example.invalid", "detection_config": None}, + validate_kwargs={}, + ) + + +def test_a_falsy_value_does_satisfy_a_required_argument() -> None: + """Only ``None`` counts as missing: zero and false are values a guardrail may take.""" + validate_guardrail_kwargs( + "openai_moderation", + create_kwargs={"api_key": "sk-x", "threshold": 0}, + validate_kwargs={}, + ) + + +def test_a_required_argument_an_environment_variable_supplies_may_be_omitted() -> None: + """``lakera_guard.api_key`` reads required because the catalog folds in + upstream's effectively-required flag, but ``LAKERA_API_KEY`` can supply it. + + Demanding it would refuse a legitimate row on a deployment that sets the + variable, which is why the rule carves out a parameter that names one. The + store does not look at the environment to decide: whether the variable is + set belongs to the process that builds the guardrail, not to this one. + """ + validate_guardrail_kwargs("lakera_guard", create_kwargs={}, validate_kwargs={}) + + +def test_a_requirement_group_that_names_environment_variables_is_not_enforced() -> None: + """All three of watsonx's groups name one, so an empty row is accepted.""" + validate_guardrail_kwargs("watsonx_guardian", create_kwargs={}, validate_kwargs={}) + + +def test_a_valid_definition_passes_every_rule() -> None: + validate_guardrail_kwargs("lakera_guard", create_kwargs=_LAKERA, validate_kwargs={}) + validate_guardrail_kwargs( + "openai_moderation", + create_kwargs={"api_key": "sk-x", "threshold": 0.7}, + validate_kwargs={}, + ) + + +def test_a_row_with_no_secret_map_decrypts_to_nothing() -> None: + row = GuardrailCredential(name="n", guardrail_name="any_llm", create_kwargs={}, validate_kwargs={}) + + assert decrypt_create_secrets(row) == {} + assert stored_secret_names(row) == (frozenset(), True) + + +def test_a_stored_map_decrypts_to_its_names(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + row = GuardrailCredential( + name="n", + guardrail_name="lakera_guard", + create_kwargs={}, + validate_kwargs={}, + encrypted_create_secrets=encrypt_secret('{"api_key": "lak-secret"}'), + ) + + assert decrypt_create_secrets(row) == {"api_key": "lak-secret"} + assert stored_secret_names(row) == (frozenset({"api_key"}), True) + + +def test_an_unreadable_map_costs_the_names_and_not_the_listing(monkeypatch: pytest.MonkeyPatch) -> None: + """A wrong key must not take the whole list down, so the row reports no names.""" + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + ciphertext = encrypt_secret('{"api_key": "lak-secret"}') + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + row = GuardrailCredential( + name="n", + guardrail_name="lakera_guard", + create_kwargs={}, + validate_kwargs={}, + encrypted_create_secrets=ciphertext, + ) + + assert stored_secret_names(row) == (frozenset(), False) + + +def test_storing_a_guardrail_never_loads_a_model_backend() -> None: + """The catalog reads an import-free registry, and this module must not widen that.""" + assert "torch" not in sys.modules + assert "transformers" not in sys.modules diff --git a/tests/unit/test_tenancy_schema_chain.py b/tests/unit/test_tenancy_schema_chain.py index 6aefd87eea..33390aed9b 100644 --- a/tests/unit/test_tenancy_schema_chain.py +++ b/tests/unit/test_tenancy_schema_chain.py @@ -64,6 +64,7 @@ _TOKEN_INDEX = "ix_user_email_verification_token" _ALIAS_WIDEN_REVISION = "c1e4a7b9d3f6" +_GUARDRAIL_CREDENTIALS_REVISION = "d3f5a7c9e1b4" _SURVIVALS_REVISION = "d2f5b8c0e4a7" _SURVIVAL_TABLES = ("routing_memory", "router_preferences", "file_objects") @@ -958,3 +959,57 @@ def test_the_migrated_survival_tables_match_their_models(sqlite_at_head: tuple[C declared = SQLModel.metadata.tables[table] migrated = {column["name"] for column in inspect(engine).get_columns(table)} assert migrated == set(declared.columns.keys()), table + + +def test_the_guardrail_credential_table_matches_its_model(sqlite_at_head: tuple[Config, Engine]) -> None: + """Hand-written revision, so nothing else would notice the two drifting apart.""" + _, engine = sqlite_at_head + + declared = SQLModel.metadata.tables["guardrail_credentials"] + migrated = {column["name"] for column in inspect(engine).get_columns("guardrail_credentials")} + assert migrated == set(declared.columns.keys()) + + +def test_a_guardrail_credential_round_trips_on_sqlite(sqlite_at_head: tuple[Config, Engine]) -> None: + """SQLite carries the JSON columns and the boolean default. + + The OSS smoke gate migrates SQLite and the integration suite migrates only + PostgreSQL, so this is the one place the revision is held to both engines. + ``enabled`` is asserted through an insert that omits it, because a server + default is what the column needs to exist for rows written before it did. + """ + _, engine = sqlite_at_head + + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO guardrail_credentials (name, guardrail_name, create_kwargs, validate_kwargs) " + "VALUES (:name, :guardrail_name, :create_kwargs, :validate_kwargs)" + ), + { + "name": "prompt-injection", + "guardrail_name": "lakera_guard", + "create_kwargs": '{"endpoint": "https://api.lakera.ai/v2/guard"}', + "validate_kwargs": "{}", + }, + ) + + with engine.connect() as connection: + row = connection.execute( + text("SELECT guardrail_name, encrypted_create_secrets, enabled FROM guardrail_credentials") + ).one() + + assert row.guardrail_name == "lakera_guard" + assert row.encrypted_create_secrets is None + assert bool(row.enabled) is True + + +def test_downgrading_removes_the_guardrail_credential_table(sqlite_at_head: tuple[Config, Engine]) -> None: + """And upgrading brings it back, so the revision is genuinely reversible.""" + config, engine = sqlite_at_head + + command.downgrade(config, _parent_of(_GUARDRAIL_CREDENTIALS_REVISION)) + assert "guardrail_credentials" not in inspect(engine).get_table_names() + + command.upgrade(config, "head") + assert "guardrail_credentials" in inspect(engine).get_table_names() diff --git a/web/src/client/schema.ts b/web/src/client/schema.ts index 15da5527e4..41bdb837d6 100644 --- a/web/src/client/schema.ts +++ b/web/src/client/schema.ts @@ -1159,6 +1159,100 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/guardrail-credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Stored Guardrails + * @description List every stored guardrail definition. + * + * Credentials are never returned. Each row reports which credentials it holds + * by name and whether they can still be read with the current + * ``OTARI_SECRET_KEY``; a row that cannot be read is listed rather than + * hidden, because the operator is the person who can fix it. + */ + get: operations["guardrail-credentials-list_stored_guardrails"]; + put?: never; + /** + * Create Stored Guardrail + * @description Store a guardrail definition. + * + * The definition is held to what the catalog says its guardrail accepts: an + * unknown guardrail, an argument it does not take, a live object that cannot + * be written down, and a required argument nothing else supplies are each a + * 400 naming the rule. Storing a credential requires ``OTARI_SECRET_KEY``. + */ + post: operations["guardrail-credentials-create_stored_guardrail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/guardrail-credentials/reencrypt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reencrypt Stored Guardrail Credentials + * @description Re-encrypt stored guardrail credentials with the primary OTARI_SECRET_KEY. + * + * The guardrail half of the key-rotation procedure; run it alongside + * ``POST /api/v1/provider-credentials/reencrypt`` and + * ``POST /api/v1/search-tools/reencrypt``. Maps that cannot be decrypted are + * left untouched and must be recovered by re-entering that guardrail's + * credentials. + */ + post: operations["guardrail-credentials-reencrypt_stored_guardrail_credentials"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/guardrail-credentials/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Stored Guardrail + * @description Read one stored guardrail definition. Credentials are returned by name only. + */ + get: operations["guardrail-credentials-get_stored_guardrail"]; + put?: never; + post?: never; + /** + * Delete Stored Guardrail + * @description Delete a stored guardrail definition, credentials and all. + */ + delete: operations["guardrail-credentials-delete_stored_guardrail"]; + options?: never; + head?: never; + /** + * Update Stored Guardrail + * @description Update a stored guardrail definition. Omitted fields are left as they are. + * + * The row is locked ``FOR UPDATE`` so the ``expected_updated_at`` check and + * the write it guards are atomic. The definition as it will be *after* the + * update is validated, so a change that would leave it unbuildable (clearing a + * required argument, or moving to a guardrail that does not take an argument + * the row carries) is refused rather than stored. + */ + patch: operations["guardrail-credentials-update_stored_guardrail"]; + trace?: never; + }; "/api/v1/health": { parameters: { query?: never; @@ -6688,6 +6782,50 @@ export interface components { */ token_limit?: number | null; }; + /** + * CreateGuardrailCredentialRequest + * @description Store a guardrail definition. Secret arguments are encrypted and never returned. + * @example { + * "create_kwargs": { + * "api_key": "lak-...", + * "endpoint": "https://api.lakera.ai/v2/guard" + * }, + * "guardrail_name": "lakera_guard", + * "name": "prompt-injection" + * } + */ + CreateGuardrailCredentialRequest: { + /** + * Create Kwargs + * @description Constructor arguments, secret and plain together. They are split by the catalog's own secret flag; the secret half is encrypted before it is stored. + */ + create_kwargs?: { + [key: string]: unknown; + }; + /** + * Enabled + * @description A disabled definition is kept but does not run. + * @default true + */ + enabled: boolean; + /** + * Guardrail Name + * @description The guardrail to build, as listed by GET /tool-settings/guardrails/catalog. + */ + guardrail_name: string; + /** + * Name + * @description The profile name a caller sends. One path segment, so it cannot contain '/'. + */ + name: string; + /** + * Validate Kwargs + * @description Per-call arguments sent with the text on every check. + */ + validate_kwargs?: { + [key: string]: unknown; + }; + }; /** * CreateKeyRequest * @description Request model for creating a new API key. @@ -10321,6 +10459,22 @@ export interface components { /** Warm */ warm: boolean; }; + /** + * ReencryptGuardrailCredentialsResponse + * @description Result of re-encrypting stored guardrail credentials with the primary secret key. + */ + ReencryptGuardrailCredentialsResponse: { + /** + * Reencrypted + * @description Number of stored credential maps re-encrypted. + */ + reencrypted: number; + /** + * Unreadable + * @description Number of maps left untouched because they could not be decrypted. + */ + unreadable: number; + }; /** * ReencryptProviderCredentialsResponse * @description Result of re-encrypting stored provider keys with the primary secret key. @@ -11065,6 +11219,49 @@ export interface components { */ message: string; }; + /** + * StoredGuardrailSchema + * @description A stored guardrail definition. Credentials are never returned, only their names. + */ + StoredGuardrailSchema: { + /** + * Create Kwargs + * @description The non-secret constructor arguments, as stored. + */ + create_kwargs?: { + [key: string]: unknown; + }; + /** + * Create Secrets + * @description The stored credentials by name, each masked. Empty when the stored map cannot be read. + */ + create_secrets?: { + [key: string]: string; + }; + /** Created At */ + created_at?: string | null; + /** + * Decryptable + * @description False when the stored credentials cannot be read with the current OTARI_SECRET_KEY. The definition is intact; re-enter its credentials or restore the key that wrote them. + * @default true + */ + decryptable: boolean; + /** Enabled */ + enabled: boolean; + /** Guardrail Name */ + guardrail_name: string; + /** Name */ + name: string; + /** Updated At */ + updated_at?: string | null; + /** + * Validate Kwargs + * @description The per-call arguments, with credential-shaped entries masked. + */ + validate_kwargs?: { + [key: string]: unknown; + }; + }; /** * StoredProviderResponse * @description A runtime-stored provider. The API key is never returned, only ``last4``. @@ -11396,6 +11593,39 @@ export interface components { */ token_limit?: number | null; }; + /** + * UpdateGuardrailCredentialRequest + * @description Update a stored definition. Omitted fields keep their stored value. + * @example { + * "create_kwargs": { + * "api_key": "***", + * "endpoint": "https://api.lakera.ai/v2/guard" + * }, + * "enabled": false + * } + */ + UpdateGuardrailCredentialRequest: { + /** + * Create Kwargs + * @description Replaces the whole map when sent. A value of '***' keeps the stored credential of that name, a new value rotates it, and a credential left out is cleared. + */ + create_kwargs?: { + [key: string]: unknown; + } | null; + /** Enabled */ + enabled?: boolean | null; + /** + * Expected Updated At + * @description Optimistic concurrency: if set, the update 412s unless it matches the stored updated_at. + */ + expected_updated_at?: string | null; + /** Guardrail Name */ + guardrail_name?: string | null; + /** Validate Kwargs */ + validate_kwargs?: { + [key: string]: unknown; + } | null; + }; /** * UpdateKeyRequest * @description Request model for updating a key. @@ -14513,6 +14743,174 @@ export interface operations { }; }; }; + "guardrail-credentials-list_stored_guardrails": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredGuardrailSchema"][]; + }; + }; + }; + }; + "guardrail-credentials-create_stored_guardrail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateGuardrailCredentialRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredGuardrailSchema"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "guardrail-credentials-reencrypt_stored_guardrail_credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReencryptGuardrailCredentialsResponse"]; + }; + }; + }; + }; + "guardrail-credentials-get_stored_guardrail": { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredGuardrailSchema"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "guardrail-credentials-delete_stored_guardrail": { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "guardrail-credentials-update_stored_guardrail": { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateGuardrailCredentialRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredGuardrailSchema"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; "health-health_check": { parameters: { query?: never;