feat: intégration TikTak #197-200 + scoped tokens, webhooks filtrés, listes mtime/size ; fix e2e-server BUG-112
This commit is contained in:
@@ -215,15 +215,37 @@ def _save_api_tokens(data: dict):
|
||||
tmp.replace(API_TOKENS_FILE)
|
||||
|
||||
|
||||
def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]:
|
||||
#: Endpoint perimeters accepted for scoped API tokens (#199). ``"full"``
|
||||
#: keeps today's behaviour ; ``"files"`` restricts the token to the file
|
||||
#: read/write surface (enforced by ``TokenScopeMiddleware`` on REST and by
|
||||
#: the tool registry on MCP/agent calls).
|
||||
API_TOKEN_SCOPES = ("full", "files")
|
||||
|
||||
|
||||
def create_api_token(
|
||||
user: dict,
|
||||
name: str,
|
||||
expiry_key: str,
|
||||
vaults: list[str] | None = None,
|
||||
scope: str = "full",
|
||||
) -> tuple[dict, str]:
|
||||
"""Create a persistent API/MCP token. Returns (record, jwt_string).
|
||||
|
||||
``expiry_key`` must be one of API_TOKEN_EXPIRY_CHOICES; "never" omits the
|
||||
``exp`` claim (valid until explicitly revoked).
|
||||
|
||||
#199 (synchro TikTak) — optional least-privilege restriction:
|
||||
``vaults`` limits the token to the given vaults (``None`` = snapshot of
|
||||
the creator's vaults, i.e. today's behaviour) and ``scope`` limits the
|
||||
endpoint perimeter (``"full"`` or ``"files"``). Both are embedded in the
|
||||
JWT so enforcement stays stateless; the record keeps them for display.
|
||||
"""
|
||||
if expiry_key not in API_TOKEN_EXPIRY_CHOICES:
|
||||
raise ValueError("Expiration invalide")
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise ValueError("Portée invalide (full | files)")
|
||||
seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key]
|
||||
resolved_vaults = list(vaults) if vaults is not None else list(user.get("vaults", []))
|
||||
with _api_tokens_lock:
|
||||
data = _load_api_tokens()
|
||||
tokens = data["tokens"]
|
||||
@@ -235,11 +257,12 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
payload = {
|
||||
"sub": user["username"],
|
||||
"role": user.get("role", "user"),
|
||||
"vaults": user.get("vaults", []),
|
||||
"vaults": resolved_vaults,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"type": "access",
|
||||
"api": True,
|
||||
"scope": scope,
|
||||
}
|
||||
if seconds is not None:
|
||||
payload["exp"] = now + seconds
|
||||
@@ -252,6 +275,8 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
"expires_at": payload.get("exp"),
|
||||
"expiry_key": expiry_key,
|
||||
"last_used_at": None,
|
||||
"vaults": resolved_vaults,
|
||||
"scope": scope,
|
||||
}
|
||||
tokens[jti] = record
|
||||
_save_api_tokens(data)
|
||||
@@ -262,11 +287,16 @@ def list_api_tokens(username: str) -> list[dict]:
|
||||
"""Token metadata for one user, newest first."""
|
||||
data = _load_api_tokens()
|
||||
now = int(time.time())
|
||||
items = [
|
||||
{**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
for t in data["tokens"].values()
|
||||
if t["username"] == username
|
||||
]
|
||||
items = []
|
||||
for t in data["tokens"].values():
|
||||
if t["username"] != username:
|
||||
continue
|
||||
# #199 : les clés créées avant la portée limitée n'ont ni scope ni
|
||||
# vaults stockés → portée pleine, comportement inchangé.
|
||||
entry = {**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
entry.setdefault("scope", "full")
|
||||
entry.setdefault("vaults", None)
|
||||
items.append(entry)
|
||||
return sorted(items, key=lambda t: t["created_at"], reverse=True)
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ def get_current_user(
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
# Attach the token id for per-token rate limiting (AI tool layer).
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
# #199 : périmètre d'endpoints du token (défaut "full" = comportement
|
||||
# actuel, y compris pour les clés créées avant la portée limitée).
|
||||
user["_token_scope"] = payload.get("scope", "full")
|
||||
# Feature #107: track last usage of user-managed API/MCP tokens
|
||||
# (throttled write — this dependency runs on both REST and /mcp paths).
|
||||
if payload.get("api"):
|
||||
|
||||
+28
-2
@@ -1003,6 +1003,8 @@ async def delete_user_endpoint(
|
||||
class CreateApiTokenRequest(BaseModel):
|
||||
name: str
|
||||
expiry: str # 1d | 30d | 180d | 365d | never
|
||||
vaults: list[str] | None = None # #199 : None = vaults du créateur (comportement actuel)
|
||||
scope: str = "full" # #199 : "full" | "files"
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
@@ -1020,9 +1022,33 @@ async def create_user_token(
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE."""
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE.
|
||||
|
||||
#199 : portée optionnelle — ``vaults`` (sous-ensemble des vaults du
|
||||
créateur, défaut = tous) et ``scope`` (``"full"`` ou ``"files"``).
|
||||
"""
|
||||
from backend.auth.jwt_handler import API_TOKEN_SCOPES
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.indexer import index
|
||||
|
||||
scope = (req.scope or "full").strip().lower()
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise HTTPException(400, "Portée invalide (full | files)")
|
||||
vaults: list[str] | None = None
|
||||
if req.vaults is not None:
|
||||
cleaned = sorted({v.strip() for v in req.vaults if isinstance(v, str) and v.strip()})
|
||||
if not cleaned:
|
||||
raise HTTPException(400, "Liste de vaults vide")
|
||||
unknown = [v for v in cleaned if v not in index]
|
||||
if unknown:
|
||||
raise HTTPException(400, f"Vaults inconnus : {', '.join(unknown)}")
|
||||
denied = [v for v in cleaned if not check_vault_access(v, current_user)]
|
||||
if denied:
|
||||
raise HTTPException(403, f"Accès refusé aux vaults : {', '.join(denied)}")
|
||||
vaults = cleaned
|
||||
try:
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry)
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry,
|
||||
vaults=vaults, scope=scope)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
from backend.audit import log_config_change
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# backend/auth/scope.py
|
||||
# Scoped API/MCP tokens (#199, synchro TikTak — Gitea #2).
|
||||
#
|
||||
# A token may carry an endpoint perimeter in its ``scope`` JWT claim:
|
||||
# - ``"full"`` (default, and the case of every token issued before #199):
|
||||
# no restriction — today's behaviour, unchanged ;
|
||||
# - ``"files"``: file read/write surface only (vault file CRUD, listings,
|
||||
# search, backups read/restore). Everything else (admin, config, AI,
|
||||
# webhooks, shares, auth management…) answers **403**.
|
||||
#
|
||||
# Vault restriction is orthogonal and needs no new machinery: a scoped token
|
||||
# embeds the allowed vault list in its ``vaults`` JWT claim, so the existing
|
||||
# ``check_vault_access`` (REST routes) and ``ToolContext.require_vault_access``
|
||||
# (MCP/agent tools) refuse other vaults with 403 — for REST *and* MCP alike.
|
||||
"""Endpoint perimeter for scoped API tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
SCOPE_FULL = "full"
|
||||
SCOPE_FILES = "files"
|
||||
KNOWN_SCOPES = (SCOPE_FULL, SCOPE_FILES)
|
||||
|
||||
# (methods, path prefix or exact path). ``methods`` None = any method.
|
||||
# Keep this list tight: a sync client (TikTak) needs file CRUD, listings,
|
||||
# search and backup read/restore — nothing else.
|
||||
_RULES_FOR_FILES: tuple[tuple[frozenset[str] | None, str, bool], ...] = (
|
||||
# (methods, path, exact_only)
|
||||
(frozenset({"GET"}), "/api/health", False),
|
||||
(frozenset({"GET"}), "/api/auth/me", True),
|
||||
(frozenset({"GET"}), "/api/vaults", True),
|
||||
(None, "/api/browse/", False),
|
||||
(None, "/api/file/", False),
|
||||
(None, "/api/media/", False),
|
||||
(None, "/api/directory/", False),
|
||||
(None, "/api/move/", False),
|
||||
(None, "/api/vault/", False), # {vault}/files, {vault}/paths, batch-upload…
|
||||
(frozenset({"GET"}), "/api/search", False),
|
||||
(frozenset({"GET"}), "/api/tree-search", True),
|
||||
(frozenset({"GET"}), "/api/suggest", False),
|
||||
(frozenset({"GET"}), "/api/tags", False),
|
||||
(frozenset({"GET"}), "/api/graph/", False),
|
||||
(frozenset({"GET"}), "/api/backups", False),
|
||||
(frozenset({"GET"}), "/docs", False),
|
||||
(frozenset({"GET"}), "/redoc", True),
|
||||
(frozenset({"GET"}), "/openapi.json", True),
|
||||
(None, "/mcp", False),
|
||||
)
|
||||
|
||||
|
||||
def is_path_allowed_for_scope(scope: str | None, method: str, path: str) -> bool:
|
||||
"""True when a token with *scope* may call (*method*, *path*).
|
||||
|
||||
- ``None``/``"full"`` → everything allowed (backward compatible).
|
||||
- ``"files"`` → allow-list above (API surface only ; static assets and
|
||||
public pages carry no data and are handled by the caller — the
|
||||
middleware only gates ``/api/*`` and ``/mcp*``).
|
||||
- unknown scope → fail-closed (deny).
|
||||
"""
|
||||
if scope in (None, SCOPE_FULL):
|
||||
return True
|
||||
if scope != SCOPE_FILES:
|
||||
return False
|
||||
if not (path.startswith("/api/") or path.startswith("/mcp")):
|
||||
return True
|
||||
method = (method or "GET").upper()
|
||||
for methods, rule_path, exact in _RULES_FOR_FILES:
|
||||
if methods is not None and method not in methods:
|
||||
continue
|
||||
if exact:
|
||||
if path == rule_path:
|
||||
return True
|
||||
elif path == rule_path or path.startswith(rule_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def scope_of_user(user: dict[str, Any] | None) -> str:
|
||||
"""Endpoint scope attached to an authenticated user (default ``"full"``)."""
|
||||
if not user:
|
||||
return SCOPE_FULL
|
||||
return user.get("_token_scope") or SCOPE_FULL
|
||||
|
||||
|
||||
class TokenScopeMiddleware:
|
||||
"""Pure-ASGI middleware enforcing the endpoint perimeter of scoped tokens.
|
||||
|
||||
Denies out-of-perimeter calls made with a scoped token (**403**) before
|
||||
they reach the routes. Only *adds* restrictions: the ``scope`` claim is
|
||||
read unverified here, and the signature is still verified downstream by
|
||||
``get_current_user`` (an invalid token is rejected with 401 a step later
|
||||
anyway). Full-scope and scopeless tokens pass through untouched.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
||||
if scope.get("type") == "http":
|
||||
method = scope.get("method", "GET")
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/api/") or path.startswith("/mcp"):
|
||||
token_scope = _scope_from_asgi(scope)
|
||||
if token_scope not in (None, SCOPE_FULL) and not is_path_allowed_for_scope(
|
||||
token_scope, method, path
|
||||
):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse(
|
||||
{"detail": "Token à portée limitée : endpoint hors périmètre 'files'"},
|
||||
status_code=403,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _scope_from_asgi(asgi_scope: Any) -> str | None:
|
||||
"""Extract the unverified ``scope`` claim from an ASGI scope's auth."""
|
||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in asgi_scope.get("headers", [])}
|
||||
token: str | None = None
|
||||
auth = headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
else:
|
||||
for part in headers.get("cookie", "").split(";"):
|
||||
name, _, value = part.strip().partition("=")
|
||||
if name.strip() == "access_token" and value.strip():
|
||||
token = value.strip()
|
||||
break
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from jose import jwt as _jwt
|
||||
|
||||
claims = _jwt.get_unverified_claims(token)
|
||||
except Exception:
|
||||
return None
|
||||
token_scope = (claims or {}).get("scope")
|
||||
return token_scope if isinstance(token_scope, str) and token_scope else None
|
||||
Reference in New Issue
Block a user