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>
232 lines
11 KiB
Python
232 lines
11 KiB
Python
"""Global authentication middleware — fail-closed safety net.
|
|
|
|
Rejects unauthenticated requests to non-public paths with 401. When a
|
|
request passes the cookie check, resolves the JWT payload to a real
|
|
``User`` object and stamps it into both ``request.state.user`` and the
|
|
``deerflow.runtime.user_context`` contextvar so that repository-layer
|
|
owner filtering works automatically via the sentinel pattern.
|
|
|
|
Fine-grained permission checks remain in authz.py decorators.
|
|
"""
|
|
|
|
import logging
|
|
from collections.abc import Callable
|
|
|
|
from fastapi import HTTPException, Request, Response
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.responses import JSONResponse
|
|
from starlette.types import ASGIApp
|
|
|
|
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
|
from app.gateway.auth_disabled import (
|
|
AUTH_SOURCE_AUTH_DISABLED,
|
|
AUTH_SOURCE_INTERNAL,
|
|
AUTH_SOURCE_PAT,
|
|
AUTH_SOURCE_SESSION,
|
|
get_auth_disabled_user,
|
|
is_auth_disabled,
|
|
)
|
|
from app.gateway.authz import AuthContext, resolve_route_permissions
|
|
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
|
from app.gateway.request_path import get_request_route_path
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Paths that never require authentication.
|
|
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
|
"/health",
|
|
"/docs",
|
|
"/redoc",
|
|
"/openapi.json",
|
|
"/api/v1/auth/oauth/",
|
|
"/api/v1/auth/callback/",
|
|
# Inbound webhooks authenticate themselves via provider-specific signatures
|
|
# (e.g. GitHub's X-Hub-Signature-256), not session cookies.
|
|
"/api/webhooks/",
|
|
)
|
|
|
|
# Exact auth paths that are public (login/register/status check).
|
|
# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public.
|
|
_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
|
{
|
|
"/api/v1/auth/login/local",
|
|
"/api/v1/auth/register",
|
|
"/api/v1/auth/logout",
|
|
"/api/v1/auth/setup-status",
|
|
"/api/v1/auth/initialize",
|
|
"/api/v1/auth/providers",
|
|
}
|
|
)
|
|
|
|
|
|
def _is_public(path: str) -> bool:
|
|
stripped = path.rstrip("/")
|
|
if stripped in _PUBLIC_EXACT_PATHS:
|
|
return True
|
|
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
|
|
|
|
|
class AuthMiddleware(BaseHTTPMiddleware):
|
|
"""Strict auth gate: reject requests without a valid session.
|
|
|
|
Two-stage check for non-public paths:
|
|
|
|
1. Cookie presence — return 401 NOT_AUTHENTICATED if missing
|
|
2. JWT validation via ``get_optional_user_from_request`` — return 401
|
|
TOKEN_INVALID if the token is absent, malformed, expired, or the
|
|
signed user does not exist / is stale
|
|
|
|
On success, stamps ``request.state.user`` and the
|
|
``deerflow.runtime.user_context`` contextvar so that repository-layer
|
|
owner filters work downstream without every route needing a
|
|
``@require_auth`` decorator. Routes that need per-resource
|
|
authorization (e.g. "user A cannot read user B's thread by guessing
|
|
the URL") should additionally use ``@require_permission(...,
|
|
owner_check=True)`` for explicit enforcement — but authentication
|
|
itself is fully handled here.
|
|
"""
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
super().__init__(app)
|
|
|
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
if _is_public(get_request_route_path(request)):
|
|
return await call_next(request)
|
|
|
|
internal_user = None
|
|
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
|
|
# Extract the channel owner user ID from the trusted header.
|
|
# When present, the synthetic internal user carries the actual
|
|
# owner identity so that get_effective_user_id() and per-user
|
|
# filesystem paths (custom skills, memory, thread data) resolve
|
|
# to the IM channel user instead of falling back to "default".
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
|
|
|
|
owner_user_id = request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME)
|
|
if owner_user_id:
|
|
owner_user_id = owner_user_id.strip()
|
|
internal_user = get_internal_user(owner_user_id=owner_user_id or None)
|
|
if owner_user_id and not is_auth_disabled():
|
|
# The internal principal is synthesized from trusted headers
|
|
# without a users-row lookup, so suspension has no other chance
|
|
# to bite on this surface (#3462 gap 3): enforce it here, the
|
|
# one gate every owner-bound internal HTTP call (IM channel
|
|
# dispatch above all) passes through. Auth-disabled mode is
|
|
# exempt — its owner header carries the synthetic
|
|
# auth-disabled identity and no suspendable account exists.
|
|
# Owner ids without a users row stay allowed (a nonexistent
|
|
# account cannot be suspended), and a store that cannot be
|
|
# read cannot hold a suspension verdict either; run admission
|
|
# re-asserts the check when the row is readable.
|
|
from app.gateway.deps import get_local_provider
|
|
|
|
try:
|
|
owner = await get_local_provider().get_user(owner_user_id)
|
|
except Exception:
|
|
logger.warning("Internal-owner suspension check skipped: users lookup failed", exc_info=True)
|
|
owner = None
|
|
if owner is not None and getattr(owner, "disabled", False):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"detail": AuthErrorResponse(
|
|
code=AuthErrorCode.ACCOUNT_DISABLED,
|
|
message="Account disabled",
|
|
).model_dump()
|
|
},
|
|
)
|
|
|
|
auth_source = AUTH_SOURCE_SESSION
|
|
access_token = request.cookies.get("access_token")
|
|
authorization = request.headers.get("authorization")
|
|
pat_scopes: frozenset[str] = frozenset()
|
|
|
|
# Non-public path: require session cookie
|
|
if internal_user is not None:
|
|
user = internal_user
|
|
auth_source = AUTH_SOURCE_INTERNAL
|
|
elif authorization is not None and not is_auth_disabled():
|
|
# Bearer (PAT) credential precedence (#4849): a present-but-invalid
|
|
# Authorization header is a hard 401 and never silently falls back
|
|
# to the session cookie. This is also what makes the CSRF
|
|
# middleware's Bearer skip safe — a cross-site attacker cannot ride
|
|
# a victim's cookie by padding the request with a garbage Bearer
|
|
# header, because the request dies here before any route runs.
|
|
# Auth-disabled mode is an operator override of all authentication,
|
|
# so it stays ahead of the Bearer check (a stray Authorization
|
|
# header from a proxy must not 401 an E2E sandbox).
|
|
from app.gateway.auth.pat import authenticate_pat, is_pat_allowed_route
|
|
|
|
try:
|
|
user, pat_scopes = await authenticate_pat(request.app, authorization)
|
|
except HTTPException as exc:
|
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
# Default-deny route boundary (#5041 review P1-1): scopes only
|
|
# constrain @require_permission routes, so any route outside the
|
|
# explicit PAT policy is closed to PAT callers outright — an
|
|
# all-scopes token must not reach undecorated mutation routes.
|
|
if not is_pat_allowed_route(request.method, get_request_route_path(request)):
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={"detail": "PAT credentials are not permitted on this route"},
|
|
)
|
|
auth_source = AUTH_SOURCE_PAT
|
|
elif access_token:
|
|
# Strict JWT validation: reject junk/expired tokens with 401
|
|
# right here instead of silently passing through. This closes
|
|
# the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8):
|
|
# without this, non-isolation routes like /api/models would
|
|
# accept any cookie-shaped string as authentication.
|
|
#
|
|
# We call the *strict* resolver so that fine-grained error
|
|
# codes (token_expired, token_invalid, user_not_found, …)
|
|
# propagate from AuthErrorCode, not get flattened into one
|
|
# generic code. BaseHTTPMiddleware doesn't let HTTPException
|
|
# bubble up, so we catch and render it as JSONResponse here.
|
|
from app.gateway.deps import get_current_user_from_request
|
|
|
|
try:
|
|
user = await get_current_user_from_request(request)
|
|
except HTTPException as exc:
|
|
if not is_auth_disabled():
|
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
user = get_auth_disabled_user()
|
|
auth_source = AUTH_SOURCE_AUTH_DISABLED
|
|
elif is_auth_disabled():
|
|
user = get_auth_disabled_user()
|
|
auth_source = AUTH_SOURCE_AUTH_DISABLED
|
|
else:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"detail": AuthErrorResponse(
|
|
code=AuthErrorCode.NOT_AUTHENTICATED,
|
|
message="Authentication required",
|
|
).model_dump()
|
|
},
|
|
)
|
|
|
|
# Stamp both request.state.user (for the contextvar pattern)
|
|
# and request.state.auth (so @require_permission's "auth is
|
|
# None" branch short-circuits instead of running the entire
|
|
# JWT-decode + DB-lookup pipeline a second time per request).
|
|
request.state.user = user
|
|
request.state.auth_source = auth_source
|
|
permissions = await resolve_route_permissions(
|
|
user,
|
|
is_internal=auth_source == AUTH_SOURCE_INTERNAL,
|
|
)
|
|
if auth_source == AUTH_SOURCE_PAT:
|
|
# A PAT can only narrow its owning user's permissions: the stored
|
|
# scopes intersect the resolved route permissions, never widen
|
|
# them, and role changes / authorization policy stay authoritative
|
|
# because they were resolved fresh from the owning user above.
|
|
permissions = [permission for permission in permissions if permission in pat_scopes]
|
|
request.state.auth = AuthContext(user=user, permissions=permissions)
|
|
token = set_current_user(user)
|
|
try:
|
|
return await call_next(request)
|
|
finally:
|
|
reset_current_user(token)
|