feat(auth): lifespan _ensure_admin_user backfills missing workspace

Pre-PR4 admins have users.default_workspace_id NULL. Without this
backfill they would log in successfully but immediately bounce off the
workspace gate (T4.7) because their token cannot encode a wid. The
lifespan hook now resolves the admin user and calls
ensure_default_workspace (idempotent — no-op if the user already has
a workspace), so the post-upgrade boot makes the admin usable.

Renamed routers.auth._ensure_default_workspace → ensure_default_workspace
to make it importable across modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-05-12 22:39:09 +08:00
parent 91846a201e
commit 5c7753c0b8
3 changed files with 160 additions and 6 deletions
+6 -6
View File
@@ -23,7 +23,7 @@ from app.gateway.deps import get_current_user_from_request, get_local_provider
logger = logging.getLogger(__name__)
async def _ensure_default_workspace(user) -> str:
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
@@ -343,7 +343,7 @@ async def login_local(
_record_login_success(client_ip)
# Ensure the user has a workspace (covers pre-PR4 users still in DB
# whose default_workspace_id was never backfilled by the lifespan hook).
workspace_id = await _ensure_default_workspace(user)
workspace_id = await ensure_default_workspace(user)
token = create_access_token(
str(user.id),
token_version=user.token_version,
@@ -373,7 +373,7 @@ async def register(request: Request, response: Response, body: RegisterRequest):
detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(),
)
workspace_id = await _ensure_default_workspace(user)
workspace_id = await ensure_default_workspace(user)
token = create_access_token(
str(user.id),
@@ -434,9 +434,9 @@ async def change_password(request: Request, response: Response, body: ChangePass
# Re-issue cookie with new token_version. wid + role must be carried
# forward so the re-signed JWT still passes the AuthMiddleware
# workspace gate; _ensure_default_workspace fills in for the (rare)
# workspace gate; ensure_default_workspace fills in for the (rare)
# case where the user predates PR4 and has not been backfilled.
workspace_id = await _ensure_default_workspace(user)
workspace_id = await ensure_default_workspace(user)
token = create_access_token(
str(user.id),
token_version=user.token_version,
@@ -555,7 +555,7 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
)
workspace_id = await _ensure_default_workspace(user)
workspace_id = await ensure_default_workspace(user)
token = create_access_token(
str(user.id),