Skip to content

Commit 4d43e78

Browse files
production hardening
1 parent d814eb3 commit 4d43e78

38 files changed

Lines changed: 2227 additions & 212 deletions

.dockerignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.git
2+
.github
3+
.venv
4+
.pytest_cache
5+
.ruff_cache
6+
.vscode
7+
.idea
8+
__pycache__
9+
*.pyc
10+
*.pyo
11+
*.pyd
12+
.env
13+
.env.*
14+
uploads
15+
tests
16+
.claude

.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
# Database
22
DATABASE_URL_ASYNC=postgresql+asyncpg://username:password@localhost:5432/rag_db
33
DATABASE_URL_SYNC=postgresql://username:password@localhost:5432/rag_db
4+
DB_POOL_SIZE=10
5+
DB_MAX_OVERFLOW=20
46

57
# Redis
68
REDIS_URL=redis://localhost:6379/0
79

10+
# Redis / runtime controls
11+
LOGIN_RATE_LIMIT_ATTEMPTS=10
12+
LOGIN_RATE_LIMIT_WINDOW_SECONDS=60
13+
CHAT_RATE_LIMIT_REQUESTS=30
14+
CHAT_RATE_LIMIT_WINDOW_SECONDS=60
15+
CHAT_IDEMPOTENCY_TTL_SECONDS=300
16+
TOKEN_SESSION_CACHE_TTL_SECONDS=60
17+
VECTOR_SEARCH_CACHE_TTL_SECONDS=300
18+
CHAT_HISTORY_CACHE_TTL_SECONDS=60
19+
DOCUMENT_METADATA_CACHE_TTL_SECONDS=60
20+
READINESS_REQUIRE_REDIS=true
21+
DEFAULT_LIST_LIMIT=50
22+
MAX_LIST_LIMIT=200
23+
MAX_UPLOAD_BYTES=26214400
24+
825
# Security
926
SECRET_KEY=your-secret-key-here
1027
ALGORITHM=HS256

.github/workflows/post-merge.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ jobs:
3636
- name: Vulture (Checking unused/dead production code)
3737
run: uv run vulture app scripts --min-confidence 70
3838

39+
- name: pip-audit (Checking known vulnerable dependencies)
40+
run: uv run pip-audit --ignore-vuln CVE-2025-3000
41+
42+
- name: Bandit (Checking Python security issues)
43+
run: uv run bandit -r app -q -x app/schemas
44+
3945
- name: Thin endpoint lint (Checking endpoint-layer boundaries)
4046
run: uv run python scripts/lint_thin_endpoints.py
4147

