-
Notifications
You must be signed in to change notification settings - Fork 58
feat(guardrails): store guardrail definitions in the database #1211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aaa20bf
43d872b
fb70087
eca3e9e
1399f58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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": "***"}, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd use a specific object, like |
||
| "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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, I don't like magic strings. IMHO something that can be separated by type. |
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a future PR. Somehow detect if suspected secrets would be passed in the validate kwargs.