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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user