Dockerfile

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
FROM python:3.13-slim
2+
3+
ENV PYTHONDONTWRITEBYTECODE=1
4+
ENV PYTHONUNBUFFERED=1
5+
ENV UV_LINK_MODE=copy
6+
ENV UV_COMPILE_BYTECODE=1
7+
8+
WORKDIR /app
9+
10+
RUN apt-get update \
11+
&& apt-get install -y --no-install-recommends \
12+
curl \
13+
libglib2.0-0 \
14+
libgl1 \
15+
libsm6 \
16+
libxext6 \
17+
libxrender1 \
18+
libxcb1 \
19+
&& rm -rf /var/lib/apt/lists/*
20+
21+
RUN pip install --no-cache-dir uv
22+
23+
COPY pyproject.toml uv.lock ./
24+
RUN uv sync --frozen --no-dev
25+
26+
COPY alembic.ini ./
27+
COPY alembic ./alembic
28+
COPY app ./app
29+
COPY scripts ./scripts
30+
COPY docs ./docs
31+
32+
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
33+
USER appuser
34+
35+
EXPOSE 8000
36+
37+
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ Cross-company access on users and documents always returns **403** — never a s
2323
```bash
2424
uv sync --group dev # install all dependencies
2525
cp .env.example .env # configure environment
26-
docker-compose up -d # start PostgreSQL + Redis
27-
alembic upgrade head # apply migrations
28-
uvicorn app.main:app --reload
26+
docker-compose up -d # start API + worker + PostgreSQL + Redis
2927
```
3028

3129
Interactive API docs: `http://localhost:8000/docs`
@@ -60,6 +58,8 @@ Full setup guide: [docs/RUNBOOK.md](docs/RUNBOOK.md)
6058
| `POST` | `/v1/chat/invoke` | Admin or Employee |
6159
| `GET` | `/v1/chat/conversations` | Admin or Employee |
6260
| `GET` | `/v1/chat/conversations/{conversation_id}/messages` | Admin or Employee |
61+
| `GET` | `/healthz` | Public (liveness) |
62+
| `GET` | `/readyz` | Public (readiness) |
6363

6464
---
6565

@@ -94,10 +94,27 @@ Create a `.env` file in the project root:
9494
# Database
9595
DATABASE_URL_ASYNC=postgresql+asyncpg://agenticraguser:agenticragpwd@localhost:5432/rag_db
9696
DATABASE_URL_SYNC=postgresql://agenticraguser:agenticragpwd@localhost:5432/rag_db
97+
DB_POOL_SIZE=10
98+
DB_MAX_OVERFLOW=20
9799
98100
# Redis
99101
REDIS_URL=redis://localhost:6379/0
100102
103+
# Runtime controls
104+
LOGIN_RATE_LIMIT_ATTEMPTS=10
105+
LOGIN_RATE_LIMIT_WINDOW_SECONDS=60
106+
CHAT_RATE_LIMIT_REQUESTS=30
107+
CHAT_RATE_LIMIT_WINDOW_SECONDS=60
108+
CHAT_IDEMPOTENCY_TTL_SECONDS=300
109+
TOKEN_SESSION_CACHE_TTL_SECONDS=60
110+
VECTOR_SEARCH_CACHE_TTL_SECONDS=300
111+
CHAT_HISTORY_CACHE_TTL_SECONDS=60
112+
DOCUMENT_METADATA_CACHE_TTL_SECONDS=60
113+
READINESS_REQUIRE_REDIS=true
114+
DEFAULT_LIST_LIMIT=50
115+
MAX_LIST_LIMIT=200
116+
MAX_UPLOAD_BYTES=26214400
117+
101118
# Security
102119
SECRET_KEY=your-secret-key-here
103120
ALGORITHM=HS256
@@ -125,6 +142,16 @@ LOCAL_UPLOAD_DIR=uploads
125142
# S3_BUCKET_NAME=agentic-rag-api
126143
```
127144

145+
## Container deployment
146+
147+
```bash
148+
# Build
149+
docker build -t agentic-rag-api:latest .
150+
151+
# Run API container (expects DB/Redis reachable from this network)
152+
docker run --rm -p 8000:8000 --env-file .env agentic-rag-api:latest
153+
```
154+
128155
Full variable reference: [docs/RUNBOOK.md#environment-variables-reference](docs/RUNBOOK.md#environment-variables-reference)
129156

130157
---
@@ -137,6 +164,7 @@ Full variable reference: [docs/RUNBOOK.md#environment-variables-reference](docs/
137164
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Component diagram, request lifecycle, singleton model loading, storage backend design, RBAC model |
138165
| [docs/ENDPOINT_ACCESS_MATRIX.md](docs/ENDPOINT_ACCESS_MATRIX.md) | Quick role/access lookup for all endpoints |
139166
| [docs/RUNBOOK.md](docs/RUNBOOK.md) | Full setup, service startup/shutdown, migrations, monitoring, common ops tasks, S3 setup, backup/restore |
167+
| [docs/PRODUCTION_DEPLOYMENT.md](docs/PRODUCTION_DEPLOYMENT.md) | Step-by-step production deployment sequence with Docker, migrations, API/worker startup, and S3 linking |
140168
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Code standards, docstring style, singleton pattern, adding endpoints, migrations, logging conventions |
141169
| [docs/CHANGELOG.md](docs/CHANGELOG.md) | Historical record of architecture, ingestion, RBAC, CI, and chat-history milestones |
142170

app/agent/tools/vector_search.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""LangGraph tool: semantic search over document chunks using pgvector cosine distance."""
22

33
from contextvars import ContextVar, Token
4+
from hashlib import sha256
45

56
from langchain_core.tools import tool
67
from langchain_huggingface import HuggingFaceEmbeddings
@@ -9,6 +10,7 @@
910

1011
from app.core.config import settings
1112
from app.core.logger import get_logger
13+
from app.core.runtime_controls import cache_get_text, cache_set_text
1214
from app.db.models import ChunkType, Document, DocumentChunk
1315
from app.db.session import AsyncSessionLocal
1416

@@ -94,6 +96,13 @@ async def search_documents(query: str) -> str:
9496
)
9597
query_counts[normalized_query] = repeat_count + 1
9698

99+
query_hash = sha256(normalized_query.encode("utf-8")).hexdigest()
100+
cache_key = f"cache:vector:{company_id}:{query_hash}"
101+
cached_context = await cache_get_text(key=cache_key)
102+
if cached_context:
103+
logger.info("Vector search cache hit", extra={"company_id": company_id})
104+
return cached_context
105+
97106
try:
98107
query_vector = _get_embeddings_model().embed_query(query)
99108
except Exception as exc:
@@ -128,4 +137,10 @@ async def search_documents(query: str) -> str:
128137
)
129138
for text, chunk_type in chunks
130139
]
131-
return "\n\n---\n\n".join(formatted_chunks)
140+
response_context = "\n\n---\n\n".join(formatted_chunks)
141+
await cache_set_text(
142+
key=cache_key,
143+
value=response_context,
144+
ttl_seconds=settings.VECTOR_SEARCH_CACHE_TTL_SECONDS,
145+
)
146+
return response_context

app/api/dependencies.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from app.core.config import settings
2323
from app.core.logger import get_logger
24+
from app.core.runtime_controls import token_session_cache_get, token_session_cache_set
2425
from app.db.models import TokenSession, User, UserRole
2526
from app.db.session import get_db
2627

@@ -53,6 +54,10 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession
5354
logger.warning("JWT references unknown user", extra={"username": username})
5455
raise credentials_exception
5556

57+
cached_user_id = await token_session_cache_get(jti=jti)
58+
if cached_user_id == user.id:
59+
return user
60+
5661
session_result = await db.execute(
5762
select(TokenSession).filter(TokenSession.jti == jti, TokenSession.user_id == user.id)
5863
)
@@ -71,6 +76,10 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession
7176
logger.warning("Attempt to use expired token", extra={"user_id": user.id, "jti": jti})
7277
raise credentials_exception
7378

79+
remaining_seconds = int((token_session.expires_at - datetime.now(UTC)).total_seconds())
80+
ttl_seconds = max(1, min(settings.TOKEN_SESSION_CACHE_TTL_SECONDS, remaining_seconds))
81+
await token_session_cache_set(jti=jti, user_id=user.id, ttl_seconds=ttl_seconds)
82+
7483
return user
7584

7685

app/api/v1/endpoints/auth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
@router.post(
1818
"/login",
1919
response_model=TokenResponse,
20-
responses={401: {"description": "Invalid username or password."}},
20+
responses={
21+
401: {"description": "Invalid username or password."},
22+
429: {"description": "Too many login attempts."},
23+
},
2124
)
2225
async def login_for_access_token(
2326
request: Request,

app/api/v1/endpoints/chat.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Chat endpoint — submits a query to the LangGraph RAG agent."""
22

3-
from fastapi import APIRouter, Depends
3+
from fastapi import APIRouter, Depends, Header, Query
44
from sqlalchemy.ext.asyncio import AsyncSession
55

66
from app.api.dependencies import require_company_user
@@ -17,11 +17,13 @@
1717
response_model=ChatResponse,
1818
responses={
1919
403: {"description": "Forbidden — `super_admin` accounts cannot use chat."},
20+
429: {"description": "Too many chat requests."},
2021
502: {"description": "Agent service unavailable — LLM or tool call failed."},
2122
},
2223
)
2324
async def invoke_agent(
2425
request: ChatRequest,
26+
idempotency_key: str | None = Header(default=None, alias="X-Idempotency-Key"),
2527
db: AsyncSession = Depends(get_db),
2628
current_user: User = Depends(require_company_user),
2729
):
@@ -37,6 +39,7 @@ async def invoke_agent(
3739
response_text, conversation_id = await chat_service.invoke_agent(
3840
query=request.query,
3941
conversation_id=request.conversation_id,
42+
idempotency_key=idempotency_key,
4043
db=db,
4144
current_user=current_user,
4245
)
@@ -45,22 +48,38 @@ async def invoke_agent(
4548

4649
@router.get("/conversations", response_model=list[ChatConversationResponse])
4750
async def list_conversations(
51+
limit: int | None = Query(default=None, ge=1, le=500),
52+
offset: int | None = Query(default=None, ge=0),
4853
db: AsyncSession = Depends(get_db),
4954
current_user: User = Depends(require_company_user),
5055
):
5156
"""List persisted chat conversations for the current authenticated user."""
52-
return await chat_service.list_conversations(db=db, current_user=current_user)
57+
service_kwargs: dict = {"db": db, "current_user": current_user}
58+
if limit is not None:
59+
service_kwargs["limit"] = limit
60+
if offset is not None:
61+
service_kwargs["offset"] = offset
62+
return await chat_service.list_conversations(**service_kwargs)
5363

5464

5565
@router.get("/conversations/{conversation_id}/messages", response_model=list[ChatMessageResponse])
5666
async def get_conversation_messages(
5767
conversation_id: str,
68+
limit: int | None = Query(default=None, ge=1, le=500),
69+
offset: int | None = Query(default=None, ge=0),
5870
db: AsyncSession = Depends(get_db),
5971
current_user: User = Depends(require_company_user),
6072
):
6173
"""Get persisted messages for one conversation owned by the current user."""
74+
service_kwargs: dict = {
75+
"conversation_id": conversation_id,
76+
"db": db,
77+
"current_user": current_user,
78+
}
79+
if limit is not None:
80+
service_kwargs["limit"] = limit
81+
if offset is not None:
82+
service_kwargs["offset"] = offset
6283
return await chat_service.get_conversation_messages(
63-
conversation_id=conversation_id,
64-
db=db,
65-
current_user=current_user,
84+
**service_kwargs,
6685
)

app/api/v1/endpoints/companies.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Company management endpoints — Super Admin only."""
22

3-
from fastapi import APIRouter, Depends, status
3+
from fastapi import APIRouter, Depends, Query, status
44
from sqlalchemy.ext.asyncio import AsyncSession
55

66
from app.api.dependencies import require_super_admin
@@ -28,11 +28,18 @@ async def create_company(
2828

2929
@router.get("/", response_model=list[CompanyResponse])
3030
async def list_companies(
31+
limit: int | None = Query(default=None, ge=1, le=500),
32+
offset: int | None = Query(default=None, ge=0),
3133
db: AsyncSession = Depends(get_db),
3234
current_user: User = Depends(require_super_admin),
3335
):
3436
"""Return all companies ordered by name (super admin only)."""
35-
return await companies_service.list_companies(db=db, current_user=current_user)
37+
service_kwargs: dict = {"db": db, "current_user": current_user}
38+
if limit is not None:
39+
service_kwargs["limit"] = limit
40+
if offset is not None:
41+
service_kwargs["offset"] = offset
42+
return await companies_service.list_companies(**service_kwargs)
3643

3744

3845
@router.get("/{company_id}", response_model=CompanyResponse)

0 commit comments

Comments
 (0)