feat(auth): /initialize auto-creates default workspace + owner membership

initialize_admin now seeds a 1-person workspace immediately after the
admin user is created: WorkspaceRepository.create(name, slug, owner_id)
+ WorkspaceMembershipRepository.add(role='owner') + writes the new
workspace id back to users.default_workspace_id. The session JWT is
re-issued with wid + role='owner' so subsequent requests pass the
T4.7 workspace gate.

Mechanical pieces:
- SQLiteUserRepository row<->user mapping now includes
  default_workspace_id (sql update_user too) so the column persists.
- workspace.sql.SLUG_BLACKLIST is now public (was _SLUG_BLACKLIST) and
  the registration helper treats blacklisted slugs as "taken" so the
  walker steps past reserved names like "admin" instead of crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-05-12 22:31:53 +08:00
parent a06e88d58b
commit a657d17995
4 changed files with 233 additions and 3 deletions
@@ -46,6 +46,7 @@ class SQLiteUserRepository(UserRepository):
oauth_id=row.oauth_id,
needs_setup=row.needs_setup,
token_version=row.token_version,
default_workspace_id=row.default_workspace_id,
)
@staticmethod
@@ -60,6 +61,7 @@ class SQLiteUserRepository(UserRepository):
oauth_id=user.oauth_id,
needs_setup=user.needs_setup,
token_version=user.token_version,
default_workspace_id=user.default_workspace_id,
)
# ── CRUD ──────────────────────────────────────────────────────────
@@ -106,6 +108,7 @@ class SQLiteUserRepository(UserRepository):
row.oauth_id = user.oauth_id
row.needs_setup = user.needs_setup
row.token_version = user.token_version
row.default_workspace_id = user.default_workspace_id
await session.commit()
return user
+56 -1
View File
@@ -15,11 +15,59 @@ from app.gateway.auth import (
)
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.auth.workspace_slug import auto_slug_from_email, next_available_slug
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_current_user_from_request, get_local_provider
logger = logging.getLogger(__name__)
async def _ensure_default_workspace(user) -> str:
"""Create the user's personal workspace + owner membership, set default_workspace_id.
Returns the new workspace id. Idempotent for users who already
have a default_workspace_id (used by both the registration flow
and the lifespan backfill in app.py).
"""
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.workspace import WorkspaceRepository
from deerflow.persistence.workspace.sql import SLUG_BLACKLIST
from deerflow.persistence.workspace_membership import WorkspaceMembershipRepository
if user.default_workspace_id:
return user.default_workspace_id
sf = get_session_factory()
ws_repo = WorkspaceRepository(sf)
m_repo = WorkspaceMembershipRepository(sf)
base_slug = auto_slug_from_email(user.email)
async def slug_exists(s: str) -> bool:
# Treat blacklisted slugs as "taken" so the walker skips them
# instead of letting WorkspaceRepository.create raise after a
# successful slug computation (the user picked a reserved name
# like "admin@example.com" → base slug "admin").
if s in SLUG_BLACKLIST:
return True
return (await ws_repo.get_by_slug(s)) is not None
unique_slug = await next_available_slug(base_slug, exists_check=slug_exists)
display_local = user.email.split("@", 1)[0]
workspace = await ws_repo.create(
name=f"{display_local}'s Workspace"[:64],
slug=unique_slug,
owner_id=str(user.id),
)
await m_repo.add(workspace_id=workspace["id"], user_id=str(user.id), role="owner")
user.default_workspace_id = workspace["id"]
await get_local_provider().update_user(user)
return workspace["id"]
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@@ -452,7 +500,14 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
)
token = create_access_token(str(user.id), token_version=user.token_version)
workspace_id = await _ensure_default_workspace(user)
token = create_access_token(
str(user.id),
token_version=user.token_version,
workspace_id=workspace_id,
role="owner",
)
_set_session_cookie(response, token, request)
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
@@ -32,7 +32,7 @@ _SLUG_MAX_LEN = 32
# slug 黑名单(应用层校验,不写 DB constraint)。包含 ADR-007 §4 保留 slug
# + 路径 + Next.js 保留 + 业务保留词。
_SLUG_BLACKLIST = frozenset(
SLUG_BLACKLIST = frozenset(
{
"admin",
"api",
@@ -77,7 +77,7 @@ def _validate_slug(slug: str) -> None:
raise WorkspaceValidationError(f"slug length must be between {_SLUG_MIN_LEN} and {_SLUG_MAX_LEN}, got {len(slug)}")
if not _SLUG_PATTERN.fullmatch(slug):
raise WorkspaceValidationError(f"slug {slug!r} does not match required pattern ^[a-z0-9](-?[a-z0-9])*$")
if slug in _SLUG_BLACKLIST:
if slug in SLUG_BLACKLIST:
raise WorkspaceValidationError(f"slug {slug!r} is reserved")
@@ -0,0 +1,172 @@
"""Registration endpoints (POST /initialize, POST /register) auto-create a workspace.
Stage 0 PR4 T4.8 + T4.9. Every newly registered user must end up with:
- a single ``workspaces`` row (their personal workspace),
- a single ``workspace_memberships`` row with ``role='owner'``,
- ``users.default_workspace_id`` pointing at that workspace,
- a session cookie whose JWT carries the workspace as the ``wid`` claim.
Tests run against a per-test SQLite engine bootstrapped by the
fixture; the registration router is exercised through the real
TestClient so the full handler + DB transaction path is covered.
"""
from __future__ import annotations
import asyncio
import os
import jwt
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-register-workspace-32+")
from app.gateway.auth.config import AuthConfig, set_auth_config
_TEST_SECRET = "test-secret-key-register-workspace-32+"
@pytest.fixture(autouse=True)
def _setup_auth(tmp_path):
from app.gateway import deps
from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN
from deerflow.persistence.engine import close_engine, init_engine
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
url = f"sqlite+aiosqlite:///{tmp_path}/register_ws.db"
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
deps._cached_local_provider = None
deps._cached_repo = None
_SETUP_STATUS_COOLDOWN.clear()
try:
yield
finally:
deps._cached_local_provider = None
deps._cached_repo = None
_SETUP_STATUS_COOLDOWN.clear()
asyncio.run(close_engine())
@pytest.fixture()
def client(_setup_auth):
from app.gateway.app import create_app
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
app = create_app()
yield TestClient(app)
def _init_payload(**extra):
return {"email": "admin@example.com", "password": "Str0ng!Pass99", **extra}
def _register_payload(email: str = "alice@example.com", **extra):
return {"email": email, "password": "Tr0ub4dor3a-strong!", **extra}
def _decode(token: str) -> dict:
"""Decode a JWT (signature-checked) and return the raw payload."""
return jwt.decode(token, _TEST_SECRET, algorithms=["HS256"])
async def _read_workspace_state(user_id: str) -> dict:
"""Inspect the per-user workspace state after a registration call.
Returns a dict with the workspace row, the owner membership row and
the user's default_workspace_id, so each test can pick what it
cares about.
"""
from sqlalchemy import select
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace.model import WorkspaceRow
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
sf = get_session_factory()
async with sf() as session:
user = await session.get(UserRow, user_id)
memberships = (await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.user_id == user_id))).scalars().all()
workspaces = []
if memberships:
workspaces = (await session.execute(select(WorkspaceRow).where(WorkspaceRow.id.in_([m.workspace_id for m in memberships])))).scalars().all()
return {
"user_default_workspace_id": getattr(user, "default_workspace_id", None) if user else None,
"memberships": [(m.workspace_id, m.user_id, m.role) for m in memberships],
"workspaces": [(w.id, w.slug, w.owner_id) for w in workspaces],
}
# ---------- T4.8 — /initialize ---------------------------------------------
def test_initialize_creates_admin_with_default_workspace(client):
"""POST /initialize → admin + workspace + owner membership + wid cookie."""
resp = client.post("/api/v1/auth/initialize", json=_init_payload())
assert resp.status_code == 201, resp.text
user_id = resp.json()["id"]
state = asyncio.run(_read_workspace_state(user_id))
assert len(state["workspaces"]) == 1, state
ws_id, ws_slug, ws_owner = state["workspaces"][0]
assert ws_owner == user_id
# base slug "admin" is reserved, walker bumps to first free suffix
assert ws_slug == "admin-2"
assert state["memberships"] == [(ws_id, user_id, "owner")]
assert state["user_default_workspace_id"] == ws_id
token = resp.cookies["access_token"]
claims = _decode(token)
assert claims["wid"] == ws_id
assert claims["role"] == "owner"
# ---------- T4.9 — /register -----------------------------------------------
def test_register_creates_user_with_default_workspace(client):
"""POST /register → user + workspace + owner membership + wid cookie."""
# Initialize an admin first so the system is past first-boot.
client.post("/api/v1/auth/initialize", json=_init_payload())
resp = client.post("/api/v1/auth/register", json=_register_payload())
assert resp.status_code == 201, resp.text
user_id = resp.json()["id"]
state = asyncio.run(_read_workspace_state(user_id))
assert len(state["workspaces"]) == 1, state
ws_id, ws_slug, ws_owner = state["workspaces"][0]
assert ws_owner == user_id
assert ws_slug == "alice"
assert state["memberships"] == [(ws_id, user_id, "owner")]
assert state["user_default_workspace_id"] == ws_id
token = resp.cookies["access_token"]
claims = _decode(token)
assert claims["wid"] == ws_id
assert claims["role"] == "owner"
def test_two_registrations_isolate_workspaces_and_avoid_slug_collision(client):
"""Two users with colliding email local-parts → distinct workspaces, slug suffix bump."""
client.post("/api/v1/auth/initialize", json=_init_payload())
r1 = client.post("/api/v1/auth/register", json=_register_payload(email="alice@example.com"))
r2 = client.post("/api/v1/auth/register", json=_register_payload(email="alice@somewhere.else"))
assert r1.status_code == 201, r1.text
assert r2.status_code == 201, r2.text
s1 = asyncio.run(_read_workspace_state(r1.json()["id"]))
s2 = asyncio.run(_read_workspace_state(r2.json()["id"]))
ws1 = s1["workspaces"][0]
ws2 = s2["workspaces"][0]
assert ws1[0] != ws2[0], "workspaces must be distinct"
assert ws1[1] == "alice"
assert ws2[1] == "alice-2", "slug collision walker should land on -2"