mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* feat(auth): add personal access tokens for programmatic API access (#4849) Backend-first implementation of the PAT contract from #4849: show-once dfp_ tokens bound to their owning user (AUTH_SOURCE_PAT, is_internal=false), digest-only storage (migration 0017), strict credential precedence (invalid Bearer is a 401, never cookie fallback), CSRF double-submit skipped only for Bearer requests while auth-endpoint origin checks still run, scopes intersecting the authz route permissions, session-auth-only PAT management and password changes, and throttled best-effort last_used_at stamps. * fix(auth): harden PAT scope boundary and schema parity from adversarial review Independent review of the initial draft found: (1) scopes only constrained the threads/runs permission axis while admin routes treated a PAT as its (possibly admin) owner — is_admin_user now rejects PAT callers outright since no scope grants admin capability; (2) the model declared a column UNIQUE constraint while migration 0017 created a named unique index, so downgrade failed on create_all-bootstrapped DBs — both now use the named unique index; (3) auth-disabled mode is an operator override and now stays ahead of the Bearer check so a stray Authorization header cannot 401 an E2E sandbox; plus wiring the previously-unused constants, bounding the last_used_at stamp cache, and four new tests (middleware-level expiry, expires_in_days, admin-capability rejection with session control, and the auth-disabled precedence). * docs(api): document personal access tokens for programmatic API access * fix(auth): close PAT security boundaries from review (default-deny routes, extension admin suppression) P1-1: scope intersection only constrains @require_permission routes, so undecorated mutation routes (DELETE /api/memory, POST /api/agents, Lark credential switching, channel config) accepted a PAT holding a single read scope. AuthMiddleware now enforces a default-deny route policy in auth/pat.py: PAT requests are admitted only to the thread/run lifecycle routes the v1 scopes govern; everything else answers 403 regardless of scopes. Session-cookie callers are unaffected. P1-2: the extension principal resolver projected is_admin/roles from the raw system_role, so an admin-owned PAT passed deerflow_extension_api.require_admin on contributed routes despite the documented no-admin guarantee. The projection is now PAT-aware and suppresses every admin signal for PAT callers, mirroring deps.is_admin_user. Both fixes carry regression tests (route outside policy 403 + session control; production resolver admin suppression), and API.md documents the default-deny boundary. * fix(auth): enforce PAT scopes on stateless run entry and harden decorator Follow-up hardening from an independent audit of the P1 fixes: - POST /api/runs/stream and /api/runs/wait were the only allowlisted run entrypoints without @require_permission, so a threads:read-only PAT could still start runs (same bug class as P1-1, now closed): both now carry @require_permission("runs", "create"). POST /api/threads and POST /api/threads/search gain threads:write / threads:read for the same reason. Authorization-disabled deployments see no change (the permission set resolves to all permissions). - require_permission now binds the wrapped signature to locate a positionally-passed request before injecting the test stub, fixing 'got multiple values for argument' on direct positional unit-test calls. - API.md: the intro PAT example used GET /api/models, which the new default-deny policy 403s — replaced with GET /api/threads; the default-deny route list now spells out method sets. Regression test: threads:read-only PAT is 403 on the decorated stateless entry while a runs:create PAT passes. * fix(auth): address review P2s (empty Authorization header, PAT name trimming, API example) - CSRFMiddleware treats an explicitly empty Authorization header as present (is None), so an invalid credential always reaches AuthMiddleware's uniform 401 instead of a CSRF 403 that varies by method/CSRF state. Regression: empty-header request dies at auth. - PATCreateRequest strips the name and rejects whitespace-only values before token generation; created names are stored trimmed. - API.md intro PAT example now uses the implemented POST /api/threads/search endpoint (GET /api/threads does not exist). - AGENTS.md trimmed back under the guidance soft budget after the upstream merge. * fix(auth): tighten PAT route policy to implemented methods only The allowlist admitted GET /api/threads, a method no router implements. Pre-authorizing a dead method weakens the default-deny boundary: a future GET collection route added without a permission decorator would become PAT-reachable without an explicit policy change. Restrict the rule to POST, fix the stale GET description in API.md's PAT constraints, and document the default-deny boundary accurately in the gateway AGENTS.md guidance (only the threads/runs allowlist is PAT-reachable; every other authenticated route 403s PAT callers). Audited every remaining rule against the mounted routers: all other method+path entries map to real routes. Regression: test_pat_policy_does_not_pre_authorize_unimplemented_methods. * test(auth): guarantee the negative digest test mutates the token token[:-1] + "X" is identical to the original whenever the generated token already ends in X (1/62), making the negative digest assertion fail intermittently. Choose the replacement character based on the existing tail so the mutated token always differs. * fix(auth): require runs:cancel for cancel-then-stream requests stream_existing_run is gated at runs:read so action-less stream joins work with read-only credentials, but its ?action=interrupt|rollback branch cancels the run — a separate permission. A runs:read-only PAT passed both the PAT route policy and the route decorator and could interrupt or roll back an active run, bypassing the runs:cancel scope. Decorators cannot express query-parameter-conditional permissions, so the check lives in require_cancel_permission_when_action(), applied at the top of the handler. Regression drives the real helper through the production middleware: runs:read-only PAT + action is 403, the same token joins action-less, runs:read+cancel passes, session control unaffected. * docs(changelog): add the PAT feature entry * docs(readme): add personal access tokens section Repo documentation-update policy requires user-facing features to update README.md in the same changeset; the PAT feature previously touched only backend/docs/API.md and the gateway AGENTS.md. * fix(auth): require runs:cancel for mutating multitask strategies All five run-creation entrypoints were gated only by runs:create, but RunCreateRequest.multitask_strategy accepts interrupt/rollback and start_run forwards it to create_or_reject, which terminates an already-active run. A runs:create-only PAT could therefore kill an existing run through a create request, bypassing runs:cancel. Decorators cannot express body-parameter-conditional permissions, and per-route checks leave the same hole for the next entrypoint, so the gate lives in start_run itself — the single choke point every run-creation path (HTTP routes and internal launchers) flows through. Regenerate launches pass multitask_strategy="reject" and are unaffected; requests without a stamped auth context (internal/test compositions) skip the gate. The check is the shared authz.require_cancel_permission_if primitive; require_cancel_permission_when_action now delegates to it, so every request dimension that carries cancel capability (query action, body strategy) flows through one gate. Regression drives the real middleware stack: runs:create-only PAT + interrupt/rollback is 403 with the exact detail, reject (explicit and default) stays available, runs:create+cancel passes, session control unaffected; a source anchor pins the gate inside start_run. * fix(runs): keep observer joins from applying creator cancel-on-disconnect sse_consumer's finally block applied the record's on_disconnect=cancel policy on ANY consumer's disconnect. The join surfaces (GET /join and the action-less GET/POST stream join) feed it the existing RunRecord, so anyone with thread read access — including a runs:read-only PAT — could cancel a locally-owned running run simply by closing the SSE connection, without runs:cancel. The policy expresses the creator's intent for their own connection; an observer's disconnect must never be read as that intent. sse_consumer gains apply_on_disconnect (default True). The two join surfaces pass False; the creating endpoints (thread-scoped and stateless create-and-stream) keep the creator semantics unchanged. wait_for_run_completion needs no change: its callers are creator-side or post-explicit-cancel paths only. Regression exercises a real generator close — the same machinery Starlette drives on client disconnect — against the production sse_consumer: creator stream disconnect cancels, observer join disconnect does not; a wiring anchor pins both join call sites and the creator defaults. API.md documents the cancel-capability constraint (this fix plus the action/strategy gates) in PAT Constraints. * test(auth): pin the multitask gate behaviorally; state wait invariant Independent adversarial review of the round-5 fixes found the P1-a regression only mirror-pinned: the source anchor could be satisfied by a comment, and deleting the gate from start_run would not fail the suite. This drives the production start_run directly — a create-only auth context gets 403 with the exact detail for interrupt, and a reject request with no cancel permission at all proceeds past the gate (never a permission 403). Also documents wait_for_run_completion's creator-side invariant (every caller is the creating endpoint or post-explicit-cancel) so a future observer wiring thinks twice before reusing it — the one-caller- away variant of the observer-disconnect P1. * docs(changelog): correct the PAT entry's digest and route-policy description The entry said HMAC digests (the implementation stores SHA-256 digests, as documented in API.md and pinned by the repository tests) and claimed the route policy admits 'implemented stateless endpoints' (it admits the thread/run lifecycle routes, narrowing further by scopes). Also notes the cancel-capability gate now covering action and multitask strategies. * fix(auth): enumerate the PAT runs route policy per implemented subroute The runs subtree rule was a GET|POST /runs(/.*)? wildcard — it pre-authorized every current and future subroute under /runs, including methods the router never implemented (e.g. GET /runs/stream), which is the same latent default-deny weakening the threads collection rule was tightened for: a future route added under /runs would become PAT-reachable without an explicit policy change. The wildcard is replaced with six segment-precise rules covering exactly the 14 implemented method+path combinations; the {run_id} slot necessarily matches any single segment, so the POST-only collection names (stream, wait, regenerate, edit-regenerate) are excluded from the GET run-id rule via negative lookahead — no dead method stays pre-authorized. Behavior for implemented routes is unchanged. test_pat_runs_policy_admits_exactly_the_mounted_routes derives the expected set from the mounted thread_runs router instead of a hand-maintained list: every implemented GET/POST route under /runs must be admitted, routes in this router outside the subtree stay denied, and representative unimplemented neighbors are denied — so adding a route under /runs now fails CI until it is explicitly allowlisted, and a removed route leaves a dead rule visible. API.md's PAT constraints list the enumerated routes and drops a feedback mention that belonged to the stateless /api/runs axis. * docs(migration): add the 0017 renumbering coordination note to 0017 The PR's migration-coordination comment states each migration file carries the note; the file did not. Adds it: numbering was generated against main head 0016 alongside #5078 and #4843; whoever merges first keeps the slot, the others renumber on rebase (revision/down_revision plus the bootstrap head assertions). * fix(auth): pad base62 tokens to a fixed 43-char width int.from_bytes discards leading zero bytes, so the unpadded encoder returned a variable-length body — empty for all-zero input, and shorter than 40 characters for any draw below 62**39 (~1 in 14.5M), leaving test_generate_pat_token_format probabilistically flaky and the token body without stable width (review round 6, P3). _base62 now left-pads with "0" to _base62_width(len(data)) — the exact integer digit count (62^43 > 2^256 > 62^42, so 43 for 32 bytes). The format test asserts the exact fixed width instead of a probabilistic floor, and a new unit test pins the all-zero, leading-zero-byte, and max-value edges deterministically.
701 lines
29 KiB
Python
701 lines
29 KiB
Python
"""Integration tests for PAT authentication (#4849).
|
|
|
|
Covers credential precedence in AuthMiddleware, the CSRF boundary for
|
|
Bearer-authenticated requests, scope intersection, PAT management routes,
|
|
and the self-protection rules (a PAT may not manage PATs or auth state).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
from starlette.testclient import TestClient
|
|
|
|
import deerflow.persistence.models # noqa: F401 (register every table)
|
|
from app.gateway.auth_disabled import AUTH_SOURCE_PAT, AUTH_SOURCE_SESSION
|
|
from app.gateway.auth_middleware import AuthMiddleware
|
|
from app.gateway.authz import require_cancel_permission_if
|
|
from app.gateway.csrf_middleware import CSRFMiddleware
|
|
from app.gateway.routers.auth import router as auth_router
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from deerflow.config.authorization_config import AuthorizationConfig
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.personal_access_tokens import PersonalAccessTokenRepository
|
|
|
|
TEST_JWT_SECRET = "test-pat-jwt-secret-0123456789abcdef"
|
|
|
|
|
|
class _FakeProvider:
|
|
"""Minimal LocalAuthProvider stand-in: resolves users by id."""
|
|
|
|
def __init__(self, *users) -> None:
|
|
self._users = {str(user.id): user for user in users}
|
|
|
|
async def get_user(self, user_id: str):
|
|
return self._users.get(str(user_id))
|
|
|
|
|
|
def _fake_user(user_id: str = "user-1", *, system_role: str = "user"):
|
|
return SimpleNamespace(
|
|
id=user_id,
|
|
email=f"{user_id}@example.com",
|
|
system_role=system_role,
|
|
needs_setup=False,
|
|
token_version=0,
|
|
oauth_provider=None,
|
|
password_hash=None,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _default_route_authorization_config(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.gateway.authz._get_route_authorization_config",
|
|
lambda: AuthorizationConfig(),
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
|
|
from app.gateway.auth.config import AuthConfig, set_auth_config
|
|
|
|
set_auth_config(AuthConfig(jwt_secret=TEST_JWT_SECRET, token_expiry_days=7))
|
|
|
|
|
|
def _make_pat_app(with_pat_repo: bool = True):
|
|
app = FastAPI()
|
|
# Production order: AuthMiddleware added first (inner), CSRF last (outer).
|
|
app.add_middleware(AuthMiddleware)
|
|
app.add_middleware(CSRFMiddleware)
|
|
app.include_router(auth_router)
|
|
|
|
@app.get("/api/threads/whoami")
|
|
async def whoami(request: Request):
|
|
return {"user_id": str(request.state.user.id), "auth_source": request.state.auth_source}
|
|
|
|
@app.get("/api/admin-check")
|
|
async def admin_check(request: Request):
|
|
from app.gateway.deps import is_admin_user
|
|
|
|
return {"is_admin": await is_admin_user(request)}
|
|
|
|
@app.post("/api/threads/{thread_id}/runs/stream")
|
|
async def run_stream(request: Request):
|
|
return {"ok": True, "permissions": list(request.state.auth.permissions)}
|
|
|
|
@app.delete("/api/memory")
|
|
async def memory_delete(request: Request):
|
|
return {"deleted": True}
|
|
|
|
@app.delete("/api/threads/{thread_id}")
|
|
async def thread_delete(request: Request):
|
|
return {"deleted": True}
|
|
|
|
# Mirrors the real stateless run entrypoint (routers/runs.py), including
|
|
# the @require_permission decorator, so scope enforcement is exercised
|
|
# end-to-end through the middleware's permission intersection.
|
|
from app.gateway.authz import require_permission
|
|
|
|
@app.post("/api/runs/stream")
|
|
@require_permission("runs", "create")
|
|
async def stateless_run_stream(request: Request):
|
|
return {"ok": True}
|
|
|
|
# Mirrors the real cancel-then-stream entrypoint (thread_runs.py
|
|
# stream_existing_run): runs:read at the decorator, plus the real
|
|
# conditional runs:cancel check the handler applies when `action` is set.
|
|
from app.gateway.routers.thread_runs import require_cancel_permission_when_action
|
|
|
|
@app.post("/api/threads/{thread_id}/runs/{run_id}/stream")
|
|
@require_permission("runs", "read")
|
|
async def cancel_then_stream(thread_id: str, run_id: str, request: Request, action: str | None = None):
|
|
require_cancel_permission_when_action(request, action)
|
|
return {"ok": True}
|
|
|
|
# Mirrors the real run-creation entrypoints (thread_runs.py / runs.py):
|
|
# runs:create at the decorator, plus the cancel-capability gate that
|
|
# start_run applies to mutating multitask strategies. RunCreateRequest is
|
|
# imported at module level — FastAPI resolves body annotations against
|
|
# module globals under postponed annotation evaluation.
|
|
@app.post("/api/threads/{thread_id}/runs")
|
|
@require_permission("runs", "create")
|
|
async def create_run(thread_id: str, body: RunCreateRequest, request: Request):
|
|
require_cancel_permission_if(request, body.multitask_strategy != "reject")
|
|
return {"ok": True}
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def pat_env(tmp_path, monkeypatch):
|
|
"""Engine + PAT repo + patched user provider; returns (client, repo)."""
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/pats.db", poolclass=NullPool)
|
|
asyncio.run(_create_tables(engine))
|
|
repo = PersonalAccessTokenRepository(async_sessionmaker(engine, expire_on_commit=False))
|
|
|
|
fake_provider = _FakeProvider(_fake_user("user-1"), _fake_user("user-2"), _fake_user("admin-1", system_role="admin"))
|
|
monkeypatch.setattr("app.gateway.deps.get_local_provider", lambda: fake_provider)
|
|
monkeypatch.setattr("app.gateway.routers.auth.get_local_provider", lambda: fake_provider)
|
|
|
|
app = _make_pat_app()
|
|
app.state.pat_repo = repo
|
|
return app, repo, engine
|
|
|
|
|
|
async def _create_tables(engine) -> None:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(pat_env):
|
|
app, repo, engine = pat_env
|
|
with TestClient(app) as test_client:
|
|
yield test_client
|
|
asyncio.run(engine.dispose())
|
|
|
|
|
|
def _session_cookie(client: TestClient, user_id: str = "user-1", token_version: int = 0) -> str:
|
|
from app.gateway.auth import create_access_token
|
|
|
|
token = create_access_token(user_id, token_version=token_version)
|
|
client.cookies.set("access_token", token)
|
|
return token
|
|
|
|
|
|
def _create_pat(client: TestClient, *, name: str = "test-token", scopes: list[str] | None = None, user_id: str = "user-1", expires_in_days: int | None = None) -> dict:
|
|
"""Create a PAT via the management API with session auth + CSRF pair."""
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client, user_id=user_id)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
payload = {"name": name, "scopes": scopes or ["runs:read", "threads:read"]}
|
|
if expires_in_days is not None:
|
|
payload["expires_in_days"] = expires_in_days
|
|
response = client.post(
|
|
"/api/v1/auth/pats",
|
|
json=payload,
|
|
headers={CSRF_HEADER_NAME: csrf},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
payload = response.json()
|
|
assert payload["token"].startswith("dfp_")
|
|
return payload
|
|
|
|
|
|
# ── Middleware precedence (#4849 point 3) ─────────────────────────────────
|
|
|
|
|
|
def test_valid_pat_authenticates_without_cookie(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"user_id": "user-1", "auth_source": AUTH_SOURCE_PAT}
|
|
|
|
|
|
def test_invalid_bearer_never_falls_back_to_session_cookie(client):
|
|
_session_cookie(client) # victim session is present and valid
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_not-a-real-token"})
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == "Invalid token"
|
|
|
|
|
|
def test_non_bearer_authorization_scheme_is_rejected(client):
|
|
_session_cookie(client)
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": "Basic dXNlcjpwYXNz"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_valid_pat_takes_precedence_over_session_cookie(client):
|
|
created = _create_pat(client) # sets a session cookie too
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
assert response.json()["auth_source"] == AUTH_SOURCE_PAT
|
|
|
|
|
|
def test_no_bearer_header_keeps_session_behavior(client):
|
|
_session_cookie(client)
|
|
response = client.get("/api/threads/whoami")
|
|
assert response.status_code == 200
|
|
assert response.json()["auth_source"] == AUTH_SOURCE_SESSION
|
|
|
|
|
|
def test_revoked_pat_is_rejected_immediately(client):
|
|
created = _create_pat(client)
|
|
delete = client.delete(f"/api/v1/auth/pats/{created['id']}", headers={"X-CSRF-Token": client.cookies.get("csrf_token")})
|
|
assert delete.status_code == 200, delete.text
|
|
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_pat_with_unresolvable_user_is_rejected(client, pat_env):
|
|
app, repo, _engine = pat_env
|
|
# Row owned by a user the provider cannot resolve (deleted user).
|
|
from app.gateway.auth.pat import generate_pat_token, pat_token_digest
|
|
|
|
token = generate_pat_token()
|
|
asyncio.run(repo.create(user_id="user-deleted", name="orphan", scopes=["runs:read"], token_digest=pat_token_digest(token)))
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {token}"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_pat_without_durable_store_is_rejected():
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
app.add_middleware(AuthMiddleware)
|
|
|
|
@app.get("/api/threads/whoami")
|
|
async def whoami(request): # pragma: no cover - never reached
|
|
return {}
|
|
|
|
with TestClient(app) as bare_client:
|
|
response = bare_client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_whatever"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
# ── Scope intersection ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_pat_scopes_intersect_user_permissions(client):
|
|
created = _create_pat(client, scopes=["runs:read"])
|
|
client.cookies.clear()
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
permissions = response.json()["permissions"]
|
|
assert "runs:read" in permissions
|
|
assert "runs:create" not in permissions
|
|
assert "threads:read" not in permissions
|
|
|
|
|
|
# ── CSRF posture (#4849 point 4) ──────────────────────────────────────────
|
|
|
|
|
|
def test_bearer_request_skips_double_submit(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear() # no csrf_token cookie, no X-CSRF-Token header
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_garbage_bearer_riding_cookie_dies_at_auth_not_csrf(client):
|
|
_session_cookie(client)
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": "Bearer garbage"})
|
|
# 401 from AuthMiddleware (invalid credential), not 403 from CSRF.
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_empty_authorization_header_is_present_and_dies_at_auth_not_csrf(client):
|
|
_session_cookie(client)
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": ""})
|
|
# An explicitly empty header is present-but-invalid: the same 401 from
|
|
# AuthMiddleware as any other invalid credential, never a CSRF 403.
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_auth_endpoint_origin_check_not_bypassed_by_bearer(client):
|
|
response = client.post(
|
|
"/api/v1/auth/login/local",
|
|
json={"email": "a@b.c", "password": "whatever1!"},
|
|
headers={"Origin": "https://evil.example", "Authorization": "Bearer dfp_garbage"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == "Cross-site auth request denied."
|
|
|
|
|
|
# ── Management routes + self-protection (#4849 point 6) ───────────────────
|
|
|
|
|
|
def test_create_returns_show_once_token_and_list_hides_it(client):
|
|
created = _create_pat(client)
|
|
listed = client.get("/api/v1/auth/pats")
|
|
assert listed.status_code == 200
|
|
entries = listed.json()
|
|
assert [entry["id"] for entry in entries] == [created["id"]]
|
|
assert "token" not in entries[0]
|
|
assert "token_digest" not in entries[0]
|
|
|
|
|
|
def test_create_rejects_unknown_scope(client):
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
response = client.post("/api/v1/auth/pats", json={"name": "bad", "scopes": ["runs:write"]}, headers={CSRF_HEADER_NAME: csrf})
|
|
assert response.status_code == 400
|
|
assert "Unknown PAT scopes" in response.json()["detail"]
|
|
|
|
|
|
def test_create_rejects_whitespace_only_name(client):
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
for name in (" ", "\t\n"):
|
|
response = client.post("/api/v1/auth/pats", json={"name": name, "scopes": ["runs:read"]}, headers={CSRF_HEADER_NAME: csrf})
|
|
# Rejected by request validation (422) before token generation.
|
|
assert response.status_code == 422, name
|
|
assert "non-whitespace" in response.text
|
|
|
|
|
|
def test_create_trims_surrounding_whitespace_in_name(client):
|
|
created = _create_pat(client, name=" ci bot ")
|
|
assert created["name"] == "ci bot"
|
|
|
|
|
|
def test_revoke_is_scoped_to_owner(client):
|
|
created = _create_pat(client, user_id="user-1")
|
|
# user-2 tries to revoke user-1's token.
|
|
_session_cookie(client, user_id="user-2")
|
|
from app.gateway.csrf_middleware import CSRF_HEADER_NAME
|
|
|
|
response = client.delete(f"/api/v1/auth/pats/{created['id']}", headers={CSRF_HEADER_NAME: client.cookies.get("csrf_token")})
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_pat_cannot_manage_pats(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
headers = {"Authorization": f"Bearer {created['token']}"}
|
|
assert client.get("/api/v1/auth/pats", headers=headers).status_code == 403
|
|
assert client.post("/api/v1/auth/pats", json={"name": "child", "scopes": ["runs:read"]}, headers=headers).status_code == 403
|
|
assert client.delete(f"/api/v1/auth/pats/{created['id']}", headers=headers).status_code == 403
|
|
|
|
|
|
def test_pat_cannot_change_password(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
response = client.post(
|
|
"/api/v1/auth/change-password",
|
|
json={"current_password": "x", "new_password": "Whatever123!"},
|
|
headers={"Authorization": f"Bearer {created['token']}"},
|
|
)
|
|
assert response.status_code == 403
|
|
# The default-deny route policy blocks the request at the middleware,
|
|
# before the route-level session-only guard gets a chance; the 403 is the
|
|
# security property either way.
|
|
assert "pat" in response.json()["detail"].lower()
|
|
|
|
|
|
def test_successful_pat_auth_stamps_last_used(client, pat_env):
|
|
_app, repo, _engine = pat_env
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
assert client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"}).status_code == 200
|
|
|
|
records = asyncio.run(repo.list_for_user("user-1"))
|
|
assert records[0]["last_used_at"] is not None
|
|
|
|
|
|
def test_expired_pat_rejected_at_middleware(client, pat_env):
|
|
_app, repo, _engine = pat_env
|
|
from app.gateway.auth.pat import generate_pat_token, pat_token_digest
|
|
|
|
token = generate_pat_token()
|
|
asyncio.run(
|
|
repo.create(
|
|
user_id="user-1",
|
|
name="already-expired",
|
|
scopes=["runs:read"],
|
|
token_digest=pat_token_digest(token),
|
|
expires_at=datetime.now(UTC) - timedelta(seconds=1),
|
|
)
|
|
)
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {token}"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_create_with_expiry_returns_expires_at(client):
|
|
created = _create_pat(client, expires_in_days=30)
|
|
assert created["expires_at"] is not None
|
|
|
|
|
|
def test_pat_never_carries_admin_capability_even_for_admin_owner(client):
|
|
created = _create_pat(client, user_id="admin-1", scopes=["runs:read"])
|
|
client.cookies.clear()
|
|
# The route-level default-deny policy blocks the PAT before the route
|
|
# runs; the is_admin_user guard inside it remains as defense in depth
|
|
# for compositions without the middleware.
|
|
response = client.get("/api/admin-check", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 403
|
|
|
|
# Control: the same admin over a session cookie keeps admin capability.
|
|
_session_cookie(client, user_id="admin-1")
|
|
control = client.get("/api/admin-check")
|
|
assert control.status_code == 200
|
|
assert control.json() == {"is_admin": True}
|
|
|
|
|
|
def test_pat_default_denied_on_route_outside_pat_policy(client):
|
|
"""P1 regression (#5041 review): a PAT holding every scope must not reach
|
|
destructive routes that have no PAT policy — scope intersection only
|
|
constrains @require_permission routes, so undecorated mutation routes
|
|
would otherwise accept a runs:read-only token."""
|
|
created = _create_pat(client, scopes=["threads:read", "threads:write", "threads:delete", "runs:create", "runs:read", "runs:cancel"])
|
|
client.cookies.clear()
|
|
response = client.delete("/api/memory", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 403
|
|
assert "PAT" in response.json()["detail"]
|
|
|
|
|
|
def test_session_cookie_reaches_route_that_denies_pat(client):
|
|
"""The default-deny is PAT-specific: the same route stays open to the
|
|
owning user's session cookie (PATs narrow, never widen, and never
|
|
restrict the interactive path)."""
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client, user_id="user-1")
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
response = client.delete("/api/memory", headers={CSRF_HEADER_NAME: csrf})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"deleted": True}
|
|
|
|
|
|
def test_pat_policy_allows_thread_lifecycle_routes(client):
|
|
created = _create_pat(client, scopes=["threads:delete"])
|
|
client.cookies.clear()
|
|
response = client.delete("/api/threads/t1", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"deleted": True}
|
|
|
|
|
|
def test_pat_policy_does_not_pre_authorize_unimplemented_methods():
|
|
"""Route-policy regression (#5041 review): the allowlist must not admit
|
|
methods the router does not implement. The Gateway has no GET collection
|
|
route for /api/threads — pre-authorizing it would make a future GET
|
|
collection route PAT-reachable without an explicit policy change."""
|
|
from app.gateway.auth.pat import is_pat_allowed_route
|
|
|
|
assert is_pat_allowed_route("POST", "/api/threads") is True
|
|
assert is_pat_allowed_route("GET", "/api/threads") is False
|
|
|
|
|
|
def test_pat_runs_policy_admits_exactly_the_mounted_routes():
|
|
"""The runs subtree is enumerated, not wildcarded: every GET/POST route
|
|
the thread_runs router actually implements is admitted (derived from the
|
|
mounted router, not a hand-maintained list), routes in this router
|
|
outside the runs subtree stay denied, and representative unimplemented
|
|
neighbors — including the POST-only collection names on GET — are
|
|
default-denied. A new route under /runs fails here until explicitly
|
|
allowlisted; a removed one leaves a dead rule visible."""
|
|
from fastapi.routing import APIRoute
|
|
|
|
from app.gateway.auth.pat import is_pat_allowed_route
|
|
from app.gateway.routers.thread_runs import router
|
|
|
|
def concrete(path: str) -> str:
|
|
return path.replace("{thread_id}", "t1").replace("{run_id}", "r1")
|
|
|
|
for route in router.routes:
|
|
if not isinstance(route, APIRoute):
|
|
continue
|
|
path = concrete(route.path)
|
|
under_runs = route.path.startswith("/api/threads/{thread_id}/runs")
|
|
for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
|
|
admitted = is_pat_allowed_route(method, path)
|
|
if under_runs:
|
|
assert admitted, f"{method} {path} is implemented but PAT-denied"
|
|
else:
|
|
# /messages, /messages/page, /token-usage sit outside the runs
|
|
# subtree and are PAT-denied pending the polling-surface
|
|
# decision — pinned here so widening it is a conscious edit.
|
|
assert not admitted, f"{method} {path} is outside the PAT policy"
|
|
|
|
for method, path in [
|
|
("GET", "/api/threads/t1/runs/stream"),
|
|
("GET", "/api/threads/t1/runs/wait"),
|
|
("GET", "/api/threads/t1/runs/regenerate"),
|
|
("GET", "/api/threads/t1/runs/edit-regenerate"),
|
|
("POST", "/api/threads/t1/runs/r1/messages"),
|
|
("DELETE", "/api/threads/t1/runs/r1"),
|
|
("POST", "/api/threads/t1/runs/summary"),
|
|
("GET", "/api/threads/t1/runs/r1/transfer"),
|
|
]:
|
|
assert not is_pat_allowed_route(method, path), f"{method} {path} is not implemented and must stay denied"
|
|
|
|
|
|
def test_pat_scopes_enforced_on_stateless_run_entry(client):
|
|
"""Follow-up to the review's P1-1: the stateless run entrypoints now
|
|
carry @require_permission("runs", "create"), so a threads:read-only PAT
|
|
cannot start runs even though the route sits inside the PAT allowlist."""
|
|
read_only = _create_pat(client, scopes=["threads:read"])
|
|
client.cookies.clear()
|
|
denied = client.post("/api/runs/stream", headers={"Authorization": f"Bearer {read_only['token']}"})
|
|
assert denied.status_code == 403
|
|
|
|
create_scope = _create_pat(client, scopes=["runs:create"])
|
|
client.cookies.clear()
|
|
allowed = client.post("/api/runs/stream", headers={"Authorization": f"Bearer {create_scope['token']}"})
|
|
assert allowed.status_code == 200
|
|
|
|
|
|
def test_runs_read_only_pat_cannot_cancel_then_stream(client):
|
|
"""Review follow-up: cancel-then-stream (`?action=interrupt|rollback`) must
|
|
require runs:cancel even though the route decorator gates at runs:read —
|
|
otherwise a read-only PAT bypasses the separate cancel scope."""
|
|
read_only = _create_pat(client, scopes=["runs:read"])
|
|
client.cookies.clear()
|
|
|
|
denied = client.post(
|
|
"/api/threads/t1/runs/run-1/stream?action=interrupt",
|
|
headers={"Authorization": f"Bearer {read_only['token']}"},
|
|
)
|
|
assert denied.status_code == 403
|
|
assert denied.json()["detail"] == "Permission denied: runs:cancel"
|
|
|
|
# The same route without an action is a plain stream join: runs:read is
|
|
# sufficient there.
|
|
join = client.post(
|
|
"/api/threads/t1/runs/run-1/stream",
|
|
headers={"Authorization": f"Bearer {read_only['token']}"},
|
|
)
|
|
assert join.status_code == 200
|
|
|
|
cancel_scope = _create_pat(client, scopes=["runs:read", "runs:cancel"])
|
|
client.cookies.clear()
|
|
allowed = client.post(
|
|
"/api/threads/t1/runs/run-1/stream?action=rollback",
|
|
headers={"Authorization": f"Bearer {cancel_scope['token']}"},
|
|
)
|
|
assert allowed.status_code == 200
|
|
|
|
# Session callers keep the full permission set (with the CSRF pair their
|
|
# cookie-authenticated POST requires).
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
session_allowed = client.post(
|
|
"/api/threads/t1/runs/run-1/stream?action=interrupt",
|
|
headers={CSRF_HEADER_NAME: csrf},
|
|
)
|
|
assert session_allowed.status_code == 200
|
|
|
|
|
|
def test_runs_create_only_pat_cannot_use_mutating_multitask_strategy(client):
|
|
"""Review round 5, P1-a: interrupt/rollback multitask strategies terminate
|
|
an already-active run — runs:cancel capability, not runs:create — so a
|
|
create-only PAT must be denied; "reject" (the default) stays within
|
|
runs:create and must keep working."""
|
|
create_only = _create_pat(client, scopes=["runs:create"])
|
|
client.cookies.clear()
|
|
|
|
for strategy in ("interrupt", "rollback"):
|
|
denied = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={"Authorization": f"Bearer {create_only['token']}"},
|
|
json={"multitask_strategy": strategy},
|
|
)
|
|
assert denied.status_code == 403, denied.text
|
|
assert denied.json()["detail"] == "Permission denied: runs:cancel"
|
|
|
|
# "reject" — explicitly and as the omitted default — does not touch
|
|
# existing runs and stays available to a create-only credential.
|
|
for body in ({"multitask_strategy": "reject"}, {}):
|
|
allowed = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={"Authorization": f"Bearer {create_only['token']}"},
|
|
json=body,
|
|
)
|
|
assert allowed.status_code == 200
|
|
|
|
cancel_scope = _create_pat(client, scopes=["runs:create", "runs:cancel"])
|
|
client.cookies.clear()
|
|
privileged = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={"Authorization": f"Bearer {cancel_scope['token']}"},
|
|
json={"multitask_strategy": "interrupt"},
|
|
)
|
|
assert privileged.status_code == 200
|
|
|
|
# Session callers keep the full permission set (with the CSRF pair their
|
|
# cookie-authenticated POST requires).
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
session_allowed = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={CSRF_HEADER_NAME: csrf},
|
|
json={"multitask_strategy": "interrupt"},
|
|
)
|
|
assert session_allowed.status_code == 200
|
|
|
|
|
|
def test_start_run_gates_mutating_strategies_at_the_choke_point():
|
|
"""The strategy gate lives inside start_run itself — the single choke point
|
|
every run-creation path (all five HTTP entrypoints plus internal
|
|
launchers) flows through — so no entry point can bypass it. Mirrored
|
|
routes prove the middleware path; this anchor proves the choke point."""
|
|
import inspect
|
|
|
|
from app.gateway.services import start_run
|
|
|
|
source = inspect.getsource(start_run)
|
|
assert "require_cancel_permission_if" in source
|
|
assert "multitask_strategy" in source
|
|
|
|
|
|
def test_start_run_gate_denies_create_only_credential_behaviorally():
|
|
"""Behavioral pin on the real start_run (the mirror route and source
|
|
anchor above prove wiring, but this drives the production choke point
|
|
itself): a create-only auth context gets 403 for a mutating strategy,
|
|
and the gate never misfires on "reject" — with no cancel permission at
|
|
all, the call proceeds past the gate (failing later on missing test
|
|
wiring, never with a permission 403)."""
|
|
from fastapi import HTTPException
|
|
|
|
from app.gateway.authz import AuthContext
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from app.gateway.services import start_run
|
|
|
|
def _request(permissions):
|
|
return SimpleNamespace(state=SimpleNamespace(auth=AuthContext(user=SimpleNamespace(id="user-1"), permissions=permissions)))
|
|
|
|
async def _denied():
|
|
with pytest.raises(HTTPException) as exc:
|
|
await start_run(RunCreateRequest(multitask_strategy="interrupt"), "t1", _request(["runs:create"]))
|
|
return exc.value
|
|
|
|
exc = asyncio.run(_denied())
|
|
assert exc.status_code == 403
|
|
assert exc.detail == "Permission denied: runs:cancel"
|
|
|
|
async def _allowed_past_gate():
|
|
try:
|
|
await start_run(RunCreateRequest(), "t1", _request([]))
|
|
except HTTPException as gate_misfire:
|
|
pytest.fail(f"gate misfired on reject: {gate_misfire.status_code} {gate_misfire.detail}")
|
|
except Exception:
|
|
pass # expected wiring failure past the gate — the gate let it through
|
|
|
|
asyncio.run(_allowed_past_gate())
|
|
|
|
|
|
def test_auth_disabled_mode_ignores_bearer_header(monkeypatch, tmp_path):
|
|
"""DEER_FLOW_AUTH_DISABLED is an operator override of all authentication.
|
|
|
|
A stray Authorization header (e.g. added by a proxy in front of an E2E
|
|
sandbox) must not turn into a 401 in that mode.
|
|
"""
|
|
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: True)
|
|
app = _make_pat_app()
|
|
with TestClient(app) as disabled_client:
|
|
response = disabled_client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_garbage"})
|
|
assert response.status_code == 200
|
|
assert response.json()["auth_source"] == "auth_disabled"
|