feat(auth): AuthMiddleware injects workspace ContextVar from JWT wid/role
deps.get_current_user_from_request now stashes the decoded payload on request.state.auth_payload so AuthMiddleware can populate the workspace_context ContextVar without a second decode. Reset is paired in the same try/finally as user_context to keep teardown atomic. Also adds: - auth.models.ActiveWorkspace — minimal proxy that satisfies the CurrentWorkspace protocol (id + role only). - auth.models.User.default_workspace_id — surfaces the DB column added in T4.4 so the eventual /auth/me payload can reference it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,12 @@ class User(BaseModel):
|
||||
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
||||
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
||||
|
||||
# Workspace linkage (Stage 0 PR4)
|
||||
default_workspace_id: str | None = Field(
|
||||
default=None,
|
||||
description="The workspace the user lands in by default after login. NULL → /select-workspace.",
|
||||
)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Response model for user info endpoint."""
|
||||
@@ -39,3 +45,18 @@ class UserResponse(BaseModel):
|
||||
email: str
|
||||
system_role: Literal["admin", "user"]
|
||||
needs_setup: bool = False
|
||||
|
||||
|
||||
class ActiveWorkspace(BaseModel):
|
||||
"""Lightweight workspace proxy injected into the request-scoped contextvar.
|
||||
|
||||
Implements the structural ``CurrentWorkspace`` protocol expected by
|
||||
``deerflow.runtime.workspace_context``: only ``.id`` (str) and
|
||||
``.role`` (str) are required. We intentionally do *not* embed the
|
||||
full ``WorkspaceRow`` here — the middleware needs to set the
|
||||
contextvar on every request and an extra DB lookup just to populate
|
||||
a name/slug we don't use yet would be wasted work.
|
||||
"""
|
||||
|
||||
id: str
|
||||
role: str
|
||||
|
||||
@@ -17,9 +17,11 @@ from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.auth.models import ActiveWorkspace
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||
|
||||
# Paths that never require authentication.
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
@@ -119,8 +121,22 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
# JWT-decode + DB-lookup pipeline a second time per request).
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
token = set_current_user(user)
|
||||
user_token = set_current_user(user)
|
||||
|
||||
# Inject workspace contextvar from the JWT's wid/role claims.
|
||||
# decode_token has already rejected legacy no-wid tokens upstream,
|
||||
# so by the time we get here payload.wid is guaranteed non-None
|
||||
# for cookie-authenticated requests. Internal-auth requests skip
|
||||
# the workspace contextvar (they don't have a workspace scope —
|
||||
# the internal user is a system actor).
|
||||
ws_token = None
|
||||
payload = getattr(request.state, "auth_payload", None)
|
||||
if payload is not None and payload.wid is not None:
|
||||
ws_token = set_current_workspace(ActiveWorkspace(id=payload.wid, role=payload.role or "owner"))
|
||||
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
if ws_token is not None:
|
||||
reset_current_workspace(ws_token)
|
||||
reset_current_user(user_token)
|
||||
|
||||
@@ -220,6 +220,10 @@ async def get_current_user_from_request(request: Request):
|
||||
detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(),
|
||||
)
|
||||
|
||||
# Stash decoded payload on request.state so AuthMiddleware can read
|
||||
# wid/role for the workspace contextvar without a second decode.
|
||||
request.state.auth_payload = payload
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user