feat(auth)!: migrate customer metadata auth

Remove local auth and user-management endpoints in favor of Keycloak-backed metadata users, project context, admin metadata APIs, and agent auth context.
This commit is contained in:
2026-06-13 15:00:58 +08:00
parent 4b02118286
commit c7947a7481
28 changed files with 1562 additions and 1155 deletions
+6 -14
View File
@@ -33,8 +33,6 @@ class AuditMiddleware(BaseHTTPMiddleware):
# 需要审计的路径前缀
AUDIT_PATHS = [
# "/api/v1/auth/",
# "/api/v1/users/",
# "/api/v1/projects/",
# "/api/v1/networks/",
]
@@ -193,20 +191,14 @@ class AuditMiddleware(BaseHTTPMiddleware):
return None
sub = None
try:
key = (
settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
if settings.KEYCLOAK_PUBLIC_KEY
else settings.SECRET_KEY
)
algorithms = (
[settings.KEYCLOAK_ALGORITHM]
if settings.KEYCLOAK_PUBLIC_KEY
else [settings.ALGORITHM]
)
if not settings.KEYCLOAK_PUBLIC_KEY:
return None
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
payload = jwt.decode(
token,
key,
algorithms=algorithms,
algorithms=[settings.KEYCLOAK_ALGORITHM],
audience=settings.KEYCLOAK_AUDIENCE or None,
)
sub = payload.get("sub")
@@ -221,7 +213,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
keycloak_id = UUID(sub)
user = await repo.get_user_by_keycloak_id(keycloak_id)
except ValueError:
user = await repo.get_user_by_username(sub)
return None
if user and user.is_active:
return user.id
return None
+2 -2
View File
@@ -54,9 +54,9 @@ class ProjectConnectionManager:
def _normalize_pg_url(self, url: str) -> str:
parsed = make_url(url)
if parsed.drivername == "postgresql":
if parsed.drivername in {"postgresql", "postgres"}:
parsed = parsed.set(drivername="postgresql+psycopg")
return str(parsed)
return parsed.render_as_string(hide_password=False)
async def get_pg_sessionmaker(
self,
@@ -1,9 +1,10 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, List
from uuid import UUID
from uuid import UUID, uuid4
from cryptography.fernet import InvalidToken
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.encryption import (
@@ -64,6 +65,27 @@ class ProjectDetail:
status: str
@dataclass(frozen=True)
class ProjectMemberSummary:
id: UUID
user_id: UUID
project_id: UUID
project_role: str
username: str
email: str
is_active: bool
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _encrypt_database_secret(value: str) -> str:
if not is_database_encryption_configured():
raise ValueError("DATABASE_ENCRYPTION_KEY is not configured")
return get_database_encryptor().encrypt(value)
class MetadataRepository:
"""元数据访问层(system_hub"""
@@ -82,6 +104,86 @@ class MetadataRepository:
)
return result.scalar_one_or_none()
async def get_user_by_id(self, user_id: UUID) -> Optional[models.User]:
result = await self.session.execute(
select(models.User).where(models.User.id == user_id)
)
return result.scalar_one_or_none()
async def list_users(self, skip: int = 0, limit: int = 100) -> List[models.User]:
result = await self.session.execute(
select(models.User)
.order_by(models.User.created_at.desc())
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def upsert_user_from_keycloak(
self,
*,
keycloak_id: UUID,
username: str,
email: str,
role: str,
is_active: bool,
) -> models.User:
user = await self.get_user_by_keycloak_id(keycloak_id)
if user is None:
user = models.User(
id=uuid4(),
keycloak_id=keycloak_id,
username=username,
email=email,
role=role,
is_active=is_active,
is_superuser=False,
)
self.session.add(user)
else:
user.username = username
user.email = email
user.role = role
user.is_active = is_active
await self.session.commit()
await self.session.refresh(user)
return user
async def refresh_user_keycloak_snapshot(
self,
user: models.User,
*,
username: str | None,
email: str | None,
last_login_at: datetime | None = None,
) -> models.User:
if username:
user.username = username
if email:
user.email = email
user.last_login_at = last_login_at or _utcnow()
user.updated_at = _utcnow()
await self.session.commit()
await self.session.refresh(user)
return user
async def update_user_admin(
self,
user_id: UUID,
*,
updates: dict,
) -> Optional[models.User]:
user = await self.get_user_by_id(user_id)
if user is None:
return None
if "role" in updates:
user.role = updates["role"]
if "is_active" in updates:
user.is_active = updates["is_active"]
await self.session.commit()
await self.session.refresh(user)
return user
async def get_project_by_id(self, project_id: UUID) -> Optional[models.Project]:
result = await self.session.execute(
select(models.Project).where(models.Project.id == project_id)
@@ -94,11 +196,66 @@ class MetadataRepository:
)
return result.scalar_one_or_none()
async def list_project_records(self) -> List[models.Project]:
result = await self.session.execute(
select(models.Project).order_by(models.Project.name)
)
return list(result.scalars().all())
async def create_project(
self,
*,
name: str,
code: str,
description: str | None,
gs_workspace: str,
map_extent: dict | None,
status: str,
) -> models.Project:
project = models.Project(
id=uuid4(),
name=name,
code=code,
description=description,
gs_workspace=gs_workspace,
map_extent=map_extent,
status=status,
created_at=_utcnow(),
updated_at=_utcnow(),
)
self.session.add(project)
await self.session.commit()
await self.session.refresh(project)
return project
async def update_project(
self,
project_id: UUID,
*,
updates: dict,
) -> Optional[models.Project]:
project = await self.get_project_by_id(project_id)
if project is None:
return None
for field in (
"name",
"code",
"description",
"gs_workspace",
"map_extent",
"status",
):
if field in updates:
setattr(project, field, updates[field])
project.updated_at = _utcnow()
await self.session.commit()
await self.session.refresh(project)
return project
async def get_project_detail_by_code(self, code: str) -> Optional[ProjectDetail]:
project = await self.get_project_by_code(code)
if not project:
return None
return ProjectDetail(
project_id=project.id,
name=project.name,
@@ -120,6 +277,142 @@ class MetadataRepository:
)
return result.scalar_one_or_none()
async def list_project_members(
self, project_id: UUID
) -> List[ProjectMemberSummary]:
stmt = (
select(models.UserProjectMembership, models.User)
.join(models.User, models.User.id == models.UserProjectMembership.user_id)
.where(models.UserProjectMembership.project_id == project_id)
.order_by(models.User.username)
)
result = await self.session.execute(stmt)
return [
ProjectMemberSummary(
id=membership.id,
user_id=membership.user_id,
project_id=membership.project_id,
project_role=membership.project_role,
username=user.username,
email=user.email,
is_active=user.is_active,
)
for membership, user in result.all()
]
async def get_project_membership(
self, project_id: UUID, user_id: UUID
) -> Optional[models.UserProjectMembership]:
result = await self.session.execute(
select(models.UserProjectMembership).where(
models.UserProjectMembership.project_id == project_id,
models.UserProjectMembership.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def add_project_member(
self, project_id: UUID, user_id: UUID, project_role: str
) -> models.UserProjectMembership:
membership = models.UserProjectMembership(
id=uuid4(),
user_id=user_id,
project_id=project_id,
project_role=project_role,
)
self.session.add(membership)
await self.session.commit()
await self.session.refresh(membership)
return membership
async def update_project_member_role(
self, project_id: UUID, user_id: UUID, project_role: str
) -> Optional[models.UserProjectMembership]:
membership = await self.get_project_membership(project_id, user_id)
if membership is None:
return None
membership.project_role = project_role
await self.session.commit()
await self.session.refresh(membership)
return membership
async def remove_project_member(self, project_id: UUID, user_id: UUID) -> bool:
result = await self.session.execute(
delete(models.UserProjectMembership).where(
models.UserProjectMembership.project_id == project_id,
models.UserProjectMembership.user_id == user_id,
)
)
await self.session.commit()
return bool(result.rowcount)
async def list_project_databases(
self, project_id: UUID
) -> List[models.ProjectDatabase]:
result = await self.session.execute(
select(models.ProjectDatabase)
.where(models.ProjectDatabase.project_id == project_id)
.order_by(models.ProjectDatabase.db_role)
)
return list(result.scalars().all())
async def get_project_database_config(
self, project_id: UUID, db_role: str
) -> Optional[models.ProjectDatabase]:
result = await self.session.execute(
select(models.ProjectDatabase).where(
models.ProjectDatabase.project_id == project_id,
models.ProjectDatabase.db_role == db_role,
)
)
return result.scalar_one_or_none()
async def upsert_project_database_config(
self,
project_id: UUID,
*,
db_role: str,
db_type: str,
dsn: str | None,
pool_min_size: int,
pool_max_size: int,
) -> models.ProjectDatabase:
record = await self.get_project_database_config(project_id, db_role)
if record is None:
if dsn is None:
raise ValueError("dsn is required when creating project database config")
record = models.ProjectDatabase(
id=uuid4(),
project_id=project_id,
db_role=db_role,
db_type=db_type,
dsn_encrypted=_encrypt_database_secret(dsn),
pool_min_size=pool_min_size,
pool_max_size=pool_max_size,
)
self.session.add(record)
else:
record.db_type = db_type
if dsn is not None:
record.dsn_encrypted = _encrypt_database_secret(dsn)
record.pool_min_size = pool_min_size
record.pool_max_size = pool_max_size
await self.session.commit()
await self.session.refresh(record)
return record
async def delete_project_database_config(
self, project_id: UUID, db_role: str
) -> bool:
result = await self.session.execute(
delete(models.ProjectDatabase).where(
models.ProjectDatabase.project_id == project_id,
models.ProjectDatabase.db_role == db_role,
)
)
await self.session.commit()
return bool(result.rowcount)
async def get_project_db_routing(
self, project_id: UUID, db_role: str
) -> Optional[ProjectDbRouting]:
@@ -1,235 +0,0 @@
from typing import Optional, List
from datetime import datetime
from app.infra.db.postgresql.database import Database
from app.domain.schemas.user import UserCreate, UserUpdate, UserInDB
from app.domain.models.role import UserRole
from app.core.security import get_password_hash
import logging
logger = logging.getLogger(__name__)
class UserRepository:
"""用户数据访问层"""
def __init__(self, db: Database):
self.db = db
async def create_user(self, user: UserCreate) -> Optional[UserInDB]:
"""
创建新用户
Args:
user: 用户创建数据
Returns:
创建的用户对象
"""
hashed_password = get_password_hash(user.password)
query = """
INSERT INTO users (username, email, hashed_password, role, is_active, is_superuser)
VALUES (%(username)s, %(email)s, %(hashed_password)s, %(role)s, TRUE, FALSE)
RETURNING id, username, email, hashed_password, role, is_active, is_superuser,
created_at, updated_at
"""
try:
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, {
'username': user.username,
'email': user.email,
'hashed_password': hashed_password,
'role': user.role.value
})
row = await cur.fetchone()
if row:
return UserInDB(**row)
except Exception as e:
logger.error(f"Error creating user: {e}")
raise
return None
async def get_user_by_id(self, user_id: int) -> Optional[UserInDB]:
"""根据ID获取用户"""
query = """
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
created_at, updated_at
FROM users
WHERE id = %(user_id)s
"""
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, {'user_id': user_id})
row = await cur.fetchone()
if row:
return UserInDB(**row)
return None
async def get_user_by_username(self, username: str) -> Optional[UserInDB]:
"""根据用户名获取用户"""
query = """
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
created_at, updated_at
FROM users
WHERE username = %(username)s
"""
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, {'username': username})
row = await cur.fetchone()
if row:
return UserInDB(**row)
return None
async def get_user_by_email(self, email: str) -> Optional[UserInDB]:
"""根据邮箱获取用户"""
query = """
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
created_at, updated_at
FROM users
WHERE email = %(email)s
"""
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, {'email': email})
row = await cur.fetchone()
if row:
return UserInDB(**row)
return None
async def get_all_users(self, skip: int = 0, limit: int = 100) -> List[UserInDB]:
"""获取所有用户(分页)"""
query = """
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
created_at, updated_at
FROM users
ORDER BY created_at DESC
LIMIT %(limit)s OFFSET %(skip)s
"""
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, {'skip': skip, 'limit': limit})
rows = await cur.fetchall()
return [UserInDB(**row) for row in rows]
async def update_user(self, user_id: int, user_update: UserUpdate) -> Optional[UserInDB]:
"""
更新用户信息
Args:
user_id: 用户ID
user_update: 更新数据
Returns:
更新后的用户对象
"""
# 构建动态更新语句
update_fields = []
params = {'user_id': user_id}
if user_update.email is not None:
update_fields.append("email = %(email)s")
params['email'] = user_update.email
if user_update.password is not None:
update_fields.append("hashed_password = %(hashed_password)s")
params['hashed_password'] = get_password_hash(user_update.password)
if user_update.role is not None:
update_fields.append("role = %(role)s")
params['role'] = user_update.role.value
if user_update.is_active is not None:
update_fields.append("is_active = %(is_active)s")
params['is_active'] = user_update.is_active
if not update_fields:
return await self.get_user_by_id(user_id)
query = f"""
UPDATE users
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
WHERE id = %(user_id)s
RETURNING id, username, email, hashed_password, role, is_active, is_superuser,
created_at, updated_at
"""
try:
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, params)
row = await cur.fetchone()
if row:
return UserInDB(**row)
except Exception as e:
logger.error(f"Error updating user {user_id}: {e}")
raise
return None
async def delete_user(self, user_id: int) -> bool:
"""
删除用户
Args:
user_id: 用户ID
Returns:
是否成功删除
"""
query = "DELETE FROM users WHERE id = %(user_id)s"
try:
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, {'user_id': user_id})
return cur.rowcount > 0
except Exception as e:
logger.error(f"Error deleting user {user_id}: {e}")
return False
async def user_exists(self, username: str = None, email: str = None) -> bool:
"""
检查用户是否存在
Args:
username: 用户名
email: 邮箱
Returns:
是否存在
"""
conditions = []
params = {}
if username:
conditions.append("username = %(username)s")
params['username'] = username
if email:
conditions.append("email = %(email)s")
params['email'] = email
if not conditions:
return False
query = f"""
SELECT EXISTS(
SELECT 1 FROM users WHERE {' OR '.join(conditions)}
)
"""
async with self.db.get_connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query, params)
result = await cur.fetchone()
return result['exists'] if result else False