"""安全 FAQ — the employee read surface, plus its admin console. Two routers: * ``/api/v1/me/security-faq`` — any authenticated employee. Self-scope is the authorisation, the same contract as ``/me/access-review`` and ``/me/systems``: there is nothing here that is anyone's private data, and the service layer refuses to return an entry that is archived or retracted, including by id. * ``/api/v1/auditor/security-faq`` — ``can_view_security`` reads and ``can_manage_security`` writes, matching the rest of the auditor domain. This is where the security team sees what employees read, what they voted useless, and what they asked that we could not answer. Employee reads are not audited. Auditing them would put "who looked up the AI-tools policy" in the permanent record, which is both a privacy cost and a chilling one, for a page whose entire purpose is that people use it. Admin decisions on the gap ledger ARE audited — those are governance actions with a named actor. """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, Header, Query, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( check_security_faq_ask_rate_limit, get_current_user, get_real_ip, ) from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_security, can_view_security from app.core.response import success_response from app.domains.auditor import security_faq_service as svc # ════════════════════════════════════════════════════════════════════ # Employee surface # ════════════════════════════════════════════════════════════════════ self_router = APIRouter(prefix="/api/v1/me/security-faq", tags=["security-faq"]) # The page renders whole answers rather than links, so a page of 20 is # already a long scroll; the cap stops a client asking for the library. _MAX_PAGE_SIZE = 50 def _lang(explicit: str | None, header: str | None) -> str: """UI language wins over the browser header — Helix's own language switcher is what the reader actually chose.""" return svc.resolve_lang(explicit or header) class AskRequest(BaseModel): question: str = Field(min_length=1, max_length=200) lang: str | None = Field(default=None, max_length=16) class FeedbackRequest(BaseModel): helpful: bool @self_router.get("/facets", summary="Categories, featured entries, freshness") async def faq_facets( request: Request, lang: str | None = Query(None, max_length=16), accept_language: str | None = Header(None, alias="Accept-Language"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return success_response( await svc.facets(db, lang=_lang(lang, accept_language)), request=request, ) @self_router.post( "/ask", summary="Free-text question answered strictly out of the visible library", dependencies=[Depends(check_security_faq_ask_rate_limit)], ) async def faq_ask( request: Request, payload: AskRequest, accept_language: str | None = Header(None, alias="Accept-Language"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await svc.ask( db, query=payload.question, lang=_lang(payload.lang, accept_language), person_id=user.id, ) # The gap ledger upsert is the only write on this path, and it is the # whole point of a miss — commit it. await db.commit() return success_response(result, request=request) @self_router.get("", summary="Browse / search the employee-visible library") async def list_faq( request: Request, category: str | None = Query(None, max_length=64), search: str | None = Query(None, max_length=200), lang: str | None = Query(None, max_length=16), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=_MAX_PAGE_SIZE), accept_language: str | None = Header(None, alias="Accept-Language"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await svc.list_faq( db, lang=_lang(lang, accept_language), category=category, search=search, page=page, page_size=page_size, ) data["my_votes"] = await svc.my_votes( db, person_id=user.id, entry_ids=[uuid.UUID(i["id"]) for i in data["items"]], ) return success_response(data, request=request) @self_router.get("/{entry_id}", summary="One entry (deep link target)") async def get_faq_entry( request: Request, entry_id: uuid.UUID, lang: str | None = Query(None, max_length=16), accept_language: str | None = Header(None, alias="Accept-Language"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): entry = await svc.get_visible_entry(db, entry_id) counted = await svc.record_view_once(db, entry_id=entry_id, viewer_id=user.id) if counted: await db.commit() view = svc.faq_view(entry, lang=_lang(lang, accept_language)) votes = await svc.my_votes(db, person_id=user.id, entry_ids=[entry_id]) view["my_vote"] = votes.get(str(entry_id)) return success_response(view, request=request) @self_router.post("/{entry_id}/view", summary="Count a read (deduped per day)") async def record_faq_view( request: Request, entry_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): await svc.get_visible_entry(db, entry_id) counted = await svc.record_view_once(db, entry_id=entry_id, viewer_id=user.id) await db.commit() return success_response({"counted": counted}, request=request) @self_router.post("/{entry_id}/feedback", summary="有帮助 / 没帮助") async def record_faq_feedback( request: Request, entry_id: uuid.UUID, payload: FeedbackRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await svc.record_vote( db, entry_id=entry_id, person_id=user.id, helpful=payload.helpful, ) await db.commit() return success_response(result, request=request) # ════════════════════════════════════════════════════════════════════ # Admin surface # ════════════════════════════════════════════════════════════════════ admin_router = APIRouter( prefix="/api/v1/auditor/security-faq", tags=["security-faq"], ) def _require_view(user: AuthUser) -> None: if not can_view_security(user.role_names): raise ForbiddenError("auditor or global_admin role required to view security data") def _require_manage(user: AuthUser) -> None: if not can_manage_security(user.role_names): raise ForbiddenError("auditor or global_admin role required to mutate security data") class GapResolveRequest(BaseModel): status: str = Field(max_length=16) entry_id: uuid.UUID | None = None note: str | None = Field(default=None, max_length=500) @admin_router.get("/overview", summary="What the employee face looks like") async def faq_overview( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await svc.employee_overview(db), request=request) @admin_router.get("/index", summary="Semantic-index coverage for the FAQ") async def faq_index( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) from app.domains.auditor import security_faq_rag as rag return success_response(await rag.index_stats(db), request=request) @admin_router.post("/index/backfill", summary="Embed the entries missing a vector") async def faq_index_backfill( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Drain the embedding backlog now, rather than waiting for the cron. The daily job is the safety net; this button is what the security team presses after publishing a batch of answers, because "the new entries are not findable by meaning until tomorrow" is a support ticket. Bounded and self-committing (per row), so a provider outage half way through keeps the vectors already produced. """ _require_manage(user) from app.domains.auditor import security_faq_rag as rag result = await rag.drain(db) return success_response( {**result, **await rag.index_stats(db)}, request=request, ) @admin_router.get("/gaps", summary="Questions employees asked that we cannot answer") async def list_gaps( request: Request, status: str | None = Query("open", max_length=16), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response( await svc.list_gaps(db, status=status, page=page, page_size=page_size), request=request, ) @admin_router.post("/gaps/{gap_id}/resolve", summary="Answer / dismiss / reopen a gap") async def resolve_gap( request: Request, gap_id: uuid.UUID, payload: GapResolveRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) gap = await svc.resolve_gap( db, gap_id, status=payload.status, entry_id=payload.entry_id, note=payload.note, actor_id=user.id, ) await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action="auditor.security_faq.gap_resolve", status="success", resource_type="auditor_security_faq_gap", resource_id=str(gap.id), detail={ "status": payload.status, "entry_id": str(payload.entry_id) if payload.entry_id else None, "query": gap.query_sample, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response(svc.gap_view(gap), request=request)