deer-flow/backend/tests/test_pat_auth.py
Sunshine bf740ffa90
feat(auth): add personal access tokens for programmatic API access (#5041)
* 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.
2026-08-29 23:50:45 +08:00

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"