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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user