Skip to content

Commit dbfcf32

Browse files
Banlist Management (#36)
Co-authored-by: AkhileshNegi <akhileshnegi.an3@gmail.com>
1 parent 7249820 commit dbfcf32

38 files changed

+1671
-185
lines changed

.env.example

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,10 @@ SENTRY_DSN=
2121

2222
DOCKER_IMAGE_BACKEND=kaapi-guardrails-backend
2323

24-
# Callback Timeouts (in seconds)
25-
CALLBACK_CONNECT_TIMEOUT=3
26-
CALLBACK_READ_TIMEOUT=10
27-
2824
# require as a env if you want to use doc transformation
2925
OPENAI_API_KEY="<ADD-KEY>"
3026
GUARDRAILS_HUB_API_KEY="<ADD-KEY>"
3127
# SHA-256 hex digest of your bearer token (64 lowercase hex chars)
3228
AUTH_TOKEN="<ADD-HASH-TOKEN>"
29+
KAAPI_AUTH_URL="<ADD-KAAPI-AUTH-URL>"
30+
KAAPI_AUTH_TIMEOUT=5

.env.test.example

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,10 @@ SENTRY_DSN=
2121

2222
DOCKER_IMAGE_BACKEND=kaapi-guardrails-backend
2323

24-
# Callback Timeouts (in seconds)
25-
CALLBACK_CONNECT_TIMEOUT=3
26-
CALLBACK_READ_TIMEOUT=10
27-
2824
# require as a env if you want to use doc transformation
2925
OPENAI_API_KEY="<ADD-KEY>"
3026
GUARDRAILS_HUB_API_KEY="<ADD-KEY>"
3127
# SHA-256 hex digest of your bearer token (64 lowercase hex chars)
3228
AUTH_TOKEN="<ADD-HASH-TOKEN>"
29+
KAAPI_AUTH_URL="<ADD-KAAPI-AUTH-URL>"
30+
KAAPI_AUTH_TIMEOUT=5

.github/workflows/continuous_integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
- name: Install uv
4747
uses: astral-sh/setup-uv@v7
4848
with:
49-
version: "0.4.15"
49+
version: "0.7.2"
5050
enable-cache: true
5151

5252
- name: Install dependencies

backend/app/alembic/versions/001_added_request_log.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
"""Added request log
22
33
Revision ID: 001
4-
Revises:
4+
Revises:
55
Create Date: 2026-01-07 09:42:54.128852
66
77
"""
8+
89
from typing import Sequence, Union
910

1011
from alembic import op
1112
import sqlalchemy as sa
1213
import sqlmodel
1314

14-
1515
# revision identifiers, used by Alembic.
1616
revision: str = "001"
1717
down_revision: str | None = None
@@ -23,6 +23,8 @@ def upgrade() -> None:
2323
op.create_table(
2424
"request_log",
2525
sa.Column("id", sa.Uuid(), nullable=False),
26+
sa.Column("organization_id", sa.Integer(), nullable=False),
27+
sa.Column("project_id", sa.Integer(), nullable=False),
2628
sa.Column("request_id", sa.Uuid(), nullable=False),
2729
sa.Column("response_id", sa.Uuid(), nullable=True),
2830
sa.Column(

backend/app/alembic/versions/002_added_validator_log.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
Create Date: 2026-01-07 09:43:48.002351
66
77
"""
8+
89
from typing import Sequence, Union
910

1011
from alembic import op
1112
import sqlalchemy as sa
1213
import sqlmodel
1314

14-
1515
# revision identifiers, used by Alembic.
1616
revision: str = "002"
1717
down_revision: str = "001"
@@ -23,6 +23,8 @@ def upgrade() -> None:
2323
op.create_table(
2424
"validator_log",
2525
sa.Column("id", sa.Uuid(), nullable=False),
26+
sa.Column("organization_id", sa.Integer(), nullable=False),
27+
sa.Column("project_id", sa.Integer(), nullable=False),
2628
sa.Column("request_id", sa.Uuid(), nullable=False),
2729
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
2830
sa.Column("input", sqlmodel.sql.sqltypes.AutoString(), nullable=False),

backend/app/alembic/versions/003_added_validator_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Create Date: 2026-02-05 09:42:54.128852
66
77
"""
8+
89
from typing import Sequence, Union
910

1011
from alembic import op

backend/app/alembic/versions/004_added_log_indexes.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
Create Date: 2026-02-11 10:45:00.000000
66
77
"""
8+
89
from typing import Sequence, Union
910

1011
from alembic import op
1112

12-
1313
# revision identifiers, used by Alembic.
1414
revision: str = "004"
1515
down_revision: str = "003"
@@ -21,19 +21,31 @@ def upgrade() -> None:
2121
op.create_index("idx_request_log_request_id", "request_log", ["request_id"])
2222
op.create_index("idx_request_log_status", "request_log", ["status"])
2323
op.create_index("idx_request_log_inserted_at", "request_log", ["inserted_at"])
24+
op.create_index(
25+
"idx_request_log_organization_id", "request_log", ["organization_id"]
26+
)
27+
op.create_index("idx_request_log_project_id", "request_log", ["project_id"])
2428

2529
op.create_index("idx_validator_log_request_id", "validator_log", ["request_id"])
2630
op.create_index("idx_validator_log_inserted_at", "validator_log", ["inserted_at"])
2731
op.create_index("idx_validator_log_outcome", "validator_log", ["outcome"])
2832
op.create_index("idx_validator_log_name", "validator_log", ["name"])
33+
op.create_index(
34+
"idx_validator_log_organization_id", "validator_log", ["organization_id"]
35+
)
36+
op.create_index("idx_validator_log_project_id", "validator_log", ["project_id"])
2937

3038

3139
def downgrade() -> None:
3240
op.drop_index("idx_validator_log_inserted_at", table_name="validator_log")
3341
op.drop_index("idx_validator_log_request_id", table_name="validator_log")
3442
op.drop_index("idx_validator_log_outcome", table_name="validator_log")
3543
op.drop_index("idx_validator_log_name", table_name="validator_log")
44+
op.drop_index("idx_validator_log_project_id", table_name="validator_log")
45+
op.drop_index("idx_validator_log_organization_id", table_name="validator_log")
3646

3747
op.drop_index("idx_request_log_inserted_at", table_name="request_log")
3848
op.drop_index("idx_request_log_status", table_name="request_log")
3949
op.drop_index("idx_request_log_request_id", table_name="request_log")
50+
op.drop_index("idx_request_log_organization_id", table_name="request_log")
51+
op.drop_index("idx_request_log_project_id", table_name="request_log")
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Added ban_list table
2+
3+
Revision ID: 005
4+
Revises: 004
5+
Create Date: 2026-02-05 09:42:54.128852
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
from sqlalchemy.dialects import postgresql
13+
import sqlalchemy as sa
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "005"
17+
down_revision = "004"
18+
branch_labels = None
19+
depends_on = None
20+
21+
22+
def upgrade() -> None:
23+
op.create_table(
24+
"ban_list",
25+
sa.Column("id", sa.Uuid(), nullable=False),
26+
sa.Column("name", sa.String(), nullable=False),
27+
sa.Column("description", sa.String(), nullable=False),
28+
sa.Column("organization_id", sa.Integer(), nullable=False),
29+
sa.Column("project_id", sa.Integer(), nullable=False),
30+
sa.Column("domain", sa.String(), nullable=False),
31+
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.false()),
32+
sa.Column(
33+
"banned_words",
34+
postgresql.ARRAY(sa.String(length=100)),
35+
nullable=False,
36+
server_default="{}",
37+
),
38+
sa.Column("created_at", sa.DateTime(), nullable=False),
39+
sa.Column("updated_at", sa.DateTime(), nullable=False),
40+
sa.PrimaryKeyConstraint("id"),
41+
sa.UniqueConstraint(
42+
"name", "organization_id", "project_id", name="uq_ban_list_name_org_project"
43+
),
44+
sa.CheckConstraint(
45+
"coalesce(array_length(banned_words, 1), 0) <= 1000",
46+
name="ck_ban_list_banned_words_max_items",
47+
),
48+
)
49+
50+
op.create_index("idx_ban_list_organization", "ban_list", ["organization_id"])
51+
op.create_index("idx_ban_list_project", "ban_list", ["project_id"])
52+
op.create_index("idx_ban_list_domain", "ban_list", ["domain"])
53+
op.create_index(
54+
"idx_ban_list_is_public_true",
55+
"ban_list",
56+
["is_public"],
57+
postgresql_where=sa.text("is_public = true"),
58+
)
59+
60+
61+
def downgrade() -> None:
62+
op.drop_table("ban_list")

backend/app/api/deps.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from collections.abc import Generator
2+
from dataclasses import dataclass
23
from typing import Annotated
4+
35
import hashlib
46
import secrets
7+
import httpx
58

6-
from fastapi import Depends, HTTPException, status, Security
9+
from fastapi import Depends, Header, HTTPException, Security, status
710
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
811
from sqlmodel import Session
912

@@ -17,34 +20,102 @@ def get_db() -> Generator[Session, None, None]:
1720

1821

1922
SessionDep = Annotated[Session, Depends(get_db)]
23+
24+
25+
# Static bearer token auth for internal routes.
2026
security = HTTPBearer(auto_error=False)
2127

2228

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

2632

33+
def _unauthorized(detail: str) -> HTTPException:
34+
return HTTPException(
35+
status_code=status.HTTP_401_UNAUTHORIZED,
36+
detail=detail,
37+
)
38+
39+
2740
def verify_bearer_token(
2841
credentials: Annotated[
2942
HTTPAuthorizationCredentials | None,
3043
Security(security),
31-
]
32-
):
44+
],
45+
) -> bool:
3346
if credentials is None:
34-
raise HTTPException(
35-
status_code=status.HTTP_401_UNAUTHORIZED,
36-
detail="Missing Authorization header",
37-
)
47+
raise _unauthorized("Missing Authorization header")
3848

3949
if not secrets.compare_digest(
40-
_hash_token(credentials.credentials), settings.AUTH_TOKEN
50+
_hash_token(credentials.credentials),
51+
settings.AUTH_TOKEN,
4152
):
42-
raise HTTPException(
43-
status_code=status.HTTP_401_UNAUTHORIZED,
44-
detail="Invalid authorization token",
45-
)
53+
raise _unauthorized("Invalid authorization token")
4654

4755
return True
4856

4957

5058
AuthDep = Annotated[bool, Depends(verify_bearer_token)]
59+
60+
61+
# Multitenant auth context resolved from X-API-KEY.
62+
@dataclass
63+
class TenantContext:
64+
organization_id: int
65+
project_id: int
66+
67+
68+
def _fetch_tenant_from_backend(token: str) -> TenantContext:
69+
if not settings.KAAPI_AUTH_URL:
70+
raise HTTPException(
71+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
72+
detail="KAAPI_AUTH_URL is not configured",
73+
)
74+
75+
try:
76+
response = httpx.get(
77+
f"{settings.KAAPI_AUTH_URL}/apikeys/verify",
78+
headers={"X-API-KEY": f"ApiKey {token}"},
79+
timeout=settings.KAAPI_AUTH_TIMEOUT,
80+
)
81+
except httpx.RequestError:
82+
raise HTTPException(
83+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
84+
detail="Auth service unavailable",
85+
)
86+
87+
if response.status_code != 200:
88+
raise _unauthorized("Invalid API key")
89+
90+
data = response.json()
91+
if not isinstance(data, dict) or data.get("success") is not True:
92+
raise _unauthorized("Invalid API key")
93+
94+
record = data.get("data")
95+
if not isinstance(record, dict):
96+
raise _unauthorized("Invalid API key")
97+
98+
organization_id = record.get("organization_id")
99+
project_id = record.get("project_id")
100+
if not isinstance(organization_id, int) or not isinstance(project_id, int):
101+
raise _unauthorized("Invalid API key")
102+
103+
return TenantContext(
104+
organization_id=organization_id,
105+
project_id=project_id,
106+
)
107+
108+
109+
def validate_multitenant_key(
110+
x_api_key: Annotated[str | None, Header(alias="X-API-KEY")] = None,
111+
) -> TenantContext:
112+
if not x_api_key or not x_api_key.strip():
113+
raise _unauthorized("Missing X-API-KEY header")
114+
115+
return _fetch_tenant_from_backend(x_api_key.strip())
116+
117+
118+
MultitenantAuthDep = Annotated[
119+
TenantContext,
120+
Depends(validate_multitenant_key),
121+
]

backend/app/api/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import utils, guardrails, validator_configs
3+
from app.api.routes import ban_lists, guardrails, validator_configs, utils
44

55
api_router = APIRouter()
6-
api_router.include_router(utils.router)
6+
api_router.include_router(ban_lists.router)
77
api_router.include_router(guardrails.router)
88
api_router.include_router(validator_configs.router)
9+
api_router.include_router(utils.router)
910

1011
# if settings.ENVIRONMENT == "local":
1112
# api_router.include_router(private.router)

0 commit comments

Comments
 (0)