diff --git a/server/app.py b/server/app.py index 60b1353..31a6f99 100644 --- a/server/app.py +++ b/server/app.py @@ -9,6 +9,10 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve Interactive docs: http:///api/docs """ import base64 +import csv +import hashlib +import hmac +import io import logging import os import re @@ -3509,6 +3513,59 @@ def usage_summary( } +def _pseudonym(username: str) -> str: + """A stable per-user id for the sanitized export — the SAME input always + produces the SAME output, within one export and across separate export + runs, so an external system (Power BI or similar) can still group and + trend "by user" without ever receiving a real name. HMAC rather than a + plain hash: a plain sha256(username) is trivially reversed against a + wordlist of the handful of usernames this app actually has; keying it + with AUTH_SECRET_KEY (already a real secret, already required in + production — see auth.py) means recovering a username from its + pseudonym requires the signing key, not just guessing.""" + digest = hmac.new(auth.SECRET_KEY.encode(), username.encode(), hashlib.sha256).hexdigest() + return "u_" + digest[:16] + + +@app.get("/api/usage/export") +def usage_export( + date_from: Optional[str] = Query(None, alias="from"), + date_to: Optional[str] = Query(None, alias="to"), + project_id: Optional[str] = Query(None), + username: Optional[str] = Query(None), + tool: Optional[str] = Query(None), + sanitize: bool = Query(False), + caller: models.User = Depends(require_user_manager), + db: Session = Depends(get_db), +): + """CR-019. Same gate, same filters, same underlying row set as + usage_summary() (_usage_query) — the export can never show a different + slice of data than what the console counted for the same filters. + + sanitize=true replaces `username` with a stable pseudonym (_pseudonym) + and — deliberately — the `detail` column is not exported in EITHER mode. + Every event this app writes today (login, page_open) leaves `detail` + empty, so this costs nothing now, but it also means a future event type + that DOES populate `detail` can't accidentally leak a real name into a + sanitized file through a column nobody thought to scrub. If `detail` + is ever needed in the export, it has to be sanitized explicitly, not + assumed safe because the rest of the row was.""" + rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all() + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["at", "username", "project_id", "tool", "event"]) + for e in rows: + who = _pseudonym(e.username) if sanitize else e.username + w.writerow([models._iso(e.at), who, e.project_id or "", e.tool, e.event]) + filename = "usage_export_%s_%s.csv" % ( + "sanitized" if sanitize else "raw", datetime.now(timezone.utc).strftime("%Y%m%d"), + ) + return Response( + content=buf.getvalue(), media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @app.get("/api/comments") def list_comments( source: Optional[str] = Query(None),