mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
614 lines
19 KiB
Python
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
|