"""FlowDeck — Public API v2 : identity. Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). """ from __future__ import annotations import json import logging import secrets from datetime import datetime from fastapi import APIRouter, Body, Header, HTTPException, Request from fastapi.responses import JSONResponse from app.db import get_conn from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers audit_log, check_idempotency, check_v2_rate_limit, get_bearer_user, has_scope, paginate_headers, parse_pagination, require_scope, row_to_dict, store_idempotency, to_iso8601, validate_scopes_input, ) from ._common import _hash, _v2_rate_check logger = logging.getLogger(__name__) router = APIRouter(tags=["api-v2"]) @router.post("/tokens") def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) name = (body.get("name") or "API token").strip()[:100] scopes = validate_scopes_input(body.get("scopes") or "read,write") expires_at = body.get("expires_at") # Idempotency idem = check_idempotency(request, user["id"]) if idem: return JSONResponse(content=idem["data"], status_code=idem["status"]) token = f"fd_{secrets.token_urlsafe(32)}" prefix = token[:12] th = _hash(token) exp_val = None if expires_at: try: # accept ISO string exp_val = str(expires_at) # validate parse datetime.fromisoformat(exp_val.replace("Z", "+00:00")) except Exception as err: raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err with get_conn() as conn: try: cur = conn.execute( "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)", (user["id"], name, th, prefix, scopes, exp_val), ) conn.commit() tid = cur.lastrowid except Exception as e: raise HTTPException(409, f"Token creation failed: {e}") from None row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone() audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request) data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer "} key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip() if key: store_idempotency(key, user["id"], data, 200) return data @router.get("/tokens") def list_tokens(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall() out = [] for r in rows: d = dict(r) d["created_at"] = to_iso8601(d.get("created_at")) d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None # never expose hash out.append({k: v for k, v in d.items() if k != "token_hash"}) return {"tokens": out} @router.delete("/tokens/{token_id}") def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone() if not row: raise HTTPException(404, "Token not found") if row["user_id"] != user["id"] and not user.get("is_admin"): raise HTTPException(403, "Not your token") conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,)) conn.commit() audit_log(user, "token.revoke", "api_token", token_id, "", request) return {"id": token_id, "status": "revoked"} @router.post("/tokens/{token_id}/rotate") def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone() if not row: raise HTTPException(404, "Token not found") if row["user_id"] != user["id"] and not user.get("is_admin"): raise HTTPException(403, "Not your token") # revoke old conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,)) new_token = f"fd_{secrets.token_urlsafe(32)}" th = _hash(new_token) prefix = new_token[:12] cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write")) conn.commit() nid = cur.lastrowid audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request) return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"} @router.get("/users/me") def get_me(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone() if not row: raise HTTPException(404, "User not found") d = row_to_dict(row) # parse json prefs for k in ("notification_prefs", "sidebar_config"): if isinstance(d.get(k), str): try: d[k] = json.loads(d[k] or "{}") except Exception: logger.exception("get_me") # never expose secrets return d @router.patch("/users/me") def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"} updates = {} for k in allowed: if k in body: updates[k] = body[k] if not updates: raise HTTPException(400, "No updatable fields") # validation if "email" in updates and updates["email"] and "@" not in str(updates["email"]): raise HTTPException(400, "Invalid email") with get_conn() as conn: sets = [] params = [] for k, v in updates.items(): if k in ("notification_prefs", "sidebar_config"): v = json.dumps(v) if isinstance(v, (dict, list)) else str(v) sets.append(f"{k}=?") params.append(v) params.append(user["id"]) conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params) conn.commit() row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone() audit_log(user, "user.update", "user", user["id"], "", request) return row_to_dict(row) @router.get("/users/search") def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) q = (q or request.query_params.get("q") or "").strip() if not q: return {"users": []} like = f"%{q}%" with get_conn() as conn: rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall() return {"users": [dict(r) for r in rows]}