Protect private API routes and scope agent data
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user