Compare commits

...
3 Commits
Author SHA1 Message Date
QihuAI Design 245484a0c5 phase0-final-rls-rbac-complete
CI / gate (push) Has been cancelled
2026-07-27 09:57:55 +08:00
QihuAI Design 2068421d41 phase0-fix-auth-order-db-migration 2026-07-27 09:54:03 +08:00
QihuAI Design 7b1941fdaf phase0-multi-tenant-rls-rbac 2026-07-27 09:44:06 +08:00
3 changed files with 96 additions and 2 deletions

No files matched your search

+26 -1
View File
@@ -12,12 +12,35 @@ from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
# Phase0: 5-level RBAC
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)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
security = HTTPBearer() security = HTTPBearer()
security_optional = HTTPBearer(auto_error=False) security_optional = HTTPBearer(auto_error=False)
async def get_current_tenant(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
return payload.get("tenant_id")
except JWTError:
return None
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
"""使用 bcrypt 直接生成密码哈希""" """使用 bcrypt 直接生成密码哈希"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
@@ -35,8 +58,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return False 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() 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)) expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+43 -1
View File
@@ -32,7 +32,7 @@ class User(Base):
phone = Column(String(20), default=None) phone = Column(String(20), default=None)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
avatar = Column(String(500), default=None) 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 status = Column(String(10), nullable=False, default="active") # active / banned
real_name_status = Column(String(20), nullable=False, default="unverified") real_name_status = Column(String(20), nullable=False, default="unverified")
real_name = Column(String(50), default=None) real_name = Column(String(50), default=None)
@@ -40,6 +40,7 @@ class User(Base):
membership_plan_id = Column(String(36), default=None) membership_plan_id = Column(String(36), default=None)
membership_level = Column(Integer, default=0) membership_level = Column(Integer, default=0)
membership_expire_at = Column(DateTime, default=None) membership_expire_at = Column(DateTime, default=None)
tenant_id = Column(String(36), default=None)
auto_renew = Column(Boolean, default=False) auto_renew = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -76,6 +77,47 @@ class ApiKey(Base):
# AI聊天与频道 # 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): class Conversation(Base):
__tablename__ = "conversations" __tablename__ = "conversations"
+27
View File
@@ -11,6 +11,30 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware 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 from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -206,6 +230,9 @@ app.add_middleware(
) )
# 前台 API # 前台 API
# Phase0: Register RLS middleware
app.add_middleware(TenantRLSMiddleware)
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])