Vineetiitg commited on
Commit
b9537ed
·
1 Parent(s): aaa6ad5

feat: add auth guardrails and request observability

Browse files
.env.example CHANGED
@@ -15,3 +15,7 @@ MAX_CONTEXT_CHARS=12000
15
  ENABLE_GUARDRAILS=true
16
  ENABLE_RAG_EVAL=false
17
  MAX_QUERY_LENGTH=2000
 
 
 
 
 
15
  ENABLE_GUARDRAILS=true
16
  ENABLE_RAG_EVAL=false
17
  MAX_QUERY_LENGTH=2000
18
+ RATE_LIMIT_PER_MINUTE=30
19
+ AUTH_ENABLED=false
20
+ ADMIN_API_KEY=change-me-admin
21
+ USER_API_KEY=change-me-user
app/auth/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
app/auth/models.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class UserContext(BaseModel):
5
+ role: str
6
+ user_id: str
7
+
8
+
9
+ class LoginRequest(BaseModel):
10
+ api_key: str
11
+
12
+
13
+ class LoginResponse(BaseModel):
14
+ role: str
15
+ token_type: str = "api_key"
app/auth/security.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Header, HTTPException
2
+
3
+ from app.auth.models import UserContext
4
+ from app.core.config import settings
5
+
6
+
7
+ def resolve_user(x_api_key: str | None = Header(default=None)) -> UserContext:
8
+ if not settings.AUTH_ENABLED:
9
+ return UserContext(role="admin", user_id="local-dev")
10
+ if x_api_key == settings.ADMIN_API_KEY:
11
+ return UserContext(role="admin", user_id="admin")
12
+ if x_api_key == settings.USER_API_KEY:
13
+ return UserContext(role="user", user_id="user")
14
+ raise HTTPException(status_code=401, detail="Invalid or missing API key.")
15
+
16
+
17
+ def require_admin(user: UserContext) -> None:
18
+ if user.role != "admin":
19
+ raise HTTPException(status_code=403, detail="Admin role required.")
app/core/config.py CHANGED
@@ -33,6 +33,12 @@ class Settings(BaseSettings):
33
  ENABLE_GUARDRAILS: bool = True
34
  ENABLE_RAG_EVAL: bool = False
35
  MAX_QUERY_LENGTH: int = 2000
 
 
 
 
 
 
36
 
37
  class Config:
38
  env_file = ".env"
 
33
  ENABLE_GUARDRAILS: bool = True
34
  ENABLE_RAG_EVAL: bool = False
35
  MAX_QUERY_LENGTH: int = 2000
36
+ RATE_LIMIT_PER_MINUTE: int = 30
37
+
38
+ # Auth Config
39
+ AUTH_ENABLED: bool = False
40
+ ADMIN_API_KEY: str = "local-admin-key"
41
+ USER_API_KEY: str = "local-user-key"
42
 
43
  class Config:
44
  env_file = ".env"
app/engine/ingestion.py CHANGED
@@ -15,6 +15,7 @@ from app.engine.document_registry import (
15
  )
16
  from app.engine.indexer import delete_document, index_documents, reset_collection
17
  from app.engine.loaders import SUPPORTED_EXTENSIONS, load_document
 
18
 
19
 
20
  def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
@@ -40,6 +41,11 @@ def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
40
 
41
  doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{path.name}:{content_hash}"))
42
  loaded = load_document(path)
 
 
 
 
 
43
  for document in loaded:
44
  document.metadata["doc_id"] = doc_id
45
  document.metadata["content_hash"] = content_hash
 
15
  )
16
  from app.engine.indexer import delete_document, index_documents, reset_collection
17
  from app.engine.loaders import SUPPORTED_EXTENSIONS, load_document
18
+ from app.guardrails.document import filter_malicious_documents
19
 
20
 
21
  def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
 
41
 
42
  doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{path.name}:{content_hash}"))
43
  loaded = load_document(path)
44
+ loaded, flagged = filter_malicious_documents(loaded)
45
+ for flagged_source in flagged:
46
+ print(f"Skipping document with suspicious instructions: {flagged_source}")
47
+ if not loaded:
48
+ continue
49
  for document in loaded:
50
  document.metadata["doc_id"] = doc_id
51
  document.metadata["content_hash"] = content_hash
