Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ CALLBACK_READ_TIMEOUT=10
# require as a env if you want to use doc transformation
OPENAI_API_KEY="<ADD-KEY>"
GUARDRAILS_HUB_API_KEY="<ADD-KEY>"
AUTH_TOKEN="<ADD-TOKEN>"
# SHA-256 hex digest of your bearer token (64 lowercase hex chars)
AUTH_TOKEN="<ADD-HASH-TOKEN>"
3 changes: 2 additions & 1 deletion .env.test.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ CALLBACK_READ_TIMEOUT=10
# require as a env if you want to use doc transformation
OPENAI_API_KEY="<ADD-KEY>"
GUARDRAILS_HUB_API_KEY="<ADD-KEY>"
AUTH_TOKEN="<ADD-TOKEN>"
# SHA-256 hex digest of your bearer token (64 lowercase hex chars)
AUTH_TOKEN="<ADD-HASH-TOKEN>"
7 changes: 7 additions & 0 deletions .github/workflows/continuous_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jobs:
- name: Making env file
run: |
cp .env.test.example .env.test
AUTH_TOKEN_HASH="${{ secrets.AUTH_TOKEN_SHA256 }}"
if [ -z "$AUTH_TOKEN_HASH" ]; then
AUTH_TOKEN_HASH="$(echo -n "ci-test-token" | sha256sum | awk '{print $1}')"
fi
sed -i "s|AUTH_TOKEN=\"<ADD-HASH-TOKEN>\"|AUTH_TOKEN=\"${AUTH_TOKEN_HASH}\"|" .env.test
sed -i "s|GUARDRAILS_HUB_API_KEY=\"<ADD-KEY>\"|GUARDRAILS_HUB_API_KEY=\"${{ secrets.GUARDRAILS_HUB_API_KEY }}\"|" .env.test
cp .env.test .env

Expand All @@ -49,6 +54,8 @@ jobs:
working-directory: backend

