"""FlowDeck — helpers for API v2 (v6.3.0). Pagination, ISO-8601, RFC7807 errors, hierarchical scopes, Bearer auth. No duplication: thin wrappers over existing services. """ from __future__ import annotations import hashlib import json import logging import time from datetime import UTC, datetime from typing import Any from fastapi import Header, HTTPException, Request from fastapi.responses import JSONResponse from app.config import settings from app.db import get_conn logger = logging.getLogger(__name__) # ── ISO-8601 ────────────────────────────────────────────────────────────── def to_iso8601(value: str | None) -> str | None: if not value: return None # SQLite stores "YYYY-MM-DD HH:MM:SS" or with T; convert to UTC Z try: # try with seconds for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S.%f"): try: dt = datetime.strptime(value[:19], fmt[:8] if "." in value else fmt) # SQLite has no tz => assume UTC dt = dt.replace(tzinfo=UTC) return dt.isoformat().replace("+00:00", "Z") except ValueError: continue # fallback: if already ISO with T/Z, return as-is if "T" in value: return value return value except Exception: return value def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at", "created_at_ts", "last_login", "joined_at", "accessed_at", "fired_at", "start_date", "end_date", "logged_at", "last_seen_at", "last_used_at", "verified_at", "last_login_at")) -> dict: if row is None: return {} d = dict(row) for k in list(d.keys()): if k in iso_fields and d[k]: iso = to_iso8601(str(d[k])) if iso: d[k] = iso # parse *_json columns if k.endswith("_json") and isinstance(d[k], str): try: d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k]) except Exception: logger.exception("row_to_dict") return d # ── Pagination ──────────────────────────────────────────────────────────── def parse_pagination(request: Request, default_limit: int = 30, max_limit: int = 100) -> tuple[int, int]: try: limit = int(request.query_params.get("limit", str(default_limit))) except ValueError: limit = default_limit try: offset = int(request.query_params.get("offset", "0")) except ValueError: offset = 0 limit = max(1, min(limit, max_limit)) offset = max(0, offset) return limit, offset def paginate_headers(total: int) -> dict[str, str]: return {"X-Total-Count": str(total)} # ── Scopes (hierarchical: read < write < admin) ────────────────────────── SCOPE_RANK = {"read": 1, "write": 2, "admin": 3} VALID_SCOPES = set(SCOPE_RANK.keys()) def normalize_scopes(raw: str | None) -> set[str]: if not raw: return set() parts = [p.strip().lower() for p in raw.split(",") if p.strip()] return {p for p in parts if p in VALID_SCOPES} def has_scope(token_scopes: str | None, required: str) -> bool: req_rank = SCOPE_RANK.get(required, 99) # token with higher rank satisfies lower requirement # admin => write => read token_set = normalize_scopes(token_scopes) if not token_set: return False # effective rank = max rank among token scopes eff = max((SCOPE_RANK.get(s, 0) for s in token_set), default=0) return eff >= req_rank def validate_scopes_input(scopes_raw: str | None) -> str: if not scopes_raw: return "read" parts = [p.strip().lower() for p in scopes_raw.split(",") if p.strip()] for p in parts: if p not in VALID_SCOPES: raise HTTPException(status_code=400, detail=f"Invalid scope: {p}. Valid: read, write, admin") if not parts: return "read" # dedup preserve order seen = [] for p in parts: if p not in seen: seen.append(p) return ",".join(seen) # ── Bearer auth (unified) ───────────────────────────────────────────────── def _hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() def resolve_bearer_token(token: str) -> dict | None: """Resolve Bearer token to user dict. Returns None if invalid/expired/revoked. Supports api_tokens (hashed), extension_devices (hashed), and legacy user_tokens (plain). """ if not token: return None # dev-only fallback if token == "fd-public-key": if not settings.public_api_insecure_ok: return None # return a synthetic admin-like user? Use first admin or id 1 with get_conn() as conn: row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone() if row: d = dict(row) d["_token_id"] = None d["_token_scopes"] = "read,write,admin" d["_token_hash"] = None return d row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users ORDER BY id LIMIT 1").fetchone() if row: d = dict(row) d["_token_id"] = None d["_token_scopes"] = "read,write,admin" d["_token_hash"] = None return d return None th = _hash_token(token) with get_conn() as conn: # 1) api_tokens row = conn.execute("SELECT id, user_id, scopes, expires_at, revoked FROM api_tokens WHERE token_hash=?", (th,)).fetchone() if row: if row["revoked"]: return None exp = row["expires_at"] if exp: try: # compare as timestamp; SQLite format "YYYY-MM-DD HH:MM:SS" # parse to epoch dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) if "T" in str(exp) else datetime.strptime(str(exp)[:19], "%Y-%m-%d %H:%M:%S") if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) if dt.timestamp() < time.time(): return None except Exception: logger.exception("resolve_bearer_token") u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone() if u: d = dict(u) d["_token_id"] = row["id"] d["_token_scopes"] = row["scopes"] or "read,write" d["_token_hash"] = th # touch last_used_at best-effort try: conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)) conn.commit() except Exception: logger.exception("resolve_bearer_token") return d # 2) extension_devices row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone() if row: u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone() if u: d = dict(u) d["_token_id"] = None d["_token_scopes"] = row["scopes"] or "read,write" d["_token_hash"] = th return d # 3) legacy user_tokens (plain storage) row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone() if row: u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone() if u: d = dict(u) d["_token_id"] = None d["_token_scopes"] = "read,write" d["_token_hash"] = th return d return None def get_bearer_user(request: Request, authorization: str | None = Header(default=None)) -> dict: # Prefer explicit Authorization header, fallback to lowercase auth = authorization or request.headers.get("authorization") or request.headers.get("Authorization") or "" if not auth or not auth.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="API token required. Use Authorization: Bearer ") token = auth[7:].strip() user = resolve_bearer_token(token) if not user: raise HTTPException(status_code=401, detail="Invalid or expired API token") return user def require_scope(required: str): """A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient `has_scope(...)` à la main (69 sites dans api_v2.py).""" def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict: user = get_bearer_user(request, authorization) # Pas de default "read" : identique au contrôle manuel des handlers # (un jeton sans scope est refusé, quel que soit le scope demandé). scopes = user.get("_token_scopes") if not has_scope(scopes, required): raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}") return user return _dep # ── RFC 7807 ────────────────────────────────────────────────────────────── def problem_response(request: Request, exc: HTTPException) -> JSONResponse: title_map = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 409: "Conflict", 422: "Unprocessable Entity", 429: "Too Many Requests", 500: "Internal Server Error", } status = exc.status_code detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail) body = { "type": f"https://flowdeck/api/errors/{status}", "title": title_map.get(status, "Error"), "status": status, "detail": detail, "instance": str(request.url.path), } return JSONResponse(status_code=status, content=body, media_type="application/problem+json") # ── Audit ───────────────────────────────────────────────────────────────── def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str | int = "", detail: str = "", request: Request | None = None) -> None: try: ip = "" if request and request.client: ip = request.client.host or "" with get_conn() as conn: conn.execute( "INSERT INTO api_audit_log (user_id, token_id, action, resource_type, resource_id, ip_address, detail) VALUES (?, ?, ?, ?, ?, ?, ?)", (user.get("id"), user.get("_token_id"), action, resource_type, str(resource_id), ip, detail[:1000]), ) conn.commit() except Exception: logger.exception("audit_log") # ── Rate limit per token (in-memory) ───────────────────────────────────── _v2_rate_store: dict[str, tuple[float, int]] = {} def check_v2_rate_limit(token_hash: str | None, ip: str) -> bool: """Return True if allowed, False if 429. Uses api_v2_rate_limit_per_token.""" key = token_hash or f"ip:{ip}" now = time.time() window = 60.0 max_req = settings.api_v2_rate_limit_per_token start, count = _v2_rate_store.get(key, (now, 0)) if now - start > window: _v2_rate_store[key] = (now, 1) return True if count >= max_req: return False _v2_rate_store[key] = (start, count + 1) return True # ── Idempotency ─────────────────────────────────────────────────────────── def check_idempotency(request: Request, user_id: int) -> dict | None: key = request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") if not key: return None key = key.strip()[:200] if not key: return None with get_conn() as conn: row = conn.execute("SELECT response_json, status_code FROM idempotency_keys WHERE key=? AND user_id=?", (key, user_id)).fetchone() if row: try: data = json.loads(row["response_json"]) return {"data": data, "status": row["status_code"], "key": key} except Exception: return None return None def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200) -> None: if not key: return try: with get_conn() as conn: conn.execute( "INSERT OR IGNORE INTO idempotency_keys (key, user_id, response_json, status_code) VALUES (?, ?, ?, ?)", (key.strip()[:200], user_id, json.dumps(data), status_code), ) conn.commit() except Exception: logger.exception("store_idempotency")