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

103 lines
4.8 KiB
Python

"""ORM model for the users table.
Lives in the harness persistence package so it is picked up by
``Base.metadata.create_all()`` alongside ``threads_meta``, ``runs``,
``run_events``, and ``feedback``. Using the shared engine means:
- One SQLite/Postgres database, one connection pool
- One schema initialisation codepath
- Consistent async sessions across auth and persistence reads
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, false, text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
# Single source of truth for the index name, shared with
# app.gateway.auth.repositories.sqlite._is_oauth_identity_violation (which
# has to match a Postgres driver error's constraint_name against exactly
# this string). Previously that name was a separate hardcoded literal
# there, with no test catching the two drifting apart. Migration files
# under persistence/migrations/versions/ intentionally do NOT import this
# -- migrations are frozen historical DDL, not a live view of the model --
# so 0018_oauth_identity_pg_partial.py keeps its own literal by
# convention (consistent with every other revision in that package).
OAUTH_IDENTITY_INDEX_NAME = "idx_users_oauth_identity"
class UserPreferenceRow(Base):
"""Independent keys allow concurrent clients to patch disjoint preferences."""
__tablename__ = "user_preferences"
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
key: Mapped[str] = mapped_column(String(40), primary_key=True)
value: Mapped[object] = mapped_column(JSON, nullable=True)
class UserRow(Base):
__tablename__ = "users"
# UUIDs are stored as 36-char strings for cross-backend portability.
id: Mapped[str] = mapped_column(String(36), primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
# "admin" | "user" — kept as plain string to avoid ALTER TABLE pain
# when new roles are introduced.
system_role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
# OAuth linkage (optional). A partial unique index enforces one
# account per (provider, oauth_id) pair, leaving NULL/NULL rows
# unconstrained so plain password accounts can coexist.
oauth_provider: Mapped[str | None] = mapped_column(String(32), nullable=True)
oauth_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Auth lifecycle flags
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
token_version: Mapped[int] = mapped_column(nullable=False, default=0)
# Account lifecycle (RFC #4063 / issue #3462 gap 3): an operator-disabled
# account is rejected at every authentication surface (password login,
# JWT session resolution, PAT, OAuth) — the row is retained with its
# credentials and role so re-enabling restores exactly what was suspended.
disabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=false())
__table_args__ = (
# sqlite_where alone is a SQLAlchemy dialect-specific kwarg -- it
# does not apply on the postgresql dialect, so a table created
# against Postgres with only sqlite_where builds a FULL
# (non-partial) unique index instead of a partial one. This is
# NOT a correctness bug: verified empirically against a live
# Postgres instance that a full index already enforces the
# intended semantics on its own -- Postgres (like SQLite) treats
# NULL as never-equal-to-NULL in a unique index, so real
# (provider, id) duplicates are already rejected and unlimited
# (NULL, NULL) rows are already allowed, per standard SQL NULL
# handling, independent of the WHERE predicate. postgresql_where
# is added for two smaller, real reasons instead: (1) it matches
# the comment above literally (a partial index, on both
# backends), and (2) a partial index only indexes non-NULL rows,
# so it stays smaller and cheaper to maintain as the
# plain-password-account rows (the common case) accumulate.
Index(
OAUTH_IDENTITY_INDEX_NAME,
"oauth_provider",
"oauth_id",
unique=True,
sqlite_where=text("oauth_provider IS NOT NULL AND oauth_id IS NOT NULL"),
postgresql_where=text("oauth_provider IS NOT NULL AND oauth_id IS NOT NULL"),
),
)