mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30:39 +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>
121 lines
4.9 KiB
Python
121 lines
4.9 KiB
Python
"""User provisioning for OIDC logins.
|
|
|
|
Handles the logic of finding existing users, auto-creating new ones, and
|
|
enforcing email domain restrictions. A pre-existing local account is never
|
|
auto-linked to an OIDC identity: an email collision blocks the SSO login with
|
|
a 409 instead, so an SSO login can never seize a local password account.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
from app.gateway.auth.local_provider import LocalAuthProvider
|
|
from app.gateway.auth.oidc import OIDCIdentity
|
|
from deerflow.config.auth_config import OIDCProviderConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def get_or_provision_oidc_user(
|
|
provider_id: str,
|
|
provider_config: OIDCProviderConfig,
|
|
identity: OIDCIdentity,
|
|
local_provider: LocalAuthProvider,
|
|
) -> dict:
|
|
"""Resolve an OIDC identity to a DeerFlow user.
|
|
|
|
Flow:
|
|
1. Look up existing user by (provider, subject)
|
|
2. If not found, enforce domain/email-verified rules
|
|
3. Block if a local account already owns the email (never auto-link)
|
|
4. Auto-create if enabled
|
|
|
|
Returns a dict with ``user`` (the User model instance) and ``created`` (bool).
|
|
"""
|
|
# 1. Existing OAuth link
|
|
existing = await local_provider.get_user_by_oauth(provider_id, identity.subject)
|
|
if existing:
|
|
if getattr(existing, "disabled", False):
|
|
# Operator-disabled account (#3462 gap 3): the IdP authenticated
|
|
# the identity, but the account is suspended — the row stays
|
|
# linked so re-enabling restores access unchanged.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="This account has been disabled. Please contact your administrator.",
|
|
)
|
|
return {"user": existing, "created": False}
|
|
|
|
# 2. Verified email requirement
|
|
if provider_config.require_verified_email and not identity.email_verified:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=("Your email could not be verified by the identity provider. Please contact your administrator."),
|
|
)
|
|
|
|
if not identity.email:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="The identity provider did not provide an email address.",
|
|
)
|
|
|
|
email = identity.email.lower()
|
|
|
|
# 3. Domain restriction
|
|
if provider_config.allowed_email_domains:
|
|
domain = email.rsplit("@", 1)[-1]
|
|
if domain not in {d.lower().lstrip("@") for d in provider_config.allowed_email_domains}:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Your email domain is not allowed. Please use an approved email address.",
|
|
)
|
|
|
|
# 4. Block if a local account already owns this email. We never auto-link an
|
|
# SSO identity onto a pre-existing local account, since that would let an SSO
|
|
# login take over a password account that happens to share the email.
|
|
local_user = await local_provider.get_user_by_email(email)
|
|
|
|
if local_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=("An account with this email already exists. Contact your administrator to link it to your SSO account."),
|
|
)
|
|
|
|
# 5. Auto-create
|
|
if not provider_config.auto_create_users:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Automatic account creation is disabled. Contact your administrator.",
|
|
)
|
|
|
|
role = _resolve_role(email, provider_config.admin_emails)
|
|
try:
|
|
user = await local_provider.create_oauth_user(
|
|
email=email,
|
|
oauth_provider=provider_id,
|
|
oauth_id=identity.subject,
|
|
system_role=role,
|
|
)
|
|
except ValueError:
|
|
# Lost a race: a concurrent callback (double-click, replayed code) already
|
|
# inserted a row that collides on the unique index. Re-resolve instead of
|
|
# bubbling a raw 500. If the winner created this same identity, return it;
|
|
# otherwise the email now belongs to a different account → 409.
|
|
existing = await local_provider.get_user_by_oauth(provider_id, identity.subject)
|
|
if existing:
|
|
return {"user": existing, "created": False}
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=("An account with this email already exists. Contact your administrator to link it to your SSO account."),
|
|
) from None
|
|
logger.info("Auto-created OIDC user %s (provider=%s, role=%s)", email, provider_id, role)
|
|
return {"user": user, "created": True}
|
|
|
|
|
|
def _resolve_role(email: str, admin_emails: list[str]) -> str:
|
|
"""Return ``admin`` if the email is in the admin list, otherwise ``user``."""
|
|
email_lower = email.lower()
|
|
return "admin" if any(e.lower() == email_lower for e in admin_emails) else "user"
|