mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
* fix(auth): claim the first admin atomically instead of counting first POST /api/v1/auth/initialize read the admin count in one session and created the admin in another, with no lock between them. Two requests arriving together with different emails both saw an empty system and both became admins: reproduced over HTTP, two 201s and two admin rows, after which setup-status reports an initialized system. The endpoint is public and unthrottled, and during the pre-setup window anyone who can reach the gateway can claim admin with a single request anyway — that part is the first-boot design. What the race removes is the only signal: without it exactly one request wins and the loser gets 409, so an operator who loses sees "already initialized" and looks into it. With it, both succeed and the operator's setup looks ordinary. The check and the insert now share one transaction in the repository, with writers serialized first — SQLite takes the write lock up front (BEGIN IMMEDIATE) and Postgres a transaction-scoped advisory lock, the idiom the project and channel repositories already use. The same repository serves both backends, and multi-replica deployments make the race genuinely parallel rather than an event-loop interleaving. create_first_admin returns None when the claim is lost, which the route reports as 409; uniqueness conflicts still raise ValueError and stay a 400. The count in the handler is now only a fast path that avoids hashing a password for an already-initialized system. AUTH_DESIGN.md said concurrent initialization was backstopped by a database unique constraint. No such constraint exists — email uniqueness only catches the same address submitted twice — so it now describes the claim that does the work. * docs(changelog): note the atomic first-admin claim (#5776) * fix(auth): refuse a dialect the first-admin claim cannot serialize Review follow-up. The claim's serialization handled sqlite and postgresql and fell through silently for anything else, which would leave create_first_admin as the plain check-then-act it exists to prevent — the one place the abstract method's atomicity contract is enforced. An unknown dialect now raises instead. The engine builds only these two today, so this guards a future backend: it fails loudly at first boot rather than quietly reopening the race.
129 lines
3.8 KiB
Python
129 lines
3.8 KiB
Python
"""User repository interface for abstracting database operations."""
|
|
|
|
from abc import ABC, abstractmethod
|
|
|
|
from app.gateway.auth.models import User
|
|
|
|
|
|
class UserNotFoundError(LookupError):
|
|
"""Raised when a user repository operation targets a non-existent row.
|
|
|
|
Subclass of :class:`LookupError` so callers that already catch
|
|
``LookupError`` for "missing entity" can keep working unchanged,
|
|
while specific call sites can pin to this class to distinguish
|
|
"concurrent delete during update" from other lookups.
|
|
"""
|
|
|
|
|
|
class UserRepository(ABC):
|
|
"""Abstract interface for user data storage.
|
|
|
|
Implement this interface to support different storage backends
|
|
(SQLite)
|
|
"""
|
|
|
|
@abstractmethod
|
|
async def create_user(self, user: User) -> User:
|
|
"""Create a new user.
|
|
|
|
Args:
|
|
user: User object to create
|
|
|
|
Returns:
|
|
Created User with ID assigned. ``email`` is the canonical
|
|
(lowercase) stored form, which may differ in case from what was
|
|
passed in -- implementations mutate the input ``user`` in place
|
|
to reflect this rather than returning a fresh object.
|
|
|
|
Raises:
|
|
ValueError: If email already exists
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def create_first_admin(self, user: User) -> User | None:
|
|
"""Create *user* as the first admin, atomically.
|
|
|
|
Implementations must read the admin count and insert in one
|
|
serialized transaction: a count-then-create pair lets two concurrent
|
|
first-boot requests both find an empty system.
|
|
|
|
Returns:
|
|
The created User, or None if an admin already exists.
|
|
|
|
Raises:
|
|
ValueError: If email already exists
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def get_user_by_id(self, user_id: str) -> User | None:
|
|
"""Get user by ID.
|
|
|
|
Args:
|
|
user_id: User UUID as string
|
|
|
|
Returns:
|
|
User if found, None otherwise
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def get_user_by_email(self, email: str) -> User | None:
|
|
"""Get user by email.
|
|
|
|
Args:
|
|
email: User email address
|
|
|
|
Returns:
|
|
User if found, None otherwise
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def update_user(self, user: User) -> User:
|
|
"""Update an existing user.
|
|
|
|
Args:
|
|
user: User object with updated fields
|
|
|
|
Returns:
|
|
Updated User. ``email`` is the canonical (lowercase) stored
|
|
form -- implementations mutate the input ``user`` in place to
|
|
reflect this rather than returning a fresh object.
|
|
|
|
Raises:
|
|
UserNotFoundError: If no row exists for ``user.id``. This is
|
|
a hard failure (not a no-op) so callers cannot mistake a
|
|
concurrent-delete race for a successful update.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def count_users(self) -> int:
|
|
"""Return total number of registered users."""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def list_user_ids(self) -> list[str]:
|
|
"""Return every registered user ID in deterministic creation order."""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def count_admin_users(self) -> int:
|
|
"""Return number of users with system_role == 'admin'."""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
|
"""Get user by OAuth provider and ID.
|
|
|
|
Args:
|
|
provider: OAuth provider name (e.g. 'github', 'google')
|
|
oauth_id: User ID from the OAuth provider
|
|
|
|
Returns:
|
|
User if found, None otherwise
|
|
"""
|
|
raise NotImplementedError
|