"""请求依赖:认证、租户上下文、角色校验。 关键点:**租户上下文必须在任何业务查询之前绑定**,否则 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 .config import get_settings 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"))] async def platform_admin(user: CurrentUser) -> AuthUser: """平台管理员:能改 SMTP 这类**跨租户**的系统设置。 刻意不复用工作区角色:某个工作区的 owner 只说明他管得了自己的工作区, 不代表他能看到整台服务的发信账号(那里面存着别人的授权码)。 名单来自配置 `PLATFORM_ADMIN_PHONES`,换人不用发版。 """ if user.phone not in get_settings().platform_admin_phones: raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有平台管理员能改系统设置") return user PlatformAdminDep = Annotated[AuthUser, Depends(platform_admin)]