- name: Install Guardrails hub validators
env:
GUARDRAILS_HUB_API_KEY: ${{ secrets.GUARDRAILS_HUB_API_KEY }}
run: |
source .venv/bin/activate
chmod +x scripts/install_guardrails_from_hub.sh
Expand Down
1 change: 0 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ RUN chmod +x /app/scripts/*.sh
COPY ./pyproject.toml ./uv.lock ./alembic.ini /app/

COPY ./app /app/app
COPY ./tests /app/tests

# Sync the project
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
Expand Down
12 changes: 12 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,18 @@ If you don't want to start with the default models and want to remove them / mod

# Guardrails AI

## Auth Token Configuration

`AUTH_TOKEN` must be the SHA-256 hex digest (64 lowercase hex characters) of the bearer token clients will send in the `Authorization: Bearer <token>` header.

Example to generate the digest:

```bash
echo -n "your-plain-text-token" | shasum -a 256
```

Set the resulting digest as `AUTH_TOKEN` in your `.env` / `.env.test`.

## Guardrails AI Setup
1. Ensure that the .env file contains the correct value from `GUARDRAILS_HUB_API_KEY`. The key can be fetched from [here](https://hub.guardrailsai.com/keys).

Expand Down
2 changes: 2 additions & 0 deletions backend/app/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from app.core.config import settings
from app.models import SQLModel

import app.models

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
Expand Down
39 changes: 39 additions & 0 deletions backend/app/alembic/versions/004_added_log_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Added indexes for request_log and validator_log

Revision ID: 004
Revises: 003
Create Date: 2026-02-11 10:45:00.000000

"""
from typing import Sequence, Union

from alembic import op


# revision identifiers, used by Alembic.
revision: str = "004"
down_revision: str = "003"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_index("idx_request_log_request_id", "request_log", ["request_id"])
op.create_index("idx_request_log_status", "request_log", ["status"])
op.create_index("idx_request_log_inserted_at", "request_log", ["inserted_at"])

op.create_index("idx_validator_log_request_id", "validator_log", ["request_id"])
op.create_index("idx_validator_log_inserted_at", "validator_log", ["inserted_at"])
op.create_index("idx_validator_log_outcome", "validator_log", ["outcome"])
op.create_index("idx_validator_log_name", "validator_log", ["name"])


def downgrade() -> None:
op.drop_index("idx_validator_log_inserted_at", table_name="validator_log")
op.drop_index("idx_validator_log_request_id", table_name="validator_log")
op.drop_index("idx_validator_log_outcome", table_name="validator_log")
op.drop_index("idx_validator_log_name", table_name="validator_log")

op.drop_index("idx_request_log_inserted_at", table_name="request_log")
op.drop_index("idx_request_log_status", table_name="request_log")
op.drop_index("idx_request_log_request_id", table_name="request_log")
14 changes: 10 additions & 4 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from collections.abc import Generator
from typing import Annotated
import hashlib
import secrets

from fastapi import Depends, HTTPException, status, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
Expand All @@ -18,6 +20,10 @@ def get_db() -> Generator[Session, None, None]:
security = HTTPBearer(auto_error=False)


def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()


def verify_bearer_token(
credentials: Annotated[
HTTPAuthorizationCredentials | None,
Expand All @@ -30,12 +36,12 @@ def verify_bearer_token(
detail="Missing Authorization header",
)

token = credentials.credentials

if token != settings.AUTH_TOKEN:
if not secrets.compare_digest(
_hash_token(credentials.credentials), settings.AUTH_TOKEN
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
detail="Invalid authorization token",
)

return True
Expand Down
17 changes: 11 additions & 6 deletions backend/app/api/routes/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

from app.api.deps import AuthDep, SessionDep
from app.core.constants import REPHRASE_ON_FAIL_PREFIX
from app.core.config import settings
from app.core.guardrail_controller import build_guard, get_validator_config_models
from app.core.exception_handlers import _safe_error_message
from app.crud.request_log import RequestLogCrud
from app.crud.validator_log import ValidatorLogCrud
from app.schemas.guardrail_config import GuardrailRequest, GuardrailResponse
Expand All @@ -21,7 +23,7 @@
@router.post(
"/", response_model=APIResponse[GuardrailResponse], response_model_exclude_none=True
)
async def run_guardrails(
def run_guardrails(
payload: GuardrailRequest,
session: SessionDep,
_: AuthDep,
Expand All @@ -36,7 +38,7 @@ async def run_guardrails(
return APIResponse.failure_response(error="Invalid request_id")

request_log = request_log_crud.create(request_id, input_text=payload.input)
return await _validate_with_guard(
return _validate_with_guard(
payload.input,
payload.validators,
request_log_crud,
Expand All @@ -47,7 +49,7 @@ async def run_guardrails(


@router.get("/")
async def list_validators(_: AuthDep):
def list_validators(_: AuthDep):
"""
Lists all validators and their parameters directly.
"""
Expand All @@ -67,13 +69,16 @@ async def list_validators(_: AuthDep):

except (KeyError, TypeError) as e:
return APIResponse.failure_response(
error=f"Failed to retrieve schema for validator {model.__name__}: {str(e)}",
error=(
"Failed to retrieve schema for validator "
f"{model.__name__}: {_safe_error_message(e)}"
),
)

return {"validators": validators}


async def _validate_with_guard(
def _validate_with_guard(
data: str,
validators: list,
request_log_crud: RequestLogCrud,
Expand Down Expand Up @@ -162,7 +167,7 @@ def _finalize(
# Case 3: unexpected system / runtime failure
return _finalize(
status=RequestStatus.ERROR,
error_message=str(exc),
error_message=_safe_error_message(exc),
)


Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@


@router.get("/health-check/")
async def health_check() -> bool:
def health_check() -> bool:
return True
8 changes: 8 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from pathlib import Path
import re
from typing import Any, ClassVar, Literal
import warnings

Expand Down Expand Up @@ -75,9 +76,16 @@ def _check_default_secret(self, var_name: str, value: str | None) -> None:
else:
raise ValueError(message)

def _validate_auth_token_hash(self) -> None:
if not re.fullmatch(r"^[0-9a-f]{64}$", self.AUTH_TOKEN):
raise ValueError(
"AUTH_TOKEN must be a SHA-256 hex digest (64 lowercase hex characters)."
)

@model_validator(mode="after")
def _enforce_non_default_secrets(self) -> Self:
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
self._validate_auth_token_hash()
return self


Expand Down
11 changes: 8 additions & 3 deletions backend/app/core/exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
HTTP_500_INTERNAL_SERVER_ERROR,
)

from app.core.config import settings
from app.utils import APIResponse


Expand Down Expand Up @@ -48,6 +49,12 @@ def _format_validation_errors(errors: list[dict]) -> str:
return ". ".join(messages)


def _safe_error_message(exc: Exception) -> str:
if settings.ENVIRONMENT == "production":
return "An unexpected error occurred."
return str(exc) or "An unexpected error occurred."


def register_exception_handlers(app: FastAPI):
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
Expand All @@ -68,7 +75,5 @@ async def http_exception_handler(request: Request, exc: HTTPException):
async def generic_error_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
content=APIResponse.failure_response(
str(exc) or "An unexpected error occurred."
).model_dump(),
content=APIResponse.failure_response(_safe_error_message(exc)).model_dump(),
)
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

from app.models.logging.request_log import RequestLog
from app.models.logging.validator_log import ValidatorLog

from app.models.config.validator_config import ValidatorConfig
15 changes: 6 additions & 9 deletions backend/app/tests/test_validate_with_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
mock_request_log_id = uuid4()


@pytest.mark.asyncio
async def test_validate_with_guard_success():
def test_validate_with_guard_success():
class MockGuard:
def validate(self, data):
return MockResult(validated_output="clean text")
Expand All @@ -23,7 +22,7 @@ def validate(self, data):
"app.api.routes.guardrails.build_guard",
return_value=MockGuard(),
):
response = await _validate_with_guard(
response = _validate_with_guard(
data="hello",
validators=[],
request_log_crud=mock_request_log_crud,
Expand All @@ -37,8 +36,7 @@ def validate(self, data):
assert response.data.response_id is not None


@pytest.mark.asyncio
async def test_validate_with_guard_validation_error():
def test_validate_with_guard_validation_error():
class MockGuard:
def validate(self, data):
return MockResult(validated_output=None)
Expand All @@ -47,7 +45,7 @@ def validate(self, data):
"app.api.routes.guardrails.build_guard",
return_value=MockGuard(),
):
response = await _validate_with_guard(
response = _validate_with_guard(
data="bad text",
validators=[],
request_log_crud=mock_request_log_crud,
Expand All @@ -61,13 +59,12 @@ def validate(self, data):
assert response.error


@pytest.mark.asyncio
async def test_validate_with_guard_exception():
def test_validate_with_guard_exception():
with patch(
"app.api.routes.guardrails.build_guard",
side_effect=Exception("Invalid config"),
):
response = await _validate_with_guard(
response = _validate_with_guard(
data="text",
validators=[],
request_log_crud=mock_request_log_crud,
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dependencies = [
"sentry-sdk[fastapi]<2.0.0,>=1.40.6",
"pyjwt<3.0.0,>=2.8.0",
"asgi-correlation-id>=4.3.4",
"guardrails-ai>=0.7.2",
"guardrails-ai>=0.8.0",
"emoji",
"ftfy",
"presidio_analyzer>=2.2.360",
Expand Down
Loading