Files
flowdeck/app/services/api_v2_helpers.py
T
bruno ffa1fa89ab
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m12s
fix: A25 + A21 (partiel) — plus d'exception muque, transaction protégée (v7.3.8)
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
  (19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
  api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
  sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
  `create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
  transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
  qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
  unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
  restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
2026-10-01 08:16:42 -04:00

314 lines
13 KiB
Python

"""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>")
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):
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
user = get_bearer_user(request, authorization)
scopes = user.get("_token_scopes") or "read"
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")