deer-flow/backend/tests/test_auth_middleware.py
hataa 47d8ff13b2
feat(authz): account disable/enable across every authentication surface (#4063 gap 3) (#6566)
* feat(authz): account disable/enable across every authentication surface (#4063 gap 3)

Vertical slice 1 of the user-lifecycle work: an operator can suspend an
account and every authentication surface rejects it, while the row —
credentials, role, OAuth linkage — is retained so re-enabling restores
exactly what was suspended.

- users.disabled (NOT NULL, server default 0) via migration
  0036_user_disabled; User/UserResponse carry and expose it.
- Enforcement at all four surfaces: password login (no credential
  comparison happens), JWT session resolution (401 ACCOUNT_DISABLED —
  sessions die at the next request), PAT resolution (the generic 401, no
  oracle), OAuth provisioning (403 with a contact-admin message; the
  link is kept so re-enable restores access).
- SQLiteUserRepository.set_disabled: single-column, serialized write
  (same idiom as update_system_role) with the last-ACTIVE-admin guard —
  disabled admins cannot authenticate, so they cannot restore management
  access; re-enabling is always allowed. Field-scoping completed both
  ways: the credential writer mirrors and never writes disabled, so
  a stale password-change snapshot cannot re-enable a suspended account.
- PATCH /api/v1/admin/users/{id} becomes a partial account update
  (system_role and/or disabled), each field routed to its field-scoped
  writer; empty bodies 422.

Tests 11->18: route (disable/re-enable, last-active-admin 409, empty
422, combined update), real-repo (field isolation both directions,
disabled-admin-not-active, guard inside the serialized write, re-enable
always), login rejection with the CORRECT password (only the disabled
gate can reject) plus resolver-visible flag. Mutations verified red:
guard removed (M19), login gate removed (M20 — after hardening the test
with a real hash so the gate is load-bearing).

* fix(authz): portable boolean default, alternate-authenticator suspension, active-admin role guard

Review round 1 on #6566 — three P1s:

- [PG default] sa.text("0") is a raw integer expression PostgreSQL
  rejects as a BOOLEAN default, breaking startup migration and fresh
  create_all bootstraps alike. Both the migration and the ORM model now
  use sa.false()/false() — dialect-rendered, portable.
- [alternate authenticators] WebSockets bypass AuthMiddleware, so a
  disabled user's still-valid cookie could open a NEW browser/stream
  connection and drive the retained session; the standalone LangGraph
  authenticate callback accepted the same suspended JWT. Both now route
  through a shared post-lookup validator (validate_resolved_session_user
  in deps.py — token_version + disabled in one place) that the HTTP
  dependency also uses, so a lifecycle change lands everywhere at once.
- [active-admin role guard] update_system_role counted every admin row,
  so 'disable B, then demote self' — or a combined {system_role, disabled}
  self-update — could strand the deployment with zero administrators able
  to authenticate or re-enable anyone. The demotion guard now counts
  ACTIVE admins only (disabled admins cannot authenticate), under the same
  serialization boundary.

Tests 18->22: shared-validator verdicts, WS admission after suspension
(real token, real repo), LangGraph-callback admission after suspension,
disable-then-demote-self + combined self-update both rejected (fake-repo
route level and real-repo update_system_role level). Mutations verified
red: helper's disabled check removed (M21 — three admission tests fail),
active-admin predicate reverted to all-admins (M22 — repo-level strand
scenario fails). 366 tests green on the affected surface.

* fix(migrations): chain 0036_user_disabled after upstream 0037

The maintainer main-merge brought 0037_project_document_summaries,
which branched from 0035 independently of this PR's 0036 — alembic now
sees two heads and every engine bootstrap fails. Chain 0036 after 0037
(single head; revision numbers are labels, the graph is what matters).

* fix(migrations): renumber user_disabled to 0038 and follow the chain-test convention

Upstream landed its own 0036 (run_idempotency_request) with 0037 chained
after it while this PR was in review — my 0036_user_disabled collided on
the number and my interim chain-fix (0036 revises 0037) left the graph
in a state upstream's chain tests reject (heads == [0037] hardcoded).

- 0036_user_disabled -> 0038_user_disabled, revises
  0037_project_document_summaries: single head 0038.
- Chain tests follow the repo convention (each migration PR appends the
  new head): 0032/0035 gain the DOC_SUMMARIES link and CURRENT_HEAD =
  0038; 0037's single-head assertion moves to CURRENT_HEAD with its
  own-revision link check unchanged.

* fix(migrations): pin the remaining chain tests to the 0038_user_disabled head

The 0027/0028, 0029 and 0034 chain tests still pinned the single head to
0037_project_document_summaries (and 0034 asserted the head's parent was
0036_run_idempotency_request), so the renumbered lifecycle migration broke
two unit-test shards. Follow the chain-test convention: every pinned head
moves to 0038_user_disabled and 0034 walks the chain through 0037.

* fix(authz): enforce account suspension on the internal-auth dispatch surface

Round-3 review finding: the internal-auth principal is synthesized from
trusted headers without a users-row lookup, so a suspended user with an
active channel binding kept driving the agent, and scheduler-owned runs
(their fabricated in-process requests skip AuthMiddleware entirely) kept
launching. Two gates now enforce the lifecycle there:

- AuthMiddleware rejects owner-bound internal calls whose users row is
  disabled (401 account_disabled) at the single gate every internal HTTP
  call passes through. Auth-disabled mode is exempt (no suspendable
  accounts, and its owner header carries the synthetic identity), and
  owner ids without a users row stay allowed — a nonexistent account
  cannot be suspended.
- start_run re-asserts it at run admission after the owner is resolved
  for attribution: the scheduler records the rejection as the
  occurrence's launch error and no run record is created.

Also addresses the remaining round-3 nits: drop the inline token_version
check duplicated by validate_resolved_session_user, correct the
local_provider comment (login never surfaces ACCOUNT_DISABLED), refresh
the stale 0038 docstring header, and restore the deps module caches in
the two suspension tests instead of leaking a closed engine.

* fix(auth): carry the disabled field on the frontend synthetic users

UserResponse now always includes disabled, so the auth-disabled contract
e2e (gateway /auth/me deep-equals the frontend synthetic user) failed
without the field on AUTH_DISABLED_USER. Add disabled to both synthetic
users and declare it optional on the zod schema — absent still parses as
not disabled for pre-gap-3 backends.

* fix(authz): log when the internal-owner suspension check is skipped

The middleware gate's users-lookup failure path silently degraded to
allow; keep the fail-open behavior but leave a WARNING with the
exception, so a store that cannot be read is observable in production
logs instead of invisible.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 16:43:04 +08:00

614 lines
19 KiB
Python

"""Tests for the global AuthMiddleware (fail-closed safety net)."""
import pytest
from starlette.testclient import TestClient
from app.gateway.auth_middleware import AuthMiddleware, _is_public
from app.gateway.csrf_middleware import CSRFMiddleware
from deerflow.config.authorization_config import AuthorizationConfig
@pytest.fixture(autouse=True)
def _default_route_authorization_config(monkeypatch):
"""Keep minimal middleware apps independent of a repository config.yaml."""
monkeypatch.setattr(
"app.gateway.authz._get_route_authorization_config",
lambda: AuthorizationConfig(),
)
# ── _is_public unit tests ─────────────────────────────────────────────────
@pytest.mark.parametrize(
"path",
[
"/health",
"/health/",
"/docs",
"/docs/",
"/redoc",
"/openapi.json",
"/api/v1/auth/login/local",
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
],
)
def test_public_paths(path: str):
assert _is_public(path) is True
@pytest.mark.parametrize(
"path",
[
"/api/models",
"/api/mcp/config",
"/api/mcp/cache/reset",
"/api/memory",
"/api/skills",
"/api/threads/123",
"/api/threads/123/uploads",
"/api/agents",
"/api/channels",
"/api/channels/providers",
"/api/channels/slack/connect",
"/api/runs/stream",
"/api/threads/123/runs",
"/api/v1/auth/me",
"/api/v1/auth/change-password",
],
)
def test_protected_paths(path: str):
assert _is_public(path) is False
# ── Trailing slash / normalization edge cases ─────────────────────────────
@pytest.mark.parametrize(
"path",
[
"/api/v1/auth/login/local/",
"/api/v1/auth/register/",
"/api/v1/auth/logout/",
"/api/v1/auth/setup-status/",
],
)
def test_public_auth_paths_with_trailing_slash(path: str):
assert _is_public(path) is True
@pytest.mark.parametrize(
"path",
[
"/api/models/",
"/api/v1/auth/me/",
"/api/v1/auth/change-password/",
],
)
def test_protected_paths_with_trailing_slash(path: str):
assert _is_public(path) is False
def test_unknown_api_path_is_protected():
"""Fail-closed: any new /api/* path is protected by default."""
assert _is_public("/api/new-feature") is False
assert _is_public("/api/v2/something") is False
assert _is_public("/api/v1/auth/new-endpoint") is False
# ── Middleware integration tests ──────────────────────────────────────────
def _make_app():
"""Create a minimal FastAPI app with AuthMiddleware for testing."""
from fastapi import FastAPI, Request
from deerflow.runtime.user_context import get_effective_user_id
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/api/v1/auth/me")
async def auth_me(request: Request):
from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
return {
"id": str(user.id),
"email": user.email,
"system_role": user.system_role,
"needs_setup": user.needs_setup,
}
@app.get("/api/v1/auth/setup-status")
async def setup_status():
return {"needs_setup": False}
@app.get("/api/models")
async def models_get():
return {"models": []}
@app.get("/api/whoami")
async def whoami(request: Request):
user = request.state.user
return {
"id": str(user.id),
"email": getattr(user, "email", None),
"system_role": getattr(user, "system_role", None),
"context_user_id": get_effective_user_id(),
}
@app.get("/api/current-user-from-dep")
async def current_user_from_dep(request: Request):
from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
state_user = request.state.user
return {
"id": str(user.id),
"state_id": str(state_user.id),
"auth_source": request.state.auth_source,
"context_user_id": get_effective_user_id(),
}
@app.put("/api/mcp/config")
async def mcp_put():
return {"ok": True}
@app.post("/api/mcp/cache/reset")
async def mcp_cache_reset():
return {"ok": True}
@app.delete("/api/threads/abc")
async def thread_delete():
return {"ok": True}
@app.patch("/api/threads/abc")
async def thread_patch():
return {"ok": True}
@app.post("/api/threads/abc/runs/stream")
async def stream():
return {"ok": True}
@app.get("/api/future-endpoint")
async def future():
return {"ok": True}
return app
def _make_auth_csrf_app():
"""Create a minimal app with production middleware ordering."""
from fastapi import FastAPI
app = FastAPI()
app.add_middleware(AuthMiddleware)
app.add_middleware(CSRFMiddleware)
@app.post("/api/threads/abc/runs/stream")
async def protected_mutation():
return {"ok": True}
return app
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
return TestClient(_make_app())
def test_public_path_no_cookie(client):
res = client.get("/health")
assert res.status_code == 200
def test_public_auth_path_no_cookie(client):
"""Public auth endpoints (login/register) pass without cookie."""
res = client.get("/api/v1/auth/setup-status")
assert res.status_code == 200
@pytest.mark.parametrize(
"encoded_path",
[
"/api/v1/auth/setup-sta%0Atus",
"/api/v1/auth/setup-sta%0Dtus",
"/api/v1/auth/setup-sta%09tus",
"/api/v1/auth/setup-status%23private",
"/api/v1/auth/setup-status%3Fprivate",
],
)
def test_url_reconstruction_cannot_turn_a_protected_route_path_public(
monkeypatch,
encoded_path,
):
from fastapi import FastAPI
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/v1/auth/setup-sta{gap}tus")
async def control_gap(gap: str):
return {"gap": gap}
@app.get("/api/v1/auth/setup-status{suffix}")
async def delimiter_suffix(suffix: str):
return {"suffix": suffix}
response = TestClient(app).get(encoded_path)
assert response.status_code == 401
def test_auth_uses_the_same_root_path_projection_as_the_router(monkeypatch):
from fastapi import FastAPI
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
child = FastAPI()
child.add_middleware(AuthMiddleware)
@child.get("/health")
async def health():
return {"ok": True}
parent = FastAPI()
parent.mount("/prefix", child)
response = TestClient(parent).get("/prefix/health")
assert response.status_code == 200
def test_protected_auth_path_no_cookie(client):
"""/auth/me requires cookie even though it's under /api/v1/auth/."""
res = client.get("/api/v1/auth/me")
assert res.status_code == 401
def test_protected_path_no_cookie_returns_401(client):
res = client.get("/api/models")
assert res.status_code == 401
body = res.json()
assert body["detail"]["code"] == "not_authenticated"
def test_auth_disabled_allows_protected_path_without_cookie(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get("/api/models")
assert res.status_code == 200
assert res.json() == {"models": []}
def test_auth_disabled_stamps_default_admin_user_without_cookie(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get("/api/whoami")
assert res.status_code == 200
assert res.json() == {
"id": "default",
"email": "default@test.local",
"system_role": "admin",
"context_user_id": "default",
}
def test_auth_disabled_auth_me_reuses_middleware_user_without_cookie(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get("/api/v1/auth/me")
assert res.status_code == 200
assert res.json() == {
"id": "default",
"email": "default@test.local",
"system_role": "admin",
"needs_setup": False,
}
def test_auth_disabled_does_not_clobber_valid_session_cookie(monkeypatch):
from types import SimpleNamespace
async def fake_current_user(request):
return SimpleNamespace(
id="session-user",
email="session@test.local",
system_role="user",
needs_setup=False,
)
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", fake_current_user)
client = TestClient(_make_app())
res = client.get("/api/whoami", cookies={"access_token": "valid-session"})
assert res.status_code == 200
assert res.json() == {
"id": "session-user",
"email": "session@test.local",
"system_role": "user",
"context_user_id": "session-user",
}
def test_auth_disabled_does_not_clobber_internal_auth_identity(monkeypatch):
from app.gateway.internal_auth import create_internal_auth_headers
from deerflow.runtime.user_context import DEFAULT_USER_ID
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get(
"/api/current-user-from-dep",
headers=create_internal_auth_headers(),
)
assert res.status_code == 200
assert res.json() == {
"id": DEFAULT_USER_ID,
"state_id": DEFAULT_USER_ID,
"auth_source": "internal",
"context_user_id": DEFAULT_USER_ID,
}
def test_auth_disabled_skips_csrf_for_state_changing_requests(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_auth_csrf_app())
res = client.post("/api/threads/abc/runs/stream")
assert res.status_code == 200
assert res.json() == {"ok": True}
def test_auth_disabled_is_ignored_in_explicit_production_env(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setenv("DEER_FLOW_ENV", "production")
client = TestClient(_make_app())
res = client.get("/api/models")
assert res.status_code == 401
def test_auth_disabled_startup_warning_when_effective(monkeypatch, caplog):
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.delenv("DEER_FLOW_ENV", raising=False)
monkeypatch.delenv("ENVIRONMENT", raising=False)
with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"):
warn_if_auth_disabled_enabled()
assert "authentication is bypassed" in caplog.text
assert "default" in caplog.text
def test_auth_disabled_startup_warning_suppressed_in_explicit_production_env(monkeypatch, caplog):
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setenv("ENVIRONMENT", "production")
with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"):
warn_if_auth_disabled_enabled()
assert "authentication is bypassed" not in caplog.text
def test_protected_path_with_junk_cookie_rejected(client):
"""Junk cookie → 401. Middleware strictly validates the JWT now
(AUTH_TEST_PLAN test 7.5.8); it no longer silently passes bad
tokens through to the route handler."""
client.cookies.set("access_token", "some-token")
res = client.get("/api/models")
assert res.status_code == 401
def test_protected_post_no_cookie_returns_401(client):
res = client.post("/api/threads/abc/runs/stream")
assert res.status_code == 401
def test_mcp_cache_reset_post_no_cookie_returns_401(client):
res = client.post("/api/mcp/cache/reset")
assert res.status_code == 401
def test_protected_post_with_internal_auth_header_passes():
from app.gateway.internal_auth import create_internal_auth_headers
app = _make_app()
client = TestClient(app)
res = client.post(
"/api/threads/abc/runs/stream",
headers=create_internal_auth_headers(),
)
assert res.status_code == 200
def test_internal_auth_owner_header_of_suspended_account_rejected(tmp_path):
"""(#3462 gap 3) IM-channel dispatch surface: the internal principal is
synthesized from trusted headers without a users-row lookup, so the
middleware itself rejects an owner-bound internal call whose account was
suspended — at the very next request, like every other surface."""
import asyncio
import json
from starlette.requests import Request as StarletteRequest
from starlette.responses import PlainTextResponse
from app.gateway.auth.models import User
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.internal_auth import create_internal_auth_headers
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
async def _dispatch(owner_user_id: str):
headers = create_internal_auth_headers(owner_user_id=owner_user_id)
scope = {
"type": "http",
"method": "POST",
"path": "/api/threads/abc/runs/stream",
"root_path": "",
"query_string": b"",
"headers": [(name.lower().encode(), value.encode()) for name, value in headers.items()],
}
async def call_next(_request):
return PlainTextResponse("route-reached")
response = await AuthMiddleware(None).dispatch(StarletteRequest(scope), call_next)
body = json.loads(response.body) if response.status_code != 200 else {}
return response.status_code, body
async def _run():
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path}/users.db", sqlite_dir=str(tmp_path))
repo = SQLiteUserRepository(get_session_factory())
owner = await repo.create_user(User(email="channel-owner@example.com", system_role="user"))
await repo.set_disabled(str(owner.id), True)
import app.gateway.deps as deps_module
saved_repo, saved_provider = deps_module._cached_repo, deps_module._cached_local_provider
deps_module._cached_repo = repo
deps_module._cached_local_provider = None
try:
status_code, body = await _dispatch(str(owner.id))
assert status_code == 401
assert body["detail"]["code"] == "account_disabled"
# Unknown owner ids stay allowed: an account that does not exist
# in the users store cannot be suspended.
status_code, body = await _dispatch("no-such-owner")
assert status_code == 200
# Re-enabling restores the internal surface immediately.
await repo.set_disabled(str(owner.id), False)
status_code, body = await _dispatch(str(owner.id))
assert status_code == 200
finally:
deps_module._cached_repo = saved_repo
deps_module._cached_local_provider = saved_provider
try:
asyncio.run(_run())
finally:
asyncio.run(close_engine())
def test_internal_auth_owner_gate_skipped_in_auth_disabled_mode(monkeypatch):
"""Auth-disabled mode sends the synthetic auth-disabled identity as the
internal owner; deployments may have no users store at all there, so the
suspension gate must not attempt a lookup (it would fail the request)."""
import asyncio
from starlette.requests import Request as StarletteRequest
from starlette.responses import PlainTextResponse
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.internal_auth import create_internal_auth_headers
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
async def _run():
headers = create_internal_auth_headers(owner_user_id=AUTH_DISABLED_USER_ID)
scope = {
"type": "http",
"method": "POST",
"path": "/api/threads/abc/runs/stream",
"root_path": "",
"query_string": b"",
"headers": [(name.lower().encode(), value.encode()) for name, value in headers.items()],
}
async def call_next(_request):
return PlainTextResponse("route-reached")
# No engine / users store is configured on purpose: a lookup here
# would raise and 500 the request.
response = await AuthMiddleware(None).dispatch(StarletteRequest(scope), call_next)
assert response.status_code == 200
asyncio.run(_run())
def test_protected_post_with_non_ascii_internal_auth_header_returns_401(client):
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME
res = client.post(
"/api/threads/abc/runs/stream",
headers={INTERNAL_AUTH_HEADER_NAME: "forged-tok\xe9n".encode("latin-1")},
)
assert res.status_code == 401
# ── Method matrix: PUT/DELETE/PATCH also protected ────────────────────────
def test_protected_put_no_cookie(client):
res = client.put("/api/mcp/config")
assert res.status_code == 401
def test_protected_delete_no_cookie(client):
res = client.delete("/api/threads/abc")
assert res.status_code == 401
def test_protected_patch_no_cookie(client):
res = client.patch("/api/threads/abc")
assert res.status_code == 401
def test_put_with_junk_cookie_rejected(client):
"""Junk cookie on PUT → 401 (strict JWT validation in middleware)."""
client.cookies.set("access_token", "tok")
res = client.put("/api/mcp/config")
assert res.status_code == 401
def test_delete_with_junk_cookie_rejected(client):
"""Junk cookie on DELETE → 401 (strict JWT validation in middleware)."""
client.cookies.set("access_token", "tok")
res = client.delete("/api/threads/abc")
assert res.status_code == 401
# ── Fail-closed: unknown future endpoints ─────────────────────────────────
def test_unknown_endpoint_no_cookie_returns_401(client):
"""Any new /api/* endpoint is blocked by default without cookie."""
res = client.get("/api/future-endpoint")
assert res.status_code == 401
def test_unknown_endpoint_with_junk_cookie_rejected(client):
"""New endpoints are also protected by strict JWT validation."""
client.cookies.set("access_token", "tok")
res = client.get("/api/future-endpoint")
assert res.status_code == 401