From aaa20bf9ab2d211567d3e80ef6c692e762b2bedc Mon Sep 17 00:00:00 2001 From: Dimitris Poulopoulos Date: Wed, 16 Sep 2026 07:06:15 +0300 Subject: [PATCH 1/5] feat(guardrails): add the guardrail_credentials table A guardrail profile is a key in a YAML file inside the guardrails container today, so defining one means editing that file and restarting it. This table holds the definition instead: the any_guardrail class the catalog offered, plus the arguments that build and call it, named by the profile a caller sends. The constructor arguments take two columns rather than one each. The guardrails a hosted API reaches carry between zero and three secret arguments, so a column per credential would chase every guardrail upstream adds; the non-secret ones stay plain and every secret goes into one map encrypted as a single string. Nothing reads the table yet. Refs #1209 Signed-off-by: Dimitris Poulopoulos --- .../d3f5a7c9e1b4_add_guardrail_credentials.py | 49 ++++++++++++++ src/gateway/models/guardrails.py | 65 ++++++++++++++++++- tests/unit/test_tenancy_schema_chain.py | 55 ++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/d3f5a7c9e1b4_add_guardrail_credentials.py 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/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/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() From 43d872bee35fd914ef60cd24e1204ee9418ea08a Mon Sep 17 00:00:00 2001 From: Dimitris Poulopoulos Date: Wed, 16 Sep 2026 07:06:58 +0300 Subject: [PATCH 2/5] feat(guardrails): name the ways a stored guardrail can be wrong The error family and the session access for guardrail_credentials, both leaves with no logic of their own. Every error names a rule the guardrail catalog states rather than one written here, so the store can only refuse what the picker would not have offered. They carry no status_code: that convention belongs to the tenancy slice, where one registered handler serves a family too large to wrap per route, and this family has a single caller that maps it the way the search-tool routes map theirs. The repository flushes and never commits, leaving the commit boundary with the service. Refs #1209 Signed-off-by: Dimitris Poulopoulos --- .../exceptions/guardrail_credentials.py | 84 +++++++++++++++++++ .../guardrail_credentials_repository.py | 65 ++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/gateway/exceptions/guardrail_credentials.py create mode 100644 src/gateway/repositories/guardrail_credentials_repository.py diff --git a/src/gateway/exceptions/guardrail_credentials.py b/src/gateway/exceptions/guardrail_credentials.py new file mode 100644 index 0000000000..c34ba6249c --- /dev/null +++ b/src/gateway/exceptions/guardrail_credentials.py @@ -0,0 +1,84 @@ +"""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.""" + + def __init__(self, guardrail_name: str, requirement: str) -> None: + super().__init__(f"'{guardrail_name}' needs {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/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() From fb700872cabd005d8121b862fd248f4ba3d016a8 Mon Sep 17 00:00:00 2001 From: Dimitris Poulopoulos Date: Wed, 16 Sep 2026 07:10:49 +0300 Subject: [PATCH 3/5] feat(guardrails): validate and encrypt a stored guardrail definition The service behind the store: hold a submitted definition to what the catalog says its guardrail accepts, split the secrets out by the catalog's own flag, and encrypt them as one map. Every rule is read off the catalog rather than written here, so the store is wrong exactly when the picker is. A list of names, of required arguments or of which arguments are credentials could only drift from the form it exists to accept, and the drift would show as a field the form offers and the store refuses. Two rules need a carve-out. A required argument that names an environment variable may be omitted, because the catalog folds upstream's effectively-required flag into required and demanding it would refuse a working row; a requirement group that names one is skipped for the same reason. Neither consults the environment, since whether a variable is set belongs to the process that builds the guardrail and not to the one writing the row. Adds builtin_guardrail_spec to the catalog so the store looks a guardrail up instead of reaching for a private helper. Refs #1209 Signed-off-by: Dimitris Poulopoulos --- .../exceptions/guardrail_credentials.py | 9 +- src/gateway/services/guardrail_catalog.py | 18 + .../services/guardrail_credential_service.py | 408 ++++++++++++++++++ .../unit/test_guardrail_credential_service.py | 205 +++++++++ 4 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 src/gateway/services/guardrail_credential_service.py create mode 100644 tests/unit/test_guardrail_credential_service.py diff --git a/src/gateway/exceptions/guardrail_credentials.py b/src/gateway/exceptions/guardrail_credentials.py index c34ba6249c..374f005cef 100644 --- a/src/gateway/exceptions/guardrail_credentials.py +++ b/src/gateway/exceptions/guardrail_credentials.py @@ -64,10 +64,15 @@ def __init__(self, guardrail_name: str, parameter: str, alternatives: str) -> No class MissingGuardrailParameterError(GuardrailCredentialError): - """A required argument was not supplied and nothing else can supply it.""" + """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}' needs {requirement}.") + super().__init__(f"'{guardrail_name}' cannot be stored: {requirement}") class GuardrailCredentialNotFoundError(GuardrailCredentialError): 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/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 From eca3e9e7ac998833e46e94bf2a0591357c5b093a Mon Sep 17 00:00:00 2001 From: Dimitris Poulopoulos Date: Wed, 16 Sep 2026 07:17:00 +0300 Subject: [PATCH 4/5] feat(api): manage stored guardrail definitions The route in. /api/v1/guardrail-credentials is the write target for the picker at GET /api/v1/tool-settings/guardrails/catalog, so a guardrail chosen there can be filled in and saved. Deliberately the shape of /api/v1/search-tools and /api/v1/provider-credentials: rows keyed by name, credentials encrypted at rest and never returned, a tri-state PATCH under a row lock with an optimistic-concurrency check, and a re-encryption endpoint for OTARI_SECRET_KEY rotation. Operator-gated, and the hybrid stub answers the prefix with the reason rather than a bare 404. Where it parts company with those two is the response. A guardrail may hold several credentials, so the row reports which ones are set by name and each masked, rather than the last four characters of a single key. A row whose map cannot be read is listed with no names and decryptable false, because the operator is the one person who can fix it. Refs #1209 Signed-off-by: Dimitris Poulopoulos --- docs/public/openapi.json | 480 ++++++++++++++++++ docs/public/otari.postman_collection.json | 172 +++++++ scripts/sdk_codegen/sdk-endpoints.txt | 7 + src/gateway/api/main.py | 5 + .../api/routes/guardrail_credentials.py | 343 +++++++++++++ src/gateway/api/routes/hybrid_mode.py | 6 + .../test_deployment_operator_gate.py | 1 + .../test_guardrail_credentials_api.py | 427 ++++++++++++++++ tests/integration/test_hybrid_mode_surface.py | 5 + web/src/client/schema.ts | 398 +++++++++++++++ 10 files changed, 1844 insertions(+) create mode 100644 src/gateway/api/routes/guardrail_credentials.py create mode 100644 tests/integration/test_guardrail_credentials_api.py 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/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/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; From 1399f58b1a4e0eff739272f4ccc5599a711c5714 Mon Sep 17 00:00:00 2001 From: Dimitris Poulopoulos Date: Wed, 16 Sep 2026 07:17:38 +0300 Subject: [PATCH 5/5] docs(guardrails): document stored guardrail definitions Says how a choice from the catalog is saved: one create_kwargs map that Otari splits by the catalog's secret flag, a response that names the stored credentials without carrying one, what the mask means on a PATCH, the four refusals and why a live object is one of them, and what decryptable false asks an operator to do. Closes #1209 Signed-off-by: Dimitris Poulopoulos --- docs/guardrails.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) 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