deer-flow/backend/app/gateway/langgraph_auth.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

336 lines
13 KiB
Python

"""LangGraph compatibility auth handler — shares JWT logic with Gateway.
The default DeerFlow runtime is embedded in the FastAPI Gateway; scripts and
Docker deployments do not load this module. It is retained for LangGraph
tooling, Studio, or direct LangGraph Server compatibility through
``langgraph.json``'s ``auth.path``.
When that compatibility path is used, this module reuses the same JWT and CSRF
rules as Gateway so both modes validate sessions consistently.
Two layers:
1. @auth.authenticate — validates JWT cookie, extracts user_id,
and enforces CSRF on state-changing methods (POST/PUT/DELETE/PATCH)
2. @auth.on — returns metadata filter so each user only sees own threads
"""
from contextvars import ContextVar
from uuid import uuid4
from langgraph_sdk import Auth
from starlette.exceptions import HTTPException
from app.gateway.auth.errors import TokenError
from app.gateway.auth.jwt import decode_token
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, is_auth_disabled
from app.gateway.deps import get_local_provider
from app.gateway.utils import constant_time_equals
from deerflow.mcp_scope import (
THREAD_INCARNATION_CONTEXT_KEY,
THREAD_INCARNATION_METADATA_GUARD_KEY,
is_valid_thread_incarnation,
)
auth = Auth()
# StudioUser was added after DeerFlow's historical langgraph-sdk floor. Resolve
# it once so older compatible SDK installs keep ordinary owner scoping instead
# of failing every request with an AttributeError.
_STUDIO_USER_TYPE = getattr(Auth.types, "StudioUser", None)
# Methods that require CSRF validation (state-changing per RFC 7231).
_CSRF_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
_allow_thread_incarnation_write: ContextVar[bool] = ContextVar(
"deerflow_allow_standalone_thread_incarnation_write",
default=False,
)
_MISSING = object()
def _metadata(value: dict) -> dict:
metadata = value.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
value["metadata"] = metadata
return metadata
def _scrub_run_incarnation(value: dict) -> None:
lifecycle_keys = (
THREAD_INCARNATION_CONTEXT_KEY,
THREAD_INCARNATION_METADATA_GUARD_KEY,
)
metadata = _metadata(value)
for key in lifecycle_keys:
metadata.pop(key, None)
kwargs = value.get("kwargs")
if not isinstance(kwargs, dict):
kwargs = {}
value["kwargs"] = kwargs
context = kwargs.get("context")
if not isinstance(context, dict):
context = {}
kwargs["context"] = context
runtime_keys = (*lifecycle_keys, "user_id", "thread_id", "run_id")
for key in runtime_keys:
context.pop(key, None)
config = kwargs.get("config")
if not isinstance(config, dict):
config = {}
kwargs["config"] = config
for key in ("context", "metadata", "configurable"):
section = config.get(key)
if isinstance(section, dict):
for protected_key in runtime_keys:
section.pop(protected_key, None)
async def _read_standalone_thread(thread_id, ctx) -> dict | None:
from langgraph_runtime.database import connect
from langgraph_runtime.ops import Threads
try:
async with connect() as conn:
rows = await Threads.get(conn, thread_id, ctx=ctx)
return await anext(rows, None)
except HTTPException as exc:
if exc.status_code == 404:
return None
raise
async def _ensure_standalone_thread_incarnation(
thread_id,
assistant_id,
ctx,
*,
create_if_missing: bool,
creation_metadata: dict | None = None,
) -> str | None | object:
thread = await _read_standalone_thread(thread_id, ctx)
if thread is None:
if not create_if_missing:
return _MISSING
incarnation = uuid4().hex
else:
metadata = thread.get("metadata")
stored = metadata.get(THREAD_INCARNATION_CONTEXT_KEY, _MISSING) if isinstance(metadata, dict) else _MISSING
if stored is _MISSING:
# Match the rollout contract used by the Gateway and embedded
# runtime: a persisted pre-incarnation thread remains in the
# explicit legacy generation. A read/patch backfill cannot fence
# deletion plus same-ID recreation through LangGraph's public API.
return None
if not is_valid_thread_incarnation(stored):
raise RuntimeError("Standalone LangGraph thread has an invalid incarnation")
return stored
from langgraph_api.utils import AuthContext as RuntimeAuthContext
from langgraph_runtime.database import connect
from langgraph_runtime.ops import Assistants, Threads
token = _allow_thread_incarnation_write.set(True)
try:
async with connect() as conn:
auth_token = RuntimeAuthContext.set(None)
try:
assistant_rows = await Assistants.get(conn, assistant_id, ctx=None)
assistant = await anext(assistant_rows, None)
finally:
RuntimeAuthContext.reset(auth_token)
if assistant is None:
raise HTTPException(status_code=404, detail="Assistant not found")
if assistant.get("metadata", {}).get("created_by") != "system":
authorized_rows = await Assistants.get(conn, assistant_id, ctx=ctx)
if await anext(authorized_rows, None) is None:
raise HTTPException(status_code=404, detail="Assistant not found")
rows = await Threads.put(
conn,
thread_id,
metadata={**(creation_metadata or {}), THREAD_INCARNATION_CONTEXT_KEY: incarnation},
if_exists="do_nothing",
ctx=ctx,
)
if await anext(rows, None) is None:
raise RuntimeError("Standalone LangGraph thread incarnation was not persisted")
finally:
_allow_thread_incarnation_write.reset(token)
persisted = await _read_standalone_thread(thread_id, ctx)
if persisted is None:
raise RuntimeError("Standalone LangGraph thread incarnation could not be verified")
persisted_metadata = persisted.get("metadata")
value = persisted_metadata.get(THREAD_INCARNATION_CONTEXT_KEY, _MISSING) if isinstance(persisted_metadata, dict) else _MISSING
if value is _MISSING:
# A mixed-version peer may have won the do-nothing create with a
# legacy thread. Keep that persisted lifecycle on the legacy scope.
return None
if not is_valid_thread_incarnation(value):
raise RuntimeError("Standalone LangGraph thread incarnation could not be verified")
return value
async def _bind_standalone_run_incarnation(ctx, value: dict) -> None:
_scrub_run_incarnation(value)
thread_id = value.get("thread_id")
if thread_id is None:
incarnation: str | None = None
else:
# Pre-creation bypasses LangGraph's implicit-create metadata merge.
# Preserve its precedence using only the already-sanitized metadata;
# the helper adds the server incarnation last and never updates an
# existing thread (including a concurrent creation winner).
config_metadata = value["kwargs"]["config"].get("metadata")
creation_metadata = {
**(config_metadata if isinstance(config_metadata, dict) else {}),
**value["metadata"],
}
incarnation = await _ensure_standalone_thread_incarnation(
thread_id,
value["assistant_id"],
ctx,
create_if_missing=value.get("if_not_exists") == "create",
creation_metadata=creation_metadata,
)
if incarnation is _MISSING:
return
assert is_valid_thread_incarnation(incarnation)
context = value["kwargs"]["context"]
context["user_id"] = ctx.user.identity
if thread_id is not None:
context["thread_id"] = str(thread_id)
else:
context.pop("thread_id", None)
run_id = value.get("run_id")
if run_id is not None:
context["run_id"] = str(run_id)
else:
context.pop("run_id", None)
context[THREAD_INCARNATION_CONTEXT_KEY] = incarnation
context[THREAD_INCARNATION_METADATA_GUARD_KEY] = True
def _check_csrf(request) -> None:
"""Enforce Double Submit Cookie CSRF check for state-changing requests.
Mirrors Gateway's CSRFMiddleware logic so that LangGraph routes
proxied directly by nginx have the same CSRF protection.
"""
method = getattr(request, "method", "") or ""
if method.upper() not in _CSRF_METHODS:
return
if is_auth_disabled():
return
cookie_token = request.cookies.get("csrf_token")
header_token = request.headers.get("x-csrf-token")
if not cookie_token or not header_token:
raise Auth.exceptions.HTTPException(
status_code=403,
detail="CSRF token missing. Include X-CSRF-Token header.",
)
if not constant_time_equals(cookie_token, header_token):
raise Auth.exceptions.HTTPException(
status_code=403,
detail="CSRF token mismatch.",
)
@auth.authenticate
async def authenticate(request):
"""Validate the session cookie, decode JWT, and check token_version.
Same validation chain as Gateway's get_current_user_from_request:
cookie → decode JWT → DB lookup → token_version match
Also enforces CSRF on state-changing methods.
"""
# CSRF check before authentication so forged cross-site requests
# are rejected early, even if the cookie carries a valid JWT.
_check_csrf(request)
if is_auth_disabled():
return AUTH_DISABLED_USER_ID
token = request.cookies.get("access_token")
if not token:
raise Auth.exceptions.HTTPException(
status_code=401,
detail="Not authenticated",
)
payload = decode_token(token)
if isinstance(payload, TokenError):
raise Auth.exceptions.HTTPException(
status_code=401,
detail="Invalid token",
)
user = await get_local_provider().get_user(payload.sub)
if user is None:
raise Auth.exceptions.HTTPException(
status_code=401,
detail="User not found",
)
# Same post-lookup verdicts as every other JWT surface — including
# account suspension, so a disabled user's still-valid cookie is
# rejected here exactly as the Gateway's HTTP dependency rejects it.
from app.gateway.auth.errors import AuthErrorCode
from app.gateway.deps import validate_resolved_session_user
failure = validate_resolved_session_user(user, payload)
if failure is not None:
raise Auth.exceptions.HTTPException(
status_code=401,
detail="Token revoked (password changed)" if failure is AuthErrorCode.TOKEN_INVALID else "Account disabled",
)
return payload.sub
@auth.on
async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict):
"""Inject user_id metadata on writes; filter by user_id on reads.
Gateway stores thread ownership as ``metadata.user_id``.
This handler ensures LangGraph Server enforces the same isolation.
"""
# LangGraph represents its trusted local Studio principal with a dedicated
# user type. Do not infer that privilege from its public identity string:
# an ordinary authenticated principal may reuse the same string.
if _STUDIO_USER_TYPE is not None and isinstance(ctx.user, _STUDIO_USER_TYPE) and ctx.resource == "assistants" and ctx.action in {"read", "search"}:
return {
"$or": [
{"created_by": "system"},
{"user_id": ctx.user.identity},
]
}
# Ownership and provenance on external assistant writes are server-owned.
# LangGraph treats ``created_by=system`` as privileged during run creation,
# so accepting that marker from request metadata would cross the auth
# boundary. The standalone pre-runtime persistence repair also scrubs this
# marker from legacy active rows and their version history before normal
# version selection becomes available.
metadata = _metadata(value)
metadata["user_id"] = ctx.user.identity
if ctx.resource == "threads" and ctx.action == "create_run":
await _bind_standalone_run_incarnation(ctx, value)
elif ctx.resource == "threads":
if ctx.action == "create":
if not _allow_thread_incarnation_write.get():
metadata[THREAD_INCARNATION_CONTEXT_KEY] = uuid4().hex
elif not is_valid_thread_incarnation(metadata.get(THREAD_INCARNATION_CONTEXT_KEY, _MISSING)):
raise RuntimeError("Standalone LangGraph internal thread create has an invalid incarnation")
elif ctx.action == "update" and not _allow_thread_incarnation_write.get():
metadata.pop(THREAD_INCARNATION_CONTEXT_KEY, None)
if ctx.resource == "assistants" and ctx.action in {"create", "update"}:
metadata["created_by"] = "user"
# Return filter dict — LangGraph applies it to search/read/delete
return {"user_id": ctx.user.identity}