"""请求依赖:认证、租户上下文、角色校验。 关键点:**租户上下文必须在任何业务查询之前绑定**,否则 RLS 会返回空集, 表现为"数据凭空消失"而不是报错——这类 bug 最难查,所以封装成依赖强制调用。 """ from __future__ import annotations import uuid from dataclasses import dataclass from typing import Annotated from fastapi import Depends, Header, Path from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from .db import bind_tenant, bind_user, get_session from .errors import AppError, ErrorCode from .logging_setup import user_id_ctx, workspace_id_ctx from .models import AppUser, Member from .security import decode_token ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1} @dataclass(frozen=True) class AuthUser: id: uuid.UUID phone: str nickname: str @dataclass(frozen=True) class TenantCtx: ws_id: uuid.UUID user: AuthUser role: str def require_role(self, minimum: str) -> None: if ROLE_RANK.get(self.role, 0) < ROLE_RANK[minimum]: raise AppError(ErrorCode.INSUFFICIENT_ROLE, f"需要 {minimum} 及以上权限") SessionDep = Annotated[AsyncSession, Depends(get_session)] async def get_bearer_token(authorization: Annotated[str | None, Header()] = None) -> str: if not authorization or not authorization.lower().startswith("bearer "): raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问令牌") return authorization.split(" ", 1)[1].strip() TokenDep = Annotated[str, Depends(get_bearer_token)] async def current_user(token: TokenDep, session: SessionDep) -> AuthUser: user_id = decode_token(token, "access") user = await session.get(AppUser, user_id) if user is None: raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在") if user.status != "active": raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用") # 绑定 user_id:member 表策略允许"看到自己的成员记录",用于"我加入了哪些工作区" await bind_user(session, user.id) user_id_ctx.set(str(user.id)) return AuthUser(id=user.id, phone=user.phone, nickname=user.nickname) CurrentUser = Annotated[AuthUser, Depends(current_user)] async def tenant_ctx( ws_id: Annotated[uuid.UUID, Path(alias="ws_id")], user: CurrentUser, session: SessionDep, ) -> TenantCtx: """仅绑定租户(不要求是成员)。用于"凭邀请码加入工作区"这类场景。""" await bind_tenant(session, ws_id, user.id) workspace_id_ctx.set(str(ws_id)) return TenantCtx(ws_id=ws_id, user=user, role="") TenantDep = Annotated[TenantCtx, Depends(tenant_ctx)] async def member_ctx(ctx: TenantDep, session: SessionDep) -> TenantCtx: """要求是有效成员,并把角色带进上下文。""" row = await session.execute( select(Member).where( Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id, Member.status == "active", ) ) member = row.scalar_one_or_none() if member is None: # 不区分"工作区不存在"和"你不是成员",避免探测他人工作区是否存在 raise AppError(ErrorCode.NOT_A_MEMBER, "无权访问该工作区") return TenantCtx(ws_id=ctx.ws_id, user=ctx.user, role=member.role) MemberDep = Annotated[TenantCtx, Depends(member_ctx)] def require_role(minimum: str): async def _guard(ctx: MemberDep) -> TenantCtx: ctx.require_role(minimum) return ctx return _guard OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))] AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]