app/guardrails/document.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.documents import Document
2
+
3
+ from app.guardrails.input import PROMPT_INJECTION_PATTERNS
4
+
5
+
6
+ def document_has_malicious_instruction(document: Document) -> bool:
7
+ normalized = document.page_content.lower()
8
+ return any(pattern in normalized for pattern in PROMPT_INJECTION_PATTERNS)
9
+
10
+
11
+ def filter_malicious_documents(documents: list[Document]) -> tuple[list[Document], list[str]]:
12
+ safe: list[Document] = []
13
+ flagged: list[str] = []
14
+ for document in documents:
15
+ if document_has_malicious_instruction(document):
16
+ flagged.append(document.metadata.get("source", "unknown"))
17
+ continue
18
+ safe.append(document)
19
+ return safe, flagged
app/guardrails/input.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import time
3
+ from collections import defaultdict, deque
4
+
5
+ from fastapi import HTTPException
6
+
7
+ from app.core.config import settings
8
+
9
+
10
+ _requests_by_client: dict[str, deque[float]] = defaultdict(deque)
11
+
12
+
13
+ PROMPT_INJECTION_PATTERNS = [
14
+ "ignore previous instructions",
15
+ "ignore the instructions above",
16
+ "forget all previous",
17
+ "system prompt",
18
+ "developer message",
19
+ "bypass system",
20
+ "disregard instructions",
21
+ "reveal hidden",
22
+ ]
23
+
24
+
25
+ def validate_query(query: str) -> None:
26
+ if not query.strip():
27
+ raise HTTPException(status_code=400, detail="Query cannot be empty.")
28
+ if len(query) > settings.MAX_QUERY_LENGTH:
29
+ raise HTTPException(status_code=400, detail="Query is too long.")
30
+ normalized = query.lower()
31
+ for pattern in PROMPT_INJECTION_PATTERNS:
32
+ if pattern in normalized:
33
+ raise HTTPException(status_code=400, detail="Prompt injection attempt detected.")
34
+
35
+
36
+ def enforce_rate_limit(client_id: str) -> None:
37
+ if settings.RATE_LIMIT_PER_MINUTE <= 0:
38
+ return
39
+ now = time.time()
40
+ bucket = _requests_by_client[client_id]
41
+ while bucket and now - bucket[0] > 60:
42
+ bucket.popleft()
43
+ if len(bucket) >= settings.RATE_LIMIT_PER_MINUTE:
44
+ raise HTTPException(status_code=429, detail="Rate limit exceeded.")
45
+ bucket.append(now)
46
+
47
+
48
+ def contains_pii(text: str) -> bool:
49
+ email = r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"
50
+ phone = r"\b(?:\+?\d{1,3}[-.\s]?)?(?:\d{10}|\d{3}[-.\s]\d{3}[-.\s]\d{4})\b"
51
+ return bool(re.search(email, text) or re.search(phone, text))
app/guardrails/output.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+
4
+ def redact_sensitive_data(text: str) -> str:
5
+ text = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[REDACTED_EMAIL]", text)
6
+ text = re.sub(r"\b(?:\+?\d{1,3}[-.\s]?)?(?:\d{10}|\d{3}[-.\s]\d{3}[-.\s]\d{4})\b", "[REDACTED_PHONE]", text)
7
+ return text
8
+
9
+
10
+ def refuses_system_prompt_leak(text: str) -> bool:
11
+ normalized = text.lower()
12
+ return "system prompt" in normalized or "developer message" in normalized
app/main.py CHANGED
@@ -1,18 +1,24 @@
1
  import asyncio
2
- import time
3
- from fastapi import FastAPI, HTTPException
4
  from fastapi.responses import StreamingResponse
5
  from pydantic import BaseModel
6
  from guardrails import Guard
7
  from langchain_core.prompts import PromptTemplate
8
  from langchain_ollama import ChatOllama
9
 
 
 
10
  from app.core.config import settings
11
  from app.core.dependencies import check_ollama, check_qdrant
12
  from app.core.logging import configure_logging, logger
 
 
13
  from app.engine.context_builder import build_context, format_sources
14
  from app.graph.workflow import compile_workflow
 
 
15
  from app.guardrails.validators import DetectPromptInjection
 
16
 
17
  configure_logging()
18
  app = FastAPI(title=settings.PROJECT_NAME)
@@ -34,6 +40,10 @@ class ChatResponse(BaseModel):
34
  answer: str
35
  sources: list[SourceCitation] = []
36
 
 
 
 
 
37
  @app.get("/health")
38
  async def health_endpoint():
39
  return {"status": "ok", "project": settings.PROJECT_NAME}
@@ -48,11 +58,41 @@ async def ready_endpoint():
48
  "qdrant": qdrant,
49
  }
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @app.post("/chat", response_model=ChatResponse)
52
- async def chat_endpoint(request: ChatRequest):
53
- started_at = time.perf_counter()
54
- if len(request.query) > settings.MAX_QUERY_LENGTH:
55
- raise HTTPException(status_code=400, detail="Query is too long.")
56
  if settings.ENABLE_GUARDRAILS:
57
  try:
58
  input_guard.validate(request.query)
@@ -61,19 +101,20 @@ async def chat_endpoint(request: ChatRequest):
61
 
62
  initial_state = {"question": request.query, "run_count": 0}
63
  try:
64
- final_state = rag_agent.invoke(initial_state)
65
- answer = final_state.get("generation", "Unable to compile answer.")
 
66
  sources = final_state.get("sources", [])
67
  except Exception as e:
68
  raise HTTPException(status_code=500, detail=str(e))
69
 
70
- logger.info("chat completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
71
  return ChatResponse(query=request.query, answer=answer, sources=sources)
72
 
73
  @app.post("/chat/stream")
74
- async def chat_stream_endpoint(request: ChatRequest):
75
- if len(request.query) > settings.MAX_QUERY_LENGTH:
76
- raise HTTPException(status_code=400, detail="Query is too long.")
77
  if settings.ENABLE_GUARDRAILS:
78
  try:
79
  input_guard.validate(request.query)
@@ -81,9 +122,10 @@ async def chat_stream_endpoint(request: ChatRequest):
81
  raise HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
82
 
83
  async def token_generator():
84
- started_at = time.perf_counter()
85
  initial_state = {"question": request.query, "run_count": 0}
86
- final_state = rag_agent.invoke(initial_state)
 
87
  documents = final_state.get("documents", [])
88
 
89
  if not documents:
@@ -102,9 +144,9 @@ async def chat_stream_endpoint(request: ChatRequest):
102
 
103
  async for chunk in rag_chain.astream({"context": context, "question": request.query}):
104
  if chunk.content:
105
- yield chunk.content
106
  await asyncio.sleep(0.01)
107
  yield format_sources(documents)
108
- logger.info("stream completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
109
 
110
  return StreamingResponse(token_generator(), media_type="text/event-stream")
 
1
  import asyncio
2
+ from fastapi import Depends, FastAPI, HTTPException, Request
 
3
  from fastapi.responses import StreamingResponse
4
  from pydantic import BaseModel
5
  from guardrails import Guard
6
  from langchain_core.prompts import PromptTemplate
7
  from langchain_ollama import ChatOllama
8
 
9
+ from app.auth.models import LoginRequest, LoginResponse, UserContext
10
+ from app.auth.security import require_admin, resolve_user
11
  from app.core.config import settings
12
  from app.core.dependencies import check_ollama, check_qdrant
13
  from app.core.logging import configure_logging, logger
14
+ from app.engine.document_registry import load_registry
15
+ from app.engine.ingestion import delete_indexed_document, ingest_documents, reset_index
16
  from app.engine.context_builder import build_context, format_sources
17
  from app.graph.workflow import compile_workflow
18
+ from app.guardrails.input import enforce_rate_limit, validate_query
19
+ from app.guardrails.output import redact_sensitive_data
20
  from app.guardrails.validators import DetectPromptInjection
21
+ from app.observability.metrics import RequestMetrics, log_request_metrics, timed_stage
22
 
23
  configure_logging()
24
  app = FastAPI(title=settings.PROJECT_NAME)
 
40
  answer: str
41
  sources: list[SourceCitation] = []
42
 
43
+ class IngestionRequest(BaseModel):
44
+ data_dir: str = "data/docs"
45
+ force: bool = False
46
+
47
  @app.get("/health")
48
  async def health_endpoint():
49
  return {"status": "ok", "project": settings.PROJECT_NAME}
 
58
  "qdrant": qdrant,
59
  }
60
 
61
+ @app.post("/auth/login", response_model=LoginResponse)
62
+ async def login_endpoint(request: LoginRequest):
63
+ if request.api_key == settings.ADMIN_API_KEY:
64
+ return LoginResponse(role="admin")
65
+ if request.api_key == settings.USER_API_KEY:
66
+ return LoginResponse(role="user")
67
+ raise HTTPException(status_code=401, detail="Invalid API key.")
68
+
69
+ @app.get("/documents")
70
+ async def documents_endpoint(user: UserContext = Depends(resolve_user)):
71
+ return {"documents": list(load_registry().values()), "role": user.role}
72
+
73
+ @app.post("/admin/ingest")
74
+ async def admin_ingest_endpoint(request: IngestionRequest, user: UserContext = Depends(resolve_user)):
75
+ require_admin(user)
76
+ ingest_documents(data_dir=request.data_dir, force=request.force)
77
+ return {"status": "ok", "message": "Ingestion completed."}
78
+
79
+ @app.delete("/admin/documents/{doc_id}")
80
+ async def admin_delete_document_endpoint(doc_id: str, user: UserContext = Depends(resolve_user)):
81
+ require_admin(user)
82
+ delete_indexed_document(doc_id)
83
+ return {"status": "ok", "doc_id": doc_id}
84
+
85
+ @app.post("/admin/reset")
86
+ async def admin_reset_endpoint(user: UserContext = Depends(resolve_user)):
87
+ require_admin(user)
88
+ reset_index()
89
+ return {"status": "ok", "message": "Index reset."}
90
+
91
  @app.post("/chat", response_model=ChatResponse)
92
+ async def chat_endpoint(request: ChatRequest, http_request: Request, user: UserContext = Depends(resolve_user)):
93
+ metrics = RequestMetrics()
94
+ enforce_rate_limit(http_request.client.host if http_request.client else user.user_id)
95
+ validate_query(request.query)
96
  if settings.ENABLE_GUARDRAILS:
97
  try:
98
  input_guard.validate(request.query)
 
101
 
102
  initial_state = {"question": request.query, "run_count": 0}
103
  try:
104
+ with timed_stage(metrics, "rag_workflow"):
105
+ final_state = rag_agent.invoke(initial_state)
106
+ answer = redact_sensitive_data(final_state.get("generation", "Unable to compile answer."))
107
  sources = final_state.get("sources", [])
108
  except Exception as e:
109
  raise HTTPException(status_code=500, detail=str(e))
110
 
111
+ log_request_metrics(metrics, route="/chat", sources=len(sources), model=settings.OLLAMA_MODEL)
112
  return ChatResponse(query=request.query, answer=answer, sources=sources)
113
 
114
  @app.post("/chat/stream")
115
+ async def chat_stream_endpoint(request: ChatRequest, http_request: Request, user: UserContext = Depends(resolve_user)):
116
+ enforce_rate_limit(http_request.client.host if http_request.client else user.user_id)
117
+ validate_query(request.query)
118
  if settings.ENABLE_GUARDRAILS:
119
  try:
120
  input_guard.validate(request.query)
 
122
  raise HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
123
 
124
  async def token_generator():
125
+ metrics = RequestMetrics()
126
  initial_state = {"question": request.query, "run_count": 0}
127
+ with timed_stage(metrics, "rag_workflow"):
128
+ final_state = rag_agent.invoke(initial_state)
129
  documents = final_state.get("documents", [])
130
 
131
  if not documents:
 
144
 
145
  async for chunk in rag_chain.astream({"context": context, "question": request.query}):
146
  if chunk.content:
147
+ yield redact_sensitive_data(chunk.content)
148
  await asyncio.sleep(0.01)
149
  yield format_sources(documents)
150
+ log_request_metrics(metrics, route="/chat/stream", sources=len(documents), model=settings.OLLAMA_MODEL)
151
 
152
  return StreamingResponse(token_generator(), media_type="text/event-stream")
app/observability/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
app/observability/metrics.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import time
3
+ import uuid
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass, field
6
+
7
+ logger = logging.getLogger("support_docs_copilot.metrics")
8
+
9
+
10
+ @dataclass
11
+ class RequestMetrics:
12
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
13
+ started_at: float = field(default_factory=time.perf_counter)
14
+ stages: dict[str, float] = field(default_factory=dict)
15
+
16
+ def total_ms(self) -> float:
17
+ return round((time.perf_counter() - self.started_at) * 1000, 2)
18
+
19
+
20
+ @contextmanager
21
+ def timed_stage(metrics: RequestMetrics, stage: str):
22
+ started = time.perf_counter()
23
+ try:
24
+ yield
25
+ finally:
26
+ metrics.stages[stage] = round((time.perf_counter() - started) * 1000, 2)
27
+
28
+
29
+ def log_request_metrics(metrics: RequestMetrics, **extra) -> None:
30
+ logger.info(
31
+ "request_id=%s total_ms=%s stages=%s extra=%s",
32
+ metrics.request_id,
33
+ metrics.total_ms(),
34
+ metrics.stages,
35
+ extra,
36
+ )