mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +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>
336 lines
13 KiB
Python
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}
|