feat(admin): add project metadata config

This commit is contained in:
2026-06-12 15:08:37 +08:00
parent 23c008f602
commit a6e7a2e75c
15 changed files with 2460 additions and 10 deletions
+5 -3
View File
@@ -11,10 +11,12 @@ NETWORK_NAME="tjwater"
# 生成方式: openssl rand -hex 32 # 生成方式: openssl rand -hex 32
SECRET_KEY=your-secret-key-here-change-in-production-use-openssl-rand-hex-32 SECRET_KEY=your-secret-key-here-change-in-production-use-openssl-rand-hex-32
# 数据加密密钥 - 用于敏感数据加密 # 数据加密密钥 - Fernet 格式,生产环境必须替换为独立密钥
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY= # ENCRYPTION_KEY 用于 GeoServer 管理密码等通用敏感配置
DATABASE_ENCRYPTION_KEY="rJC2VqLg4KrlSq+DGJcYm869q4v5KB2dFAeuQTe0I50=" ENCRYPTION_KEY="replace-with-generated-fernet-key"
# DATABASE_ENCRYPTION_KEY 专用于 project_databases.dsn_encrypted
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
# ============================================ # ============================================
# 数据库配置 (PostgreSQL) # 数据库配置 (PostgreSQL)
+95
View File
@@ -0,0 +1,95 @@
# TJWater Authentication and Metadata Management
## Ownership
Keycloak owns login identity, credentials, token issuance, and token expiry.
TJWater metadata stores only business snapshots and authorization data:
- `users.keycloak_id` is the stable identity binding.
- `users.username`, `users.email`, and `users.last_login_at` are Keycloak claim caches.
- `users.role`, `users.is_active`, and `users.is_superuser` control TJWater system access.
- `user_project_membership.project_role` controls project access.
The backend does not accept passwords, does not issue local JWTs, and does not
trust frontend-supplied user IDs.
## Login Snapshot Refresh
Every authenticated metadata-user resolution validates the Keycloak access token
and reads `sub`, `preferred_username` or `username`, and `email` claims. The
backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users,
then refreshes `username`, `email`, and `last_login_at`.
This keeps local display data current without changing the identity binding.
There is no Keycloak webhook requirement; second-level user or permission sync is
out of scope unless explicitly requested later.
## Admin APIs
All admin APIs require metadata admin access: `users.is_superuser = true` or
`users.role = 'admin'`.
User and membership management:
- `GET /api/v1/admin/me`
- `POST /api/v1/admin/users/sync`
- `POST /api/v1/admin/users/sync/batch`
- `GET /api/v1/admin/users`
- `GET /api/v1/admin/users/{user_id}`
- `PATCH /api/v1/admin/users/{user_id}`
- `GET /api/v1/admin/projects/{project_id}/members`
- `POST /api/v1/admin/projects/{project_id}/members`
- `PATCH /api/v1/admin/projects/{project_id}/members/{user_id}`
- `DELETE /api/v1/admin/projects/{project_id}/members/{user_id}`
Project configuration:
- `GET /api/v1/admin/projects`
- `POST /api/v1/admin/projects`
- `PATCH /api/v1/admin/projects/{project_id}`
- `GET /api/v1/admin/projects/{project_id}/databases`
- `PUT /api/v1/admin/projects/{project_id}/databases`
- `DELETE /api/v1/admin/projects/{project_id}/databases/{db_role}`
- `POST /api/v1/admin/projects/{project_id}/databases/{db_role}/health`
- `GET /api/v1/admin/projects/{project_id}/geoserver`
- `PUT /api/v1/admin/projects/{project_id}/geoserver`
## Secret Handling
Admins submit plaintext DSNs and GeoServer passwords only through HTTPS admin
APIs. Operators should not write encrypted columns manually.
- `project_databases.dsn_encrypted` is encrypted with `DATABASE_ENCRYPTION_KEY`.
- `project_geoserver_configs.gs_admin_password_encrypted` is encrypted with
`ENCRYPTION_KEY`.
- Admin responses return only `has_dsn` or `has_password`.
- Audit logs record whether a secret was updated, but never store plaintext DSNs
or passwords.
Generate both keys with:
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
Keep keys stable for the lifetime of encrypted metadata. Rotating a key requires
decrypting with the old key and re-encrypting with the new key.
## Metadata Schema Patches
Apply metadata patches in order:
1. `resources/sql/004_metadata_auth_management.sql`
2. `resources/sql/005_metadata_project_configuration.sql`
`004` creates Keycloak-backed metadata users and project memberships. `005`
creates project, project database routing, and GeoServer configuration tables
with uniqueness, role/type, and pool-size constraints.
## Frontend System Management
`/system-admin` is shown only after `GET /api/v1/admin/me` confirms metadata
admin access. The page lets admins maintain metadata users, project members,
projects, project database routing for `biz_data` and `iot_data`, connection
health checks, and GeoServer config. This replaces direct SQL editing for normal
project onboarding.
+804
View File
@@ -0,0 +1,804 @@
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status
from sqlalchemy import text
from sqlalchemy.engine.url import make_url
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.ext.asyncio import create_async_engine
from app.auth.metadata_dependencies import (
get_current_metadata_admin,
get_metadata_repository,
)
from app.core.audit import AuditAction, log_audit_event
from app.domain.schemas.admin_metadata import (
AdminProjectCreateRequest,
AdminProjectResponse,
AdminProjectUpdateRequest,
MetadataUsersBatchSyncRequest,
MetadataUserResponse,
MetadataUserSyncRequest,
MetadataUserSyncResult,
MetadataUserUpdateRequest,
ProjectDatabaseHealthResponse,
ProjectDatabaseHealthRequest,
ProjectDatabaseResponse,
ProjectDatabaseUpsertRequest,
ProjectDbRole,
ProjectGeoServerConfigResponse,
ProjectGeoServerConfigUpsertRequest,
ProjectMemberCreateRequest,
ProjectMemberResponse,
ProjectMemberUpdateRequest,
)
from app.infra.db.metadb import models
from app.infra.db.metadb.repositories.metadata_repository import (
MetadataRepository,
ProjectDbRouting,
)
router = APIRouter()
def _project_response(project: models.Project) -> AdminProjectResponse:
return AdminProjectResponse(
project_id=project.id,
name=project.name,
code=project.code,
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
)
def _project_database_response(
record: models.ProjectDatabase,
) -> ProjectDatabaseResponse:
return ProjectDatabaseResponse(
id=record.id,
project_id=record.project_id,
db_role=record.db_role,
db_type=record.db_type,
pool_min_size=record.pool_min_size,
pool_max_size=record.pool_max_size,
has_dsn=bool(record.dsn_encrypted),
)
def _geoserver_config_response(
record: models.ProjectGeoServerConfig,
) -> ProjectGeoServerConfigResponse:
return ProjectGeoServerConfigResponse(
id=record.id,
project_id=record.project_id,
gs_base_url=record.gs_base_url,
gs_admin_user=record.gs_admin_user,
gs_datastore_name=record.gs_datastore_name,
default_extent=record.default_extent,
srid=record.srid,
configured=True,
has_password=bool(record.gs_admin_password_encrypted),
updated_at=record.updated_at,
)
def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict:
return {
"db_role": payload.db_role,
"db_type": _db_type_for_role(payload.db_role),
"pool_min_size": payload.pool_min_size,
"pool_max_size": payload.pool_max_size,
"dsn_updated": payload.dsn is not None,
}
def _geoserver_audit_payload(
payload: ProjectGeoServerConfigUpsertRequest,
) -> dict:
return {
"gs_base_url": payload.gs_base_url,
"gs_admin_user": payload.gs_admin_user,
"gs_datastore_name": payload.gs_datastore_name,
"default_extent": payload.default_extent,
"srid": payload.srid,
"password_updated": "gs_admin_password" in payload.model_fields_set,
}
def _to_async_sqlalchemy_url(dsn: str) -> str:
parsed = make_url(dsn)
if parsed.drivername in {"postgresql", "postgres"}:
parsed = parsed.set(drivername="postgresql+psycopg")
return parsed.render_as_string(hide_password=False)
def _db_type_for_role(db_role: str) -> str:
if db_role == "iot_data":
return "timescaledb"
return "postgresql"
def _status_for_config_value_error(exc: ValueError) -> int:
if "ENCRYPTION_KEY" in str(exc):
return status.HTTP_503_SERVICE_UNAVAILABLE
return status.HTTP_400_BAD_REQUEST
async def _check_database_connection(routing: ProjectDbRouting) -> None:
engine = create_async_engine(
_to_async_sqlalchemy_url(routing.dsn),
pool_size=1,
max_overflow=0,
pool_pre_ping=True,
)
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
finally:
await engine.dispose()
def _database_health_error_detail(exc: Exception) -> str:
message = str(exc)
lower_message = message.lower()
if "password authentication failed" in lower_message:
return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
if "connection refused" in lower_message:
return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。"
if "timeout" in lower_message or "timed out" in lower_message:
return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。"
if "could not translate host name" in lower_message or "name or service not known" in lower_message:
return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。"
first_line = message.splitlines()[0] if message else exc.__class__.__name__
return f"连通性测试失败:{first_line}"
async def _upsert_and_audit_metadata_user(
payload: MetadataUserSyncRequest,
*,
current_user,
metadata_repo: MetadataRepository,
response_status: int,
) -> MetadataUserResponse:
user = await metadata_repo.upsert_user_from_keycloak(
keycloak_id=payload.keycloak_id,
username=payload.username,
email=str(payload.email),
role=payload.role,
is_active=payload.is_active,
)
await log_audit_event(
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_type="metadata_user",
resource_id=str(user.id),
request_data=payload.model_dump(mode="json"),
response_status=response_status,
session=metadata_repo.session,
)
return MetadataUserResponse.model_validate(user)
@router.get("/me", response_model=MetadataUserResponse)
async def get_metadata_admin_me(
current_user=Depends(get_current_metadata_admin),
) -> MetadataUserResponse:
return MetadataUserResponse.model_validate(current_user)
@router.post("/users/sync", response_model=MetadataUserResponse)
async def sync_metadata_user(
payload: MetadataUserSyncRequest,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> MetadataUserResponse:
try:
return await _upsert_and_audit_metadata_user(
payload,
current_user=current_user,
metadata_repo=metadata_repo,
response_status=status.HTTP_200_OK,
)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User keycloak_id, username, or email conflicts with an existing user",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
@router.post("/users/sync/batch", response_model=List[MetadataUserSyncResult])
async def sync_metadata_users_batch(
payload: MetadataUsersBatchSyncRequest,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[MetadataUserSyncResult]:
results: list[MetadataUserSyncResult] = []
for item in payload.users:
try:
user = await _upsert_and_audit_metadata_user(
item,
current_user=current_user,
metadata_repo=metadata_repo,
response_status=status.HTTP_200_OK,
)
except IntegrityError as exc:
results.append(
MetadataUserSyncResult(
keycloak_id=item.keycloak_id,
success=False,
error="User keycloak_id, username, or email conflicts with an existing user",
)
)
await metadata_repo.session.rollback()
except SQLAlchemyError as exc:
results.append(
MetadataUserSyncResult(
keycloak_id=item.keycloak_id,
success=False,
error=f"Metadata database error: {exc}",
)
)
await metadata_repo.session.rollback()
else:
results.append(
MetadataUserSyncResult(
keycloak_id=item.keycloak_id,
success=True,
user=user,
)
)
return results
@router.get("/users", response_model=List[MetadataUserResponse])
async def list_metadata_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[MetadataUserResponse]:
users = await metadata_repo.list_users(skip=skip, limit=limit)
return [MetadataUserResponse.model_validate(user) for user in users]
@router.get("/projects", response_model=List[AdminProjectResponse])
async def list_admin_projects(
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[AdminProjectResponse]:
projects = await metadata_repo.list_project_records()
return [_project_response(project) for project in projects]
@router.post(
"/projects",
response_model=AdminProjectResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_admin_project(
payload: AdminProjectCreateRequest,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AdminProjectResponse:
try:
project = await metadata_repo.create_project(
name=payload.name,
code=payload.code,
description=payload.description,
gs_workspace=payload.gs_workspace,
map_extent=payload.map_extent,
status=payload.status,
)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project code or GeoServer workspace conflicts with an existing project",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
await log_audit_event(
action=AuditAction.CREATE,
user_id=current_user.id,
project_id=project.id,
resource_type="project",
resource_id=str(project.id),
request_data=payload.model_dump(mode="json"),
response_status=status.HTTP_201_CREATED,
session=metadata_repo.session,
)
return _project_response(project)
@router.patch(
"/projects/{project_id}",
response_model=AdminProjectResponse,
)
async def update_admin_project(
payload: AdminProjectUpdateRequest,
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> AdminProjectResponse:
updates = payload.model_dump(mode="json", exclude_unset=True)
try:
project = await metadata_repo.update_project(project_id, updates=updates)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project code or GeoServer workspace conflicts with an existing project",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
await log_audit_event(
action=AuditAction.UPDATE,
user_id=current_user.id,
project_id=project.id,
resource_type="project",
resource_id=str(project.id),
request_data=updates,
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return _project_response(project)
@router.get(
"/projects/{project_id}/databases",
response_model=List[ProjectDatabaseResponse],
)
async def list_project_databases(
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[ProjectDatabaseResponse]:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
records = await metadata_repo.list_project_databases(project_id)
return [_project_database_response(record) for record in records]
@router.put(
"/projects/{project_id}/databases",
response_model=ProjectDatabaseResponse,
)
async def upsert_project_database(
payload: ProjectDatabaseUpsertRequest,
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectDatabaseResponse:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
try:
routing = (
ProjectDbRouting(
project_id=project_id,
db_role=payload.db_role,
db_type=_db_type_for_role(payload.db_role),
dsn=payload.dsn,
pool_min_size=payload.pool_min_size,
pool_max_size=payload.pool_max_size,
)
if payload.dsn
else await metadata_repo.get_project_db_routing(project_id, payload.db_role)
)
if routing is None:
raise ValueError("dsn is required when creating project database config")
await _check_database_connection(routing)
except ValueError as exc:
raise HTTPException(
status_code=_status_for_config_value_error(exc),
detail=str(exc),
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=_database_health_error_detail(exc),
) from exc
try:
record = await metadata_repo.upsert_project_database_config(
project_id,
db_role=payload.db_role,
db_type=_db_type_for_role(payload.db_role),
dsn=payload.dsn,
pool_min_size=payload.pool_min_size,
pool_max_size=payload.pool_max_size,
)
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Project database role conflicts with an existing config",
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
await log_audit_event(
action=AuditAction.CONFIG_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_database",
resource_id=payload.db_role,
request_data=_database_audit_payload(payload),
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return _project_database_response(record)
@router.delete(
"/projects/{project_id}/databases/{db_role}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_project_database(
project_id: UUID = Path(...),
db_role: ProjectDbRole = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> None:
removed = await metadata_repo.delete_project_database_config(project_id, db_role)
if not removed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project database config not found",
)
await log_audit_event(
action=AuditAction.CONFIG_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_database",
resource_id=db_role,
request_data={"deleted": True},
response_status=status.HTTP_204_NO_CONTENT,
session=metadata_repo.session,
)
@router.post(
"/projects/{project_id}/databases/{db_role}/health",
response_model=ProjectDatabaseHealthResponse,
)
async def check_project_database_health(
response: Response,
project_id: UUID = Path(...),
db_role: ProjectDbRole = Path(...),
payload: ProjectDatabaseHealthRequest | None = None,
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectDatabaseHealthResponse:
dsn_to_test = payload.dsn if payload and payload.dsn else None
if dsn_to_test:
routing = ProjectDbRouting(
project_id=project_id,
db_role=db_role,
db_type=_db_type_for_role(db_role),
dsn=dsn_to_test,
pool_min_size=1,
pool_max_size=1,
)
else:
try:
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Project database routing DSN is invalid: {exc}",
) from exc
if routing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project database config not found",
)
try:
await _check_database_connection(routing)
except Exception as exc: # health endpoint should return diagnostic status
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return ProjectDatabaseHealthResponse(
project_id=project_id,
db_role=db_role,
db_type=routing.db_type,
ok=False,
detail=_database_health_error_detail(exc),
)
return ProjectDatabaseHealthResponse(
project_id=project_id,
db_role=db_role,
db_type=routing.db_type,
ok=True,
detail="连通性测试通过",
)
@router.get(
"/projects/{project_id}/geoserver",
response_model=ProjectGeoServerConfigResponse,
)
async def get_project_geoserver_config(
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectGeoServerConfigResponse:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
record = await metadata_repo.get_geoserver_config_record(project_id)
if record is None:
return ProjectGeoServerConfigResponse(
id=None,
project_id=project_id,
gs_base_url=None,
gs_admin_user=None,
gs_datastore_name="ds_postgis",
default_extent=None,
srid=4326,
configured=False,
has_password=False,
updated_at=None,
)
return _geoserver_config_response(record)
@router.put(
"/projects/{project_id}/geoserver",
response_model=ProjectGeoServerConfigResponse,
)
async def upsert_project_geoserver_config(
payload: ProjectGeoServerConfigUpsertRequest,
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectGeoServerConfigResponse:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
try:
record = await metadata_repo.upsert_geoserver_config(
project_id,
gs_base_url=payload.gs_base_url,
gs_admin_user=payload.gs_admin_user,
gs_admin_password=payload.gs_admin_password,
password_update_requested="gs_admin_password" in payload.model_fields_set,
gs_datastore_name=payload.gs_datastore_name,
default_extent=payload.default_extent,
srid=payload.srid,
)
except ValueError as exc:
raise HTTPException(
status_code=_status_for_config_value_error(exc),
detail=str(exc),
) from exc
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
await log_audit_event(
action=AuditAction.CONFIG_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_geoserver",
resource_id=str(project_id),
request_data=_geoserver_audit_payload(payload),
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return _geoserver_config_response(record)
@router.get("/users/{user_id}", response_model=MetadataUserResponse)
async def get_metadata_user(
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> MetadataUserResponse:
user = await metadata_repo.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return MetadataUserResponse.model_validate(user)
@router.patch("/users/{user_id}", response_model=MetadataUserResponse)
async def update_metadata_user(
payload: MetadataUserUpdateRequest,
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> MetadataUserResponse:
updates = payload.model_dump(mode="json", exclude_unset=True)
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify themselves",
)
user = await metadata_repo.update_user_admin(
user_id,
updates=updates,
)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await log_audit_event(
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_type="metadata_user",
resource_id=str(user.id),
request_data=updates,
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return MetadataUserResponse.model_validate(user)
@router.get(
"/projects/{project_id}/members",
response_model=List[ProjectMemberResponse],
)
async def list_project_members(
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> List[ProjectMemberResponse]:
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
members = await metadata_repo.list_project_members(project_id)
return [ProjectMemberResponse(**member.__dict__) for member in members]
@router.post(
"/projects/{project_id}/members",
response_model=ProjectMemberResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_project_member(
payload: ProjectMemberCreateRequest,
project_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectMemberResponse:
if payload.user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify their own project membership",
)
project = await metadata_repo.get_project_by_id(project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
user = await metadata_repo.get_user_by_id(payload.user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
existing = await metadata_repo.get_project_membership(project_id, payload.user_id)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User is already a project member",
)
membership = await metadata_repo.add_project_member(
project_id, payload.user_id, payload.project_role
)
await log_audit_event(
action=AuditAction.PERMISSION_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_member",
resource_id=str(payload.user_id),
request_data=payload.model_dump(mode="json"),
response_status=status.HTTP_201_CREATED,
session=metadata_repo.session,
)
return ProjectMemberResponse(
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,
)
@router.patch(
"/projects/{project_id}/members/{user_id}",
response_model=ProjectMemberResponse,
)
async def update_project_member(
payload: ProjectMemberUpdateRequest,
project_id: UUID = Path(...),
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> ProjectMemberResponse:
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify their own project membership",
)
user = await metadata_repo.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
membership = await metadata_repo.update_project_member_role(
project_id, user_id, payload.project_role
)
if membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
)
await log_audit_event(
action=AuditAction.PERMISSION_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_member",
resource_id=str(user_id),
request_data=payload.model_dump(mode="json"),
response_status=status.HTTP_200_OK,
session=metadata_repo.session,
)
return ProjectMemberResponse(
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,
)
@router.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_project_member(
project_id: UUID = Path(...),
user_id: UUID = Path(...),
current_user=Depends(get_current_metadata_admin),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> None:
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Users cannot modify their own project membership",
)
removed = await metadata_repo.remove_project_member(project_id, user_id)
if not removed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
)
await log_audit_event(
action=AuditAction.PERMISSION_CHANGE,
user_id=current_user.id,
project_id=project_id,
resource_type="project_member",
resource_id=str(user_id),
response_status=status.HTTP_204_NO_CONTENT,
session=metadata_repo.session,
)
+4
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import ( from app.api.v1.endpoints import (
admin_metadata,
agent_auth, agent_auth,
project, project,
simulation, simulation,
@@ -54,6 +55,9 @@ api_router = APIRouter()
# Core Services # Core Services
api_router.include_router(agent_auth.router, tags=["Agent Auth"]) api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(
admin_metadata.router, prefix="/admin", tags=["Metadata Admin"]
)
api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增 api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增
api_router.include_router(meta.router, tags=["Metadata"]) api_router.include_router(meta.router, tags=["Metadata"])
api_router.include_router(project.router, tags=["Project"]) api_router.include_router(project.router, tags=["Project"])
+47 -3
View File
@@ -6,8 +6,7 @@ from fastapi import Depends, HTTPException, status
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.keycloak_dependencies import get_current_keycloak_sub from app.auth.keycloak_dependencies import get_current_keycloak_payload
from app.core.config import settings
from app.infra.db.metadb.database import get_metadata_session from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
@@ -20,10 +19,40 @@ async def get_metadata_repository(
return MetadataRepository(session) return MetadataRepository(session)
def _keycloak_sub_from_payload(payload: dict) -> UUID:
sub = payload.get("sub")
if not sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing subject claim",
headers={"WWW-Authenticate": "Bearer"},
)
try:
return UUID(str(sub))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid subject claim",
headers={"WWW-Authenticate": "Bearer"},
) from exc
def _username_from_payload(payload: dict) -> str | None:
username = payload.get("preferred_username") or payload.get("username")
return str(username) if username else None
def _email_from_payload(payload: dict) -> str | None:
email = payload.get("email")
return str(email) if email else None
async def get_current_metadata_user( async def get_current_metadata_user(
keycloak_sub: UUID = Depends(get_current_keycloak_sub), keycloak_payload: dict = Depends(get_current_keycloak_payload),
metadata_repo: MetadataRepository = Depends(get_metadata_repository), metadata_repo: MetadataRepository = Depends(get_metadata_repository),
): ):
keycloak_sub = _keycloak_sub_from_payload(keycloak_payload)
try: try:
user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub) user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub)
except SQLAlchemyError as exc: except SQLAlchemyError as exc:
@@ -39,6 +68,21 @@ async def get_current_metadata_user(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
) )
try:
user = await metadata_repo.refresh_user_keycloak_snapshot(
user,
username=_username_from_payload(keycloak_payload),
email=_email_from_payload(keycloak_payload),
)
except SQLAlchemyError as exc:
logger.error(
"Metadata DB error while refreshing current user snapshot",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Metadata database error: {exc}",
) from exc
return user return user
+156
View File
@@ -0,0 +1,156 @@
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
BusinessRole = Literal["admin", "user", "operator", "viewer"]
ProjectRole = Literal["owner", "admin", "member", "viewer"]
ProjectStatus = Literal["active", "inactive", "archived"]
ProjectDbRole = Literal["biz_data", "iot_data"]
class MetadataUserSyncRequest(BaseModel):
keycloak_id: UUID
username: str = Field(..., min_length=1, max_length=50)
email: str = Field(..., min_length=1, max_length=100)
role: BusinessRole = "user"
is_active: bool = True
class MetadataUsersBatchSyncRequest(BaseModel):
users: list[MetadataUserSyncRequest] = Field(..., min_length=1, max_length=500)
class MetadataUserUpdateRequest(BaseModel):
role: BusinessRole | None = None
is_active: bool | None = None
class MetadataUserResponse(BaseModel):
id: UUID
keycloak_id: UUID
username: str
email: str
role: str
is_active: bool
is_superuser: bool
created_at: datetime
updated_at: datetime
last_login_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class MetadataUserSyncResult(BaseModel):
keycloak_id: UUID
user: MetadataUserResponse | None = None
success: bool
error: str | None = None
class ProjectMemberCreateRequest(BaseModel):
user_id: UUID
project_role: ProjectRole = "viewer"
class ProjectMemberUpdateRequest(BaseModel):
project_role: ProjectRole
class ProjectMemberResponse(BaseModel):
id: UUID
user_id: UUID
project_id: UUID
project_role: str
username: str
email: str
is_active: bool
class AdminProjectCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
code: str = Field(..., min_length=1, max_length=50)
description: str | None = None
gs_workspace: str = Field(..., min_length=1, max_length=100)
map_extent: dict | None = None
status: ProjectStatus = "active"
class AdminProjectUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
code: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
gs_workspace: str | None = Field(default=None, min_length=1, max_length=100)
map_extent: dict | None = None
status: ProjectStatus | None = None
class AdminProjectResponse(BaseModel):
project_id: UUID
name: str
code: str
description: str | None = None
gs_workspace: str
map_extent: dict | None = None
status: str
created_at: datetime
updated_at: datetime
class ProjectDatabaseUpsertRequest(BaseModel):
db_role: ProjectDbRole
dsn: str | None = Field(default=None, min_length=1)
pool_min_size: int = Field(default=2, ge=1)
pool_max_size: int = Field(default=10, ge=1)
@model_validator(mode="after")
def validate_pool_bounds(self):
if self.pool_max_size < self.pool_min_size:
raise ValueError("pool_max_size must be greater than or equal to pool_min_size")
return self
class ProjectDatabaseResponse(BaseModel):
id: UUID
project_id: UUID
db_role: str
db_type: str
pool_min_size: int
pool_max_size: int
has_dsn: bool
class ProjectDatabaseHealthRequest(BaseModel):
dsn: str | None = Field(default=None, min_length=1)
class ProjectDatabaseHealthResponse(BaseModel):
project_id: UUID
db_role: str
db_type: str
ok: bool
detail: str
class ProjectGeoServerConfigUpsertRequest(BaseModel):
gs_base_url: str | None = None
gs_admin_user: str | None = Field(default=None, max_length=50)
gs_admin_password: str | None = Field(default=None, min_length=1)
gs_datastore_name: str = Field(default="ds_postgis", min_length=1, max_length=100)
default_extent: dict | None = None
srid: int = Field(default=4326, ge=1)
class ProjectGeoServerConfigResponse(BaseModel):
id: UUID | None = None
project_id: UUID
gs_base_url: str | None = None
gs_admin_user: str | None = None
gs_datastore_name: str
default_extent: dict | None = None
srid: int
configured: bool = True
has_password: bool
updated_at: datetime | None = None
+2 -2
View File
@@ -54,9 +54,9 @@ class ProjectConnectionManager:
def _normalize_pg_url(self, url: str) -> str: def _normalize_pg_url(self, url: str) -> str:
parsed = make_url(url) parsed = make_url(url)
if parsed.drivername == "postgresql": if parsed.drivername in {"postgresql", "postgres"}:
parsed = parsed.set(drivername="postgresql+psycopg") parsed = parsed.set(drivername="postgresql+psycopg")
return str(parsed) return parsed.render_as_string(hide_password=False)
async def get_pg_sessionmaker( async def get_pg_sessionmaker(
self, self,
@@ -1,9 +1,10 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, List from typing import Optional, List
from uuid import UUID from uuid import UUID, uuid4
from cryptography.fernet import InvalidToken from cryptography.fernet import InvalidToken
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.encryption import ( from app.core.encryption import (
@@ -78,6 +79,33 @@ class ProjectDetail:
geoserver: Optional[ProjectGeoServerInfo] geoserver: Optional[ProjectGeoServerInfo]
@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)
def _encrypt_general_secret(value: str) -> str:
if not is_encryption_configured():
raise ValueError("ENCRYPTION_KEY is not configured")
return get_encryptor().encrypt(value)
class MetadataRepository: class MetadataRepository:
"""元数据访问层(system_hub""" """元数据访问层(system_hub"""
@@ -96,6 +124,86 @@ class MetadataRepository:
) )
return result.scalar_one_or_none() 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]: async def get_project_by_id(self, project_id: UUID) -> Optional[models.Project]:
result = await self.session.execute( result = await self.session.execute(
select(models.Project).where(models.Project.id == project_id) select(models.Project).where(models.Project.id == project_id)
@@ -108,6 +216,62 @@ class MetadataRepository:
) )
return result.scalar_one_or_none() 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]: async def get_project_detail_by_code(self, code: str) -> Optional[ProjectDetail]:
project = await self.get_project_by_code(code) project = await self.get_project_by_code(code)
if not project: if not project:
@@ -137,6 +301,142 @@ class MetadataRepository:
) )
return result.scalar_one_or_none() 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( async def get_project_db_routing(
self, project_id: UUID, db_role: str self, project_id: UUID, db_role: str
) -> Optional[ProjectDbRouting]: ) -> Optional[ProjectDbRouting]:
@@ -198,6 +498,59 @@ class MetadataRepository:
srid=record.srid, srid=record.srid,
) )
async def get_geoserver_config_record(
self, project_id: UUID
) -> Optional[models.ProjectGeoServerConfig]:
result = await self.session.execute(
select(models.ProjectGeoServerConfig).where(
models.ProjectGeoServerConfig.project_id == project_id
)
)
return result.scalar_one_or_none()
async def upsert_geoserver_config(
self,
project_id: UUID,
*,
gs_base_url: str | None,
gs_admin_user: str | None,
gs_admin_password: str | None,
password_update_requested: bool,
gs_datastore_name: str,
default_extent: dict | None,
srid: int,
) -> models.ProjectGeoServerConfig:
record = await self.get_geoserver_config_record(project_id)
encrypted_password: str | None = None
if password_update_requested and gs_admin_password is not None:
encrypted_password = _encrypt_general_secret(gs_admin_password)
if record is None:
record = models.ProjectGeoServerConfig(
id=uuid4(),
project_id=project_id,
gs_base_url=gs_base_url,
gs_admin_user=gs_admin_user,
gs_admin_password_encrypted=encrypted_password,
gs_datastore_name=gs_datastore_name,
default_extent=default_extent,
srid=srid,
updated_at=_utcnow(),
)
self.session.add(record)
else:
record.gs_base_url = gs_base_url
record.gs_admin_user = gs_admin_user
if password_update_requested:
record.gs_admin_password_encrypted = encrypted_password
record.gs_datastore_name = gs_datastore_name
record.default_extent = default_extent
record.srid = srid
record.updated_at = _utcnow()
await self.session.commit()
await self.session.refresh(record)
return record
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]: async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
stmt = ( stmt = (
select(models.Project, models.UserProjectMembership.project_role) select(models.Project, models.UserProjectMembership.project_role)
@@ -0,0 +1,62 @@
-- Metadata auth management schema patch.
-- Keycloak owns login credentials; TJWater stores only business identity and access.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
DECLARE
users_id_type text;
BEGIN
SELECT data_type INTO users_id_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'id';
IF users_id_type IS NULL THEN
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
keycloak_id UUID UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
role VARCHAR(20) DEFAULT 'user' NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
is_superuser BOOLEAN DEFAULT FALSE NOT NULL,
attributes JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
last_login_at TIMESTAMP WITH TIME ZONE
);
ELSIF users_id_type <> 'uuid' THEN
RAISE EXCEPTION
'Existing public.users.id is %, not uuid. Export old local users, create Keycloak accounts, then migrate to metadata UUID users before applying this patch.',
users_id_type;
END IF;
END $$;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS keycloak_id UUID,
ADD COLUMN IF NOT EXISTS attributes JSONB,
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE users
ALTER COLUMN role SET DEFAULT 'user';
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_id ON users(keycloak_id);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
CREATE TABLE IF NOT EXISTS user_project_membership (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
project_id UUID NOT NULL,
project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL,
CONSTRAINT user_project_membership_role_check
CHECK (project_role IN ('owner', 'admin', 'member', 'viewer')),
CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id)
);
CREATE INDEX IF NOT EXISTS idx_user_project_membership_user_id
ON user_project_membership(user_id);
CREATE INDEX IF NOT EXISTS idx_user_project_membership_project_id
ON user_project_membership(project_id);
@@ -0,0 +1,78 @@
-- Metadata project configuration schema patch.
-- Admin APIs write these tables; operators should not hand-edit encrypted values.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
code VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
gs_workspace VARCHAR(100) UNIQUE NOT NULL,
map_extent JSONB,
status VARCHAR(20) DEFAULT 'active' NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived'))
);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_code ON projects(code);
DROP TRIGGER IF EXISTS update_projects_updated_at ON projects;
CREATE TRIGGER update_projects_updated_at
BEFORE UPDATE ON projects
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TABLE IF NOT EXISTS project_databases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
db_role VARCHAR(20) NOT NULL,
db_type VARCHAR(20) NOT NULL,
dsn_encrypted TEXT NOT NULL,
pool_min_size INTEGER DEFAULT 2 NOT NULL,
pool_max_size INTEGER DEFAULT 10 NOT NULL,
CONSTRAINT project_databases_unique_role UNIQUE (project_id, db_role),
CONSTRAINT project_databases_role_check CHECK (db_role IN ('biz_data', 'iot_data')),
CONSTRAINT project_databases_type_check CHECK (db_type IN ('postgresql', 'timescaledb')),
CONSTRAINT project_databases_pool_check CHECK (
pool_min_size >= 1 AND pool_max_size >= pool_min_size
)
);
CREATE INDEX IF NOT EXISTS idx_project_databases_project_id
ON project_databases(project_id);
CREATE INDEX IF NOT EXISTS idx_project_databases_role
ON project_databases(db_role);
CREATE TABLE IF NOT EXISTS project_geoserver_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID UNIQUE NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
gs_base_url TEXT,
gs_admin_user VARCHAR(50),
gs_admin_password_encrypted TEXT,
gs_datastore_name VARCHAR(100) DEFAULT 'ds_postgis' NOT NULL,
default_extent JSONB,
srid INTEGER DEFAULT 4326 NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT project_geoserver_configs_srid_check CHECK (srid >= 1)
);
CREATE INDEX IF NOT EXISTS idx_project_geoserver_configs_project_id
ON project_geoserver_configs(project_id);
DROP TRIGGER IF EXISTS update_project_geoserver_configs_updated_at
ON project_geoserver_configs;
CREATE TRIGGER update_project_geoserver_configs_updated_at
BEFORE UPDATE ON project_geoserver_configs
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Build metadata user sync payloads from an old-user to Keycloak mapping CSV."""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
REQUIRED_COLUMNS = {"keycloak_id", "username", "email"}
def parse_bool(value: str | None) -> bool:
if value is None or value == "":
return True
return value.strip().lower() not in {"0", "false", "no", "n", "disabled"}
def build_payload(mapping_csv: Path) -> dict:
with mapping_csv.open(newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
missing = REQUIRED_COLUMNS.difference(reader.fieldnames or [])
if missing:
raise SystemExit(f"missing required CSV columns: {', '.join(sorted(missing))}")
users = []
for row in reader:
users.append(
{
"keycloak_id": row["keycloak_id"].strip(),
"username": row["username"].strip(),
"email": row["email"].strip(),
"role": (row.get("role") or "user").strip().lower(),
"is_active": parse_bool(row.get("is_active")),
}
)
return {"users": users}
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Convert old local user mappings into a JSON body for "
"POST /api/v1/admin/users/sync/batch. Passwords are never migrated."
)
)
parser.add_argument("mapping_csv", type=Path)
parser.add_argument("-o", "--output", type=Path)
args = parser.parse_args()
payload = build_payload(args.mapping_csv)
content = json.dumps(payload, ensure_ascii=False, indent=2)
if args.output:
args.output.write_text(content + "\n", encoding="utf-8")
else:
print(content)
if __name__ == "__main__":
main()
+646
View File
@@ -0,0 +1,646 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi import Response
from app.auth.metadata_dependencies import get_current_metadata_admin
from app.api.v1.endpoints import admin_metadata
from app.domain.schemas.admin_metadata import (
AdminProjectCreateRequest,
MetadataUsersBatchSyncRequest,
MetadataUserSyncRequest,
MetadataUserUpdateRequest,
ProjectDatabaseUpsertRequest,
ProjectGeoServerConfigUpsertRequest,
ProjectMemberCreateRequest,
ProjectMemberUpdateRequest,
)
from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting
@pytest.fixture
def anyio_backend():
return "asyncio"
def _user(**overrides):
data = {
"id": uuid4(),
"keycloak_id": uuid4(),
"username": "alice",
"email": "alice@example.com",
"role": "user",
"is_active": True,
"is_superuser": False,
"created_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
"updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
"last_login_at": None,
}
data.update(overrides)
return SimpleNamespace(**data)
def _project(**overrides):
data = {"id": uuid4(), "name": "Demo"}
data.update(overrides)
return SimpleNamespace(**data)
def _membership(**overrides):
data = {
"id": uuid4(),
"user_id": uuid4(),
"project_id": uuid4(),
"project_role": "viewer",
}
data.update(overrides)
return SimpleNamespace(**data)
def _database_config(**overrides):
data = {
"id": uuid4(),
"project_id": uuid4(),
"db_role": "biz_data",
"db_type": "postgresql",
"dsn_encrypted": "encrypted-dsn",
"pool_min_size": 1,
"pool_max_size": 5,
}
data.update(overrides)
return SimpleNamespace(**data)
def _geoserver_config(**overrides):
data = {
"id": uuid4(),
"project_id": uuid4(),
"gs_base_url": "http://geoserver",
"gs_admin_user": "admin",
"gs_admin_password_encrypted": "encrypted-password",
"gs_datastore_name": "ds_postgis",
"default_extent": {"bbox": [1, 2, 3, 4]},
"srid": 4326,
"updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
}
data.update(overrides)
return SimpleNamespace(**data)
def test_to_async_sqlalchemy_url_preserves_password():
url = admin_metadata._to_async_sqlalchemy_url(
"postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
)
assert url == "postgresql+psycopg://tjwater:secret@192.168.1.114:5433/tjwater"
assert "***" not in url
@pytest.mark.anyio
async def test_sync_metadata_user_upserts_without_password(monkeypatch):
keycloak_id = uuid4()
synced_user = _user(keycloak_id=keycloak_id, username="new-user")
admin = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
upsert_user_from_keycloak=AsyncMock(return_value=synced_user),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.sync_metadata_user(
MetadataUserSyncRequest(
keycloak_id=keycloak_id,
username="new-user",
email="new-user@example.com",
role="user",
is_active=True,
),
current_user=admin,
metadata_repo=repo,
)
repo.upsert_user_from_keycloak.assert_awaited_once()
kwargs = repo.upsert_user_from_keycloak.await_args.kwargs
assert kwargs["keycloak_id"] == keycloak_id
assert "password" not in kwargs
assert response.username == "new-user"
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
users = [_user(username="alice"), _user(username="bob")]
admin = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=SimpleNamespace(rollback=AsyncMock()),
upsert_user_from_keycloak=AsyncMock(side_effect=users),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.sync_metadata_users_batch(
MetadataUsersBatchSyncRequest(
users=[
MetadataUserSyncRequest(
keycloak_id=users[0].keycloak_id,
username="alice",
email="alice@example.com",
role="user",
is_active=True,
),
MetadataUserSyncRequest(
keycloak_id=users[1].keycloak_id,
username="bob",
email="bob@example.com",
role="viewer",
is_active=True,
),
]
),
current_user=admin,
metadata_repo=repo,
)
assert [item.success for item in response] == [True, True]
assert [item.user.username for item in response] == ["alice", "bob"]
assert repo.upsert_user_from_keycloak.await_count == 2
assert admin_metadata.log_audit_event.await_count == 2
@pytest.mark.anyio
async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
user_id = uuid4()
updated = _user(id=user_id, role="operator", is_active=False)
repo = SimpleNamespace(
session=object(),
update_user_admin=AsyncMock(return_value=updated),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(
role="operator",
is_active=False,
),
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
repo.update_user_admin.assert_awaited_once_with(
user_id,
updates={"role": "operator", "is_active": False},
)
assert response.role == "operator"
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_update_metadata_user_rejects_self_update(monkeypatch):
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
update_user_admin=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(role="viewer"),
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.update_user_admin.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_create_project_audits_metadata_admin_change(monkeypatch):
project = SimpleNamespace(
id=uuid4(),
name="Demo Project",
code="demo",
description="desc",
gs_workspace="demo_ws",
map_extent={"bbox": [1, 2, 3, 4]},
status="active",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
repo = SimpleNamespace(
session=object(),
create_project=AsyncMock(return_value=project),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.create_admin_project(
AdminProjectCreateRequest(
name="Demo Project",
code="demo",
description="desc",
gs_workspace="demo_ws",
map_extent={"bbox": [1, 2, 3, 4]},
status="active",
),
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_id == project.id
repo.create_project.assert_awaited_once()
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_upsert_project_database_hides_dsn_and_audits_without_plaintext(monkeypatch):
project_id = uuid4()
record = _database_config(project_id=project_id)
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
upsert_project_database_config=AsyncMock(return_value=record),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock())
response = await admin_metadata.upsert_project_database(
ProjectDatabaseUpsertRequest(
db_role="biz_data",
dsn="postgresql://user:secret@localhost/db",
pool_min_size=1,
pool_max_size=5,
),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.has_dsn is True
assert "dsn" not in response.model_dump()
admin_metadata._check_database_connection.assert_awaited_once()
repo.upsert_project_database_config.assert_awaited_once()
assert repo.upsert_project_database_config.await_args.kwargs["db_type"] == "postgresql"
request_data = admin_metadata.log_audit_event.await_args.kwargs["request_data"]
assert request_data["dsn_updated"] is True
assert request_data["db_type"] == "postgresql"
assert "dsn" not in request_data
assert "postgresql://user:secret@localhost/db" not in str(request_data)
@pytest.mark.anyio
async def test_upsert_project_database_rejects_unhealthy_connection(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
upsert_project_database_config=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
monkeypatch.setattr(
admin_metadata,
"_check_database_connection",
AsyncMock(
side_effect=Exception(
'FATAL: password authentication failed for user "tjwater"'
)
),
)
with pytest.raises(HTTPException) as exc:
await admin_metadata.upsert_project_database(
ProjectDatabaseUpsertRequest(
db_role="iot_data",
dsn="postgresql://tjwater:bad@192.168.1.114:5433/tjwater",
pool_min_size=1,
pool_max_size=5,
),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert exc.value.status_code == 400
assert exc.value.detail == "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
repo.upsert_project_database_config.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_project_database_health_returns_ok(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
get_project_db_routing=AsyncMock(
return_value=ProjectDbRouting(
project_id=project_id,
db_role="biz_data",
db_type="postgresql",
dsn="postgresql://user:secret@localhost/db",
pool_min_size=1,
pool_max_size=5,
)
)
)
monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock())
response = await admin_metadata.check_project_database_health(
project_id=project_id,
db_role="biz_data",
response=Response(),
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.ok is True
assert response.detail == "连通性测试通过"
admin_metadata._check_database_connection.assert_awaited_once()
@pytest.mark.anyio
async def test_project_database_health_can_test_unsaved_plaintext_dsn(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
get_project_db_routing=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock())
response = await admin_metadata.check_project_database_health(
project_id=project_id,
db_role="iot_data",
payload=admin_metadata.ProjectDatabaseHealthRequest(
dsn="postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
),
response=Response(),
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.ok is True
assert response.db_type == "timescaledb"
routing = admin_metadata._check_database_connection.await_args.args[0]
assert routing.dsn == "postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
repo.get_project_db_routing.assert_not_called()
@pytest.mark.anyio
async def test_project_database_health_sanitizes_password_failures(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
get_project_db_routing=AsyncMock(
return_value=ProjectDbRouting(
project_id=project_id,
db_role="iot_data",
db_type="timescaledb",
dsn="postgresql://tjwater:bad-password@192.168.1.114:5433/db",
pool_min_size=1,
pool_max_size=5,
)
)
)
monkeypatch.setattr(
admin_metadata,
"_check_database_connection",
AsyncMock(
side_effect=Exception(
'(psycopg.OperationalError) connection failed: FATAL: '
'password authentication failed for user "tjwater"'
)
),
)
fastapi_response = Response()
response = await admin_metadata.check_project_database_health(
project_id=project_id,
db_role="iot_data",
response=fastapi_response,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert fastapi_response.status_code == 503
assert response.ok is False
assert response.db_type == "timescaledb"
assert response.detail == "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
assert "psycopg" not in response.detail
@pytest.mark.anyio
async def test_upsert_geoserver_config_hides_password_and_audits_without_plaintext(monkeypatch):
project_id = uuid4()
record = _geoserver_config(project_id=project_id)
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
upsert_geoserver_config=AsyncMock(return_value=record),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.upsert_project_geoserver_config(
ProjectGeoServerConfigUpsertRequest(
gs_base_url="http://geoserver",
gs_admin_user="admin",
gs_admin_password="secret-password",
gs_datastore_name="ds_postgis",
default_extent={"bbox": [1, 2, 3, 4]},
srid=4326,
),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.has_password is True
assert "password" not in response.model_dump()
request_data = admin_metadata.log_audit_event.await_args.kwargs["request_data"]
assert request_data["password_updated"] is True
assert "secret-password" not in str(request_data)
@pytest.mark.anyio
async def test_get_geoserver_config_returns_empty_state_when_unconfigured():
project_id = uuid4()
repo = SimpleNamespace(
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
get_geoserver_config_record=AsyncMock(return_value=None),
)
response = await admin_metadata.get_project_geoserver_config(
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_id == project_id
assert response.configured is False
assert response.has_password is False
assert response.gs_datastore_name == "ds_postgis"
@pytest.mark.anyio
async def test_metadata_admin_dependency_rejects_non_admin_user():
with pytest.raises(HTTPException) as exc:
await get_current_metadata_admin(_user(role="user", is_superuser=False))
assert exc.value.status_code == 403
assert exc.value.detail == "Admin access required"
@pytest.mark.anyio
async def test_add_project_member_rejects_duplicate(monkeypatch):
project_id = uuid4()
user_id = uuid4()
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
get_user_by_id=AsyncMock(return_value=_user(id=user_id)),
get_project_membership=AsyncMock(return_value=_membership()),
add_project_member=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.add_project_member(
ProjectMemberCreateRequest(user_id=user_id, project_role="viewer"),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert exc.value.status_code == 409
repo.add_project_member.assert_not_called()
@pytest.mark.anyio
async def test_add_project_member_rejects_self_membership_change(monkeypatch):
project_id = uuid4()
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(),
add_project_member=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.add_project_member(
ProjectMemberCreateRequest(
user_id=current_user.id,
project_role="viewer",
),
project_id=project_id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.get_project_by_id.assert_not_called()
repo.add_project_member.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_update_project_member_role_audits_change(monkeypatch):
project_id = uuid4()
user_id = uuid4()
user = _user(id=user_id, username="bob", email="bob@example.com")
membership = _membership(
user_id=user_id,
project_id=project_id,
project_role="admin",
)
repo = SimpleNamespace(
session=object(),
get_user_by_id=AsyncMock(return_value=user),
update_project_member_role=AsyncMock(return_value=membership),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
project_id=project_id,
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_role == "admin"
repo.update_project_member_role.assert_awaited_once_with(
project_id, user_id, "admin"
)
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_update_project_member_rejects_self_membership_change(monkeypatch):
project_id = uuid4()
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
get_user_by_id=AsyncMock(),
update_project_member_role=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
project_id=project_id,
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.get_user_by_id.assert_not_called()
repo.update_project_member_role.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_remove_project_member_audits_change(monkeypatch):
project_id = uuid4()
user_id = uuid4()
repo = SimpleNamespace(
session=object(),
remove_project_member=AsyncMock(return_value=True),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.remove_project_member(
project_id=project_id,
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response is None
repo.remove_project_member.assert_awaited_once_with(project_id, user_id)
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_remove_project_member_rejects_self_membership_change(monkeypatch):
project_id = uuid4()
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
remove_project_member=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.remove_project_member(
project_id=project_id,
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.remove_project_member.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
+83
View File
@@ -0,0 +1,83 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.auth import metadata_dependencies
@pytest.fixture
def anyio_backend():
return "asyncio"
def _user(**overrides):
data = {
"id": uuid4(),
"keycloak_id": uuid4(),
"username": "old-name",
"email": "old@example.com",
"role": "user",
"is_active": True,
"is_superuser": False,
"created_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
"updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
"last_login_at": None,
}
data.update(overrides)
return SimpleNamespace(**data)
@pytest.mark.anyio
async def test_current_metadata_user_refreshes_keycloak_claim_snapshot():
keycloak_id = uuid4()
user = _user(keycloak_id=keycloak_id)
refreshed = _user(
id=user.id,
keycloak_id=keycloak_id,
username="alice",
email="alice@example.com",
last_login_at=datetime(2026, 6, 12, tzinfo=timezone.utc),
)
repo = SimpleNamespace(
get_user_by_keycloak_id=AsyncMock(return_value=user),
refresh_user_keycloak_snapshot=AsyncMock(return_value=refreshed),
)
response = await metadata_dependencies.get_current_metadata_user(
{
"sub": str(keycloak_id),
"preferred_username": "alice",
"email": "alice@example.com",
},
metadata_repo=repo,
)
assert response.username == "alice"
repo.get_user_by_keycloak_id.assert_awaited_once_with(keycloak_id)
repo.refresh_user_keycloak_snapshot.assert_awaited_once_with(
user,
username="alice",
email="alice@example.com",
)
@pytest.mark.anyio
async def test_current_metadata_user_rejects_invalid_keycloak_sub():
repo = SimpleNamespace(
get_user_by_keycloak_id=AsyncMock(),
refresh_user_keycloak_snapshot=AsyncMock(),
)
with pytest.raises(HTTPException) as exc:
await metadata_dependencies.get_current_metadata_user(
{"sub": "not-a-uuid"},
metadata_repo=repo,
)
assert exc.value.status_code == 401
repo.get_user_by_keycloak_id.assert_not_called()
repo.refresh_user_keycloak_snapshot.assert_not_called()
+12
View File
@@ -0,0 +1,12 @@
from app.infra.db.dynamic_manager import ProjectConnectionManager
def test_normalize_pg_url_preserves_password():
manager = ProjectConnectionManager()
url = manager._normalize_pg_url(
"postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
)
assert url == "postgresql+psycopg://tjwater:secret@192.168.1.114:5433/tjwater"
assert "***" not in url
@@ -23,6 +23,11 @@ class _DummyEncryptor:
self._raise_invalid_token = raise_invalid_token self._raise_invalid_token = raise_invalid_token
self.encrypted_values = [] self.encrypted_values = []
def encrypt(self, value):
encrypted = f"encrypted::{value}"
self.encrypted_values.append(value)
return encrypted
def decrypt(self, _value): def decrypt(self, _value):
if self._raise_invalid_token: if self._raise_invalid_token:
raise InvalidToken() raise InvalidToken()
@@ -117,3 +122,46 @@ def test_encrypted_dsn_decrypts_without_migration(monkeypatch):
assert routing.dsn == "postgresql://u:p%40ss@host/db" assert routing.dsn == "postgresql://u:p%40ss@host/db"
session.commit.assert_not_awaited() session.commit.assert_not_awaited()
def test_upsert_project_database_config_encrypts_plaintext_dsn(monkeypatch):
project_id = uuid4()
session = SimpleNamespace(
execute=None,
add=None,
commit=None,
refresh=None,
)
added = []
session.execute = AsyncMock(return_value=_DummyResult(None))
session.add = lambda item: added.append(item)
session.commit = AsyncMock()
session.refresh = AsyncMock()
encryptor = _DummyEncryptor()
repo = MetadataRepository(session)
monkeypatch.setattr(
"app.infra.db.metadb.repositories.metadata_repository.is_database_encryption_configured",
lambda: True,
)
monkeypatch.setattr(
"app.infra.db.metadb.repositories.metadata_repository.get_database_encryptor",
lambda: encryptor,
)
record = asyncio.run(
repo.upsert_project_database_config(
project_id,
db_role="biz_data",
db_type="postgresql",
dsn="postgresql://user:secret@localhost/db",
pool_min_size=1,
pool_max_size=5,
)
)
assert encryptor.encrypted_values == ["postgresql://user:secret@localhost/db"]
assert record.dsn_encrypted == "encrypted::postgresql://user:secret@localhost/db"
assert added == [record]
session.commit.assert_awaited_once()
session.refresh.assert_awaited_once_with(record)