Protect private API routes and scope agent data

This commit is contained in:
Hamza Ayed
2026-10-03 00:34:49 +03:00
parent 1c1f662850
commit b0cd825838
17 changed files with 313 additions and 95 deletions
+16 -6
View File
@@ -116,6 +116,7 @@ def initialize_database() -> None:
CREATE TABLE IF NOT EXISTS agent_audit_events (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
tool TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER NOT NULL,
@@ -138,6 +139,14 @@ def initialize_database() -> None:
}
if "password_hash" not in identity_columns:
connection.execute("ALTER TABLE user_identities ADD COLUMN password_hash TEXT")
audit_columns = {
row["name"] for row in connection.execute("PRAGMA table_info(agent_audit_events)")
}
if "user_id" not in audit_columns:
connection.execute("ALTER TABLE agent_audit_events ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE CASCADE")
connection.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_audit_user_created ON agent_audit_events(user_id, created_at DESC)"
)
def ensure_user(user_id: str) -> None:
@@ -351,29 +360,30 @@ def record_agent_audit_event(
method: str,
status_code: int,
duration_ms: int,
user_id: str | None,
) -> None:
"""Record agent route metadata only; never persist prompts or file contents."""
with _connect() as connection:
connection.execute(
"""
INSERT INTO agent_audit_events(id, tool, method, status_code, duration_ms)
VALUES (?, ?, ?, ?, ?)
INSERT INTO agent_audit_events(id, user_id, tool, method, status_code, duration_ms)
VALUES (?, ?, ?, ?, ?, ?)
""",
(event_id, tool, method, status_code, duration_ms),
(event_id, user_id, tool, method, status_code, duration_ms),
)
def list_agent_audit_events(limit: int = 50) -> list[dict[str, Any]]:
def list_agent_audit_events(user_id: str, limit: int = 50) -> list[dict[str, Any]]:
bounded_limit = max(1, min(limit, 200))
with _connect() as connection:
rows = connection.execute(
"""
SELECT id, tool, method, status_code, duration_ms, created_at
FROM agent_audit_events
FROM agent_audit_events WHERE user_id=?
ORDER BY created_at DESC, rowid DESC
LIMIT ?
""",
(bounded_limit,),
(user_id, bounded_limit),
).fetchall()
return [dict(row) for row in rows]
+83 -49
View File
@@ -62,6 +62,30 @@ app.add_middleware(
)
def get_authenticated_user_id(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> str:
if credentials is None:
raise HTTPException(
status_code=401,
detail="سجّل الدخول للوصول إلى هذه الواجهة.",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = auth.resolve_session(credentials.credentials.strip())
if user_id is None:
raise HTTPException(
status_code=401,
detail="انتهت الجلسة أو أُلغيت؛ سجّل الدخول مجددًا.",
headers={"WWW-Authenticate": "Bearer"},
)
return user_id
def _request_authenticated_user_id(request: Request) -> str | None:
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
return auth.resolve_session(token.strip()) if scheme.casefold() == "bearer" else None
def _request_id(request: Request) -> str:
return getattr(request.state, "request_id", "unknown")
@@ -151,6 +175,7 @@ async def audit_agent_routes(request: Request, call_next: Any):
method=request.method,
status_code=status_code,
duration_ms=max(0, round((perf_counter() - started) * 1000)),
user_id=_request_authenticated_user_id(request),
)
except Exception:
logger.exception("Unable to write agent audit metadata")
@@ -210,7 +235,7 @@ class WorkspaceAgentRequest(AgentRequest):
task: str = Field(min_length=1, max_length=4000, description="سؤال عن ملفات مساحة العمل المحلية")
@app.post("/v1/agent/workspace/files")
@app.post("/v1/agent/workspace/files", dependencies=[Depends(get_authenticated_user_id)])
async def list_workspace_files(request: WorkspaceFilesRequest) -> dict[str, Any]:
"""Return bounded relative file names from the folder chosen by the desktop user."""
try:
@@ -224,10 +249,13 @@ async def list_workspace_files(request: WorkspaceFilesRequest) -> dict[str, Any]
@app.post("/v1/agent/files/apply")
async def apply_agent_file_change(request: ApplyFileChangeRequest) -> dict[str, str]:
async def apply_agent_file_change(
request: ApplyFileChangeRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, str]:
"""Apply a one-time proposal only when the client explicitly confirms it."""
try:
return workspace.apply_change_preview(str(request.token))
return workspace.apply_change_preview(str(request.token), user_id=user_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -390,25 +418,6 @@ async def _read_public_page(raw_url: str) -> tuple[str, str, str]:
raise HTTPException(status_code=502, detail="تعذر الوصول إلى الموقع؛ تحقق من الإنترنت أو من إعدادات الموقع.") from exc
def get_authenticated_user_id(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> str:
if credentials is None:
raise HTTPException(
status_code=401,
detail="سجّل الدخول للوصول إلى سجل المحادثات.",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = auth.resolve_session(credentials.credentials.strip())
if user_id is None:
raise HTTPException(
status_code=401,
detail="انتهت الجلسة أو أُلغيت؛ سجّل الدخول مجددًا.",
headers={"WWW-Authenticate": "Bearer"},
)
return user_id
def validate_conversation_id(value: str) -> str:
try:
return str(UUID(value))
@@ -461,7 +470,7 @@ async def list_local_models() -> dict[str, Any]:
}
@app.get("/v1/agent/tools")
@app.get("/v1/agent/tools", dependencies=[Depends(get_authenticated_user_id)])
def list_agent_tools() -> dict[str, Any]:
"""Describe the currently available bounded tools in a stable JSON contract."""
return {
@@ -541,7 +550,7 @@ def list_agent_tools() -> dict[str, Any]:
}
@app.get("/v1/agent/skills")
@app.get("/v1/agent/skills", dependencies=[Depends(get_authenticated_user_id)])
def list_agent_skills() -> dict[str, Any]:
"""List curated local skills and their bounded tool permissions."""
return {
@@ -577,7 +586,10 @@ async def _search_local_knowledge(
@app.post("/v1/agent/knowledge/index")
async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]:
async def index_workspace_knowledge(
request: KnowledgeIndexRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""Index selected files and optionally add local semantic vectors."""
try:
root = workspace.selected_root(request.workspace_path)
@@ -669,7 +681,7 @@ async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str,
try:
document = knowledge.index_document(
user_id=database.LOCAL_USER_ID,
user_id=user_id,
workspace_path=root,
relative_path=path.relative_to(root).as_posix(),
text=text,
@@ -683,7 +695,7 @@ async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str,
try:
vectors = await embeddings.embed_texts(knowledge._chunks(text), model=model_name)
knowledge.store_embeddings(
user_id=database.LOCAL_USER_ID,
user_id=user_id,
workspace_path=root,
relative_path=path.relative_to(root).as_posix(),
model=model_name,
@@ -703,7 +715,10 @@ async def index_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str,
@app.delete("/v1/agent/knowledge/index")
def delete_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]:
def delete_workspace_knowledge(
request: KnowledgeIndexRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""Delete selected files from this user's local knowledge index."""
try:
root = workspace.selected_root(request.workspace_path)
@@ -718,7 +733,7 @@ def delete_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]
except (OSError, ValueError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
was_deleted = knowledge.delete_document(
user_id=database.LOCAL_USER_ID,
user_id=user_id,
workspace_path=root,
relative_path=path.relative_to(root).as_posix(),
)
@@ -727,7 +742,10 @@ def delete_workspace_knowledge(request: KnowledgeIndexRequest) -> dict[str, Any]
@app.post("/v1/agent/knowledge/search")
async def search_workspace_knowledge(request: AgentRequest) -> dict[str, Any]:
async def search_workspace_knowledge(
request: AgentRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
try:
root = workspace.selected_root(request.workspace_path)
except ValueError as exc:
@@ -736,19 +754,22 @@ async def search_workspace_knowledge(request: AgentRequest) -> dict[str, Any]:
raise HTTPException(status_code=422, detail="اختر مجلد مساحة العمل أولًا.")
results, search_mode = await _search_local_knowledge(
request.task,
user_id=database.LOCAL_USER_ID,
user_id=user_id,
workspace_path=root,
)
return {"results": results, "search_mode": search_mode}
@app.get("/v1/agent/audit")
def list_agent_audit(limit: int = 50) -> dict[str, Any]:
def list_agent_audit(
limit: int = 50,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""Return metadata-only history for recent agent API calls."""
return {"data": database.list_agent_audit_events(limit)}
return {"data": database.list_agent_audit_events(user_id, limit)}
@app.post("/v1/agent/workspace")
@app.post("/v1/agent/workspace", dependencies=[Depends(get_authenticated_user_id)])
async def ask_workspace(request: WorkspaceAgentRequest) -> dict[str, Any]:
"""Answer using read-only excerpts from the configured project directory."""
root = workspace.configured_root()
@@ -788,7 +809,7 @@ async def ask_workspace(request: WorkspaceAgentRequest) -> dict[str, Any]:
}
@app.post("/v1/agent/files/analyze")
@app.post("/v1/agent/files/analyze", dependencies=[Depends(get_authenticated_user_id)])
async def analyze_code_files(
files: list[UploadFile] = File(...),
question: str = Form(default="حلّل الملفات المرفقة واشرح وظيفتها وعلاقاتها."),
@@ -1005,7 +1026,7 @@ async def analyze_code_files(
return response
@app.post("/v1/agent/images/analyze")
@app.post("/v1/agent/images/analyze", dependencies=[Depends(get_authenticated_user_id)])
async def analyze_images(
files: list[UploadFile] = File(...),
question: str = Form(default="اقرأ النصوص في الصورة واشرح محتواها."),
@@ -1232,14 +1253,14 @@ def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]:
return payload
@app.post("/v1/chat/completions")
@app.post("/v1/chat/completions", dependencies=[Depends(get_authenticated_user_id)])
async def chat(request: ChatRequest) -> dict[str, Any]:
"""إرسال طلب المحادثة إلى مزوّد النموذج المضبوط."""
payload = chat_payload(request, stream=False)
return await get_completion(payload)
@app.post("/v1/chat/stream")
@app.post("/v1/chat/stream", dependencies=[Depends(get_authenticated_user_id)])
async def chat_stream(request: ChatRequest) -> StreamingResponse:
"""Pass provider token deltas to clients as newline-delimited JSON."""
provider = get_model_provider()
@@ -1389,12 +1410,20 @@ def requested_calculation(task: str) -> str | None:
@app.post("/v1/agent/run")
async def run_agent(request: AgentRequest) -> dict[str, Any]:
async def run_agent(
request: AgentRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""One-step local tool loop with bounded local knowledge search."""
return await _execute_agent(request)
return await _execute_agent(request, user_id=user_id)
async def _execute_agent(request: AgentRequest, report_progress: Any | None = None) -> dict[str, Any]:
async def _execute_agent(
request: AgentRequest,
report_progress: Any | None = None,
*,
user_id: str = database.LOCAL_USER_ID,
) -> dict[str, Any]:
async def report(message: str) -> None:
if report_progress is not None:
await report_progress(message)
@@ -1455,7 +1484,7 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
):
prefetched_knowledge, _ = await _search_local_knowledge(
request.task,
user_id=database.LOCAL_USER_ID,
user_id=user_id,
workspace_path=selected_workspace,
)
@@ -1702,7 +1731,7 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
raise HTTPException(status_code=503, detail="اختر مساحة عمل قبل البحث في الفهرس.")
matches, _ = await _search_local_knowledge(
query,
user_id=database.LOCAL_USER_ID,
user_id=user_id,
workspace_path=selected_workspace,
)
source_files = list(dict.fromkeys(item["path"] for item in matches))
@@ -1727,7 +1756,7 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
raise HTTPException(status_code=422, detail="بيانات معاينة الملف غير صالحة.")
try:
proposal = workspace.create_change_preview(
selected_workspace, path, operation, content
selected_workspace, path, operation, content, user_id=user_id
)
except (OSError, ValueError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -1775,7 +1804,10 @@ async def _execute_agent(request: AgentRequest, report_progress: Any | None = No
@app.post("/v1/agent/run/stream")
async def run_agent_stream(request: AgentRequest) -> StreamingResponse:
async def run_agent_stream(
request: AgentRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> StreamingResponse:
"""Stream real agent phase updates, then the final answer as server-sent events."""
queue: asyncio.Queue[str | None] = asyncio.Queue()
@@ -1783,7 +1815,9 @@ async def run_agent_stream(request: AgentRequest) -> StreamingResponse:
await queue.put(message)
async def event_stream():
task = asyncio.create_task(_execute_agent(request, report_progress=report))
task = asyncio.create_task(
_execute_agent(request, report_progress=report, user_id=user_id)
)
try:
while True:
if task.done() and queue.empty():
@@ -1816,7 +1850,7 @@ async def run_agent_stream(request: AgentRequest) -> StreamingResponse:
)
@app.post("/v1/web/read")
@app.post("/v1/web/read", dependencies=[Depends(get_authenticated_user_id)])
async def read_web_page(request: WebReadRequest) -> dict[str, Any]:
"""Fetch a user-provided public web page and ask the local model about its text."""
source_url, title, page_text = await _read_public_page(request.url)
@@ -1857,7 +1891,7 @@ async def read_web_page(request: WebReadRequest) -> dict[str, Any]:
}
@app.post("/v1/web/search")
@app.post("/v1/web/search", dependencies=[Depends(get_authenticated_user_id)])
async def search_web(request: WebSearchRequest) -> dict[str, Any]:
"""Search several public sources, fetch their pages, and summarize with citations."""
search_url = "https://html.duckduckgo.com/html/"
@@ -1975,7 +2009,7 @@ async def search_web(request: WebSearchRequest) -> dict[str, Any]:
}
@app.post("/v1/audio/transcriptions")
@app.post("/v1/audio/transcriptions", dependencies=[Depends(get_authenticated_user_id)])
async def transcribe_audio(
file: UploadFile = File(...),
language: str | None = Form(default=None),
+11 -2
View File
@@ -157,6 +157,8 @@ def create_change_preview(
relative_path: str,
operation: str,
content: str,
*,
user_id: str | None = None,
) -> dict[str, object]:
"""Build and retain a short-lived diff; this function never writes the file."""
raw_content = content.encode("utf-8")
@@ -202,6 +204,7 @@ def create_change_preview(
"content": proposed_bytes,
"expected_hash": hashlib.sha256(original).hexdigest(),
"expires_at": time.time() + PROPOSAL_TTL_SECONDS,
"user_id": user_id,
}
return {
"token": token,
@@ -212,11 +215,17 @@ def create_change_preview(
}
def apply_change_preview(token: str) -> dict[str, str]:
def apply_change_preview(
token: str, *, user_id: str | None = None
) -> dict[str, str]:
"""Apply a reviewed proposal once, only if its target is still unchanged."""
proposal = _pending_changes.pop(token, None)
proposal = _pending_changes.get(token)
if proposal is None or float(proposal["expires_at"]) <= time.time():
_pending_changes.pop(token, None)
raise ValueError("انتهت صلاحية معاينة التغيير أو استُخدمت مسبقًا؛ أنشئ معاينة جديدة.")
if proposal.get("user_id") != user_id:
raise ValueError("معاينة التعديل لا تخص جلسة المستخدم الحالية.")
_pending_changes.pop(token, None)
root = Path(str(proposal["root"])).resolve(strict=True)
relative_path = str(proposal["path"])