From 7b1941fdafe5ad893b265627687125aa2ab84964 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Mon, 27 Jul 2026 09:44:06 +0800 Subject: [PATCH] phase0-multi-tenant-rls-rbac --- backend/python/app/auth.py | 28 ++++++++++++++++++++++- backend/python/app/models.py | 44 +++++++++++++++++++++++++++++++++++- backend/python/main.py | 27 ++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/backend/python/app/auth.py b/backend/python/app/auth.py index c02a28a..3367538 100644 --- a/backend/python/app/auth.py +++ b/backend/python/app/auth.py @@ -12,6 +12,30 @@ from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES + +# Phase0: 5-level RBAC hierarchy +ROLE_HIERARCHY = { + "super_admin": 100, + "admin": 80, + "developer": 50, + "user": 30, + "guest": 10, +} + +def has_role(user_role: str, required: str) -> bool: + return ROLE_HIERARCHY.get(user_role, 0) >= ROLE_HIERARCHY.get(required, 0) + + +async def get_current_tenant( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> Optional[str]: + """Phase0: Extract tenant_id from JWT token""" + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + return payload.get("tenant_id") + except JWTError: + return None + logger = logging.getLogger(__name__) security = HTTPBearer() @@ -35,8 +59,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return False -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, tenant_id: str = None) -> str: to_encode = data.copy() + if tenant_id: + to_encode["tenant_id"] = tenant_id expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({"exp": expire}) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) diff --git a/backend/python/app/models.py b/backend/python/app/models.py index 466ddde..e4c89cc 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -32,7 +32,7 @@ class User(Base): phone = Column(String(20), default=None) password_hash = Column(String(255), nullable=False) avatar = Column(String(500), default=None) - role = Column(String(10), nullable=False, default="user") # user / admin + role = Column(String(20), nullable=False, default="user") # super_admin/admin/developer/user/guest status = Column(String(10), nullable=False, default="active") # active / banned real_name_status = Column(String(20), nullable=False, default="unverified") real_name = Column(String(50), default=None) @@ -40,6 +40,7 @@ class User(Base): membership_plan_id = Column(String(36), default=None) membership_level = Column(Integer, default=0) membership_expire_at = Column(DateTime, default=None) + tenant_id = Column(String(36), default=None) auto_renew = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) @@ -76,6 +77,47 @@ class ApiKey(Base): # AI聊天与频道 # ============================================================ + + +class UserGroup(Base): + __tablename__ = "user_groups" + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), unique=True, nullable=False) + description = Column(String(500), default=None) + owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) + tenant_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + owner = relationship("User", foreign_keys=[owner_id]) + +class UserGroupMember(Base): + __tablename__ = "user_group_members" + id = Column(String(36), primary_key=True, default=gen_uuid) + group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + joined_at = Column(DateTime, default=datetime.now) + group = relationship("UserGroup") + user = relationship("User") + +class GroupModelQuota(Base): + __tablename__ = "group_model_quotas" + id = Column(String(36), primary_key=True, default=gen_uuid) + group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False) + model_id = Column(String(36), ForeignKey("models.id"), nullable=False) + daily_limit = Column(Integer, default=1000) + rpm_limit = Column(Integer, default=60) + group = relationship("UserGroup") + model = relationship("AIModel") + +class Tenant(Base): + __tablename__ = "tenants" + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + slug = Column(String(50), unique=True, nullable=False) + status = Column(String(20), default="active") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + class Conversation(Base): __tablename__ = "conversations" diff --git a/backend/python/main.py b/backend/python/main.py index 4c8950e..eec576c 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -11,6 +11,30 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware + + +# Phase0: Tenant RLS Middleware +class TenantRLSMiddleware(BaseHTTPMiddleware): + """Inject tenant_id from JWT into PostgreSQL for RLS policies""" + async def dispatch(self, request: Request, call_next): + try: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + from jose import jwt + from app.auth import SECRET_KEY, ALGORITHM + token = auth_header[7:] + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + tenant_id = payload.get("tenant_id") + if tenant_id: + from app.database import SessionLocal + db = SessionLocal() + try: + db.execute(f"SET app.current_tenant = '{tenant_id}'") + finally: + db.close() + except Exception: + pass + return await call_next(request) from datetime import datetime sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -206,6 +230,9 @@ app.add_middleware( ) # 前台 API + +# Phase0: Register RLS middleware +app.add_middleware(TenantRLSMiddleware) app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])