feat(server): add project RBAC and guarded workflows

This commit is contained in:
2026-07-30 16:45:09 +08:00
parent 3fbb17bb30
commit ae1a657554
29 changed files with 1431 additions and 412 deletions
+77
View File
@@ -0,0 +1,77 @@
from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.v1.endpoints import access as access_endpoint
from app.auth.metadata_dependencies import (
get_current_metadata_user,
get_metadata_repository,
)
from tests.conftest import build_test_app
def _user(**overrides):
data = {
"id": uuid4(),
"username": "alice",
"role": "user",
"is_superuser": False,
}
data.update(overrides)
return SimpleNamespace(**data)
def _build_client(user, repo) -> TestClient:
app = build_test_app(access_endpoint.router, "/api/v1")
app.dependency_overrides[get_current_metadata_user] = lambda: user
app.dependency_overrides[get_metadata_repository] = lambda: repo
return TestClient(app)
def test_access_context_returns_global_admin_permissions_without_project():
user = _user(role="admin")
repo = SimpleNamespace()
client = _build_client(user, repo)
response = client.get("/api/v1/access/context")
assert response.status_code == 200
payload = response.json()
assert payload["is_system_admin"] is True
assert payload["project_id"] is None
assert "environment.manage" in payload["permissions"]
assert "webgis.view" not in payload["permissions"]
def test_access_context_returns_project_member_permissions():
project_id = uuid4()
user = _user()
async def get_project_by_id(value):
assert value == project_id
return SimpleNamespace(id=project_id, code="demo", status="active")
async def get_membership_role(value, user_id):
assert value == project_id
assert user_id == user.id
return "member"
repo = SimpleNamespace(
get_project_by_id=get_project_by_id,
get_membership_role=get_membership_role,
)
client = _build_client(user, repo)
response = client.get(
"/api/v1/access/context",
headers={"X-Project-Id": str(project_id)},
)
assert response.status_code == 200
payload = response.json()
assert payload["project_id"] == str(project_id)
assert payload["project_role"] == "member"
assert "scada.clean" in payload["permissions"]
assert "optimization.run" in payload["permissions"]
assert "model.import" not in payload["permissions"]
+17 -12
View File
@@ -138,7 +138,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
keycloak_id=users[1].keycloak_id,
username="bob",
email="bob@example.com",
role="viewer",
role="user",
is_active=True,
),
]
@@ -156,7 +156,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
@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)
updated = _user(id=user_id, role="user", is_active=False)
repo = SimpleNamespace(
session=object(),
update_user_admin=AsyncMock(return_value=updated),
@@ -165,7 +165,7 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
response = await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(
role="operator",
role="user",
is_active=False,
),
user_id=user_id,
@@ -175,9 +175,9 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
repo.update_user_admin.assert_awaited_once_with(
user_id,
updates={"role": "operator", "is_active": False},
updates={"role": "user", "is_active": False},
)
assert response.role == "operator"
assert response.role == "user"
admin_metadata.log_audit_event.assert_awaited_once()
@@ -192,7 +192,7 @@ async def test_update_metadata_user_rejects_self_update(monkeypatch):
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(role="viewer"),
MetadataUserUpdateRequest(role="user"),
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
@@ -221,6 +221,7 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch):
create_project=AsyncMock(return_value=project),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
current_user = _user(role="admin", is_superuser=True)
response = await admin_metadata.create_admin_project(
AdminProjectCreateRequest(
@@ -231,12 +232,16 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch):
map_extent={"bbox": [1, 2, 3, 4]},
status="active",
),
current_user=_user(role="admin", is_superuser=True),
current_user=current_user,
metadata_repo=repo,
)
assert response.project_id == project.id
repo.create_project.assert_awaited_once()
assert (
repo.create_project.await_args.kwargs["creator_user_id"]
== current_user.id
)
admin_metadata.log_audit_event.assert_awaited_once()
@@ -482,7 +487,7 @@ async def test_update_project_member_role_audits_change(monkeypatch):
membership = _membership(
user_id=user_id,
project_id=project_id,
project_role="admin",
project_role="member",
)
repo = SimpleNamespace(
session=object(),
@@ -492,16 +497,16 @@ async def test_update_project_member_role_audits_change(monkeypatch):
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
ProjectMemberUpdateRequest(project_role="member"),
project_id=project_id,
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_role == "admin"
assert response.project_role == "member"
repo.update_project_member_role.assert_awaited_once_with(
project_id, user_id, "admin"
project_id, user_id, "member"
)
admin_metadata.log_audit_event.assert_awaited_once()
@@ -519,7 +524,7 @@ async def test_update_project_member_rejects_self_membership_change(monkeypatch)
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
ProjectMemberUpdateRequest(project_role="member"),
project_id=project_id,
user_id=current_user.id,
current_user=current_user,
+16 -2
View File
@@ -30,7 +30,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
project_id=project_id,
project_code="fengyang",
user_id=user_id,
project_role="editor",
project_role="member",
),
current_user=SimpleNamespace(
id=user_id,
@@ -52,7 +52,21 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
"is_superuser": False,
"project_id": str(project_id),
"network": "fengyang",
"project_role": "editor",
"project_role": "member",
"permissions": [
"burst.run",
"burst.view",
"optimization.run",
"optimization.view",
"risk.run",
"risk.view",
"scada.clean",
"scada.view",
"simulation.run",
"simulation.view",
"webgis.edit",
"webgis.view",
],
"token_expires_at": "2026-06-11T13:10:00+00:00",
}
+7 -1
View File
@@ -1,4 +1,5 @@
from unittest.mock import AsyncMock
from uuid import uuid4
from fastapi.testclient import TestClient
@@ -10,7 +11,12 @@ from app.auth.metadata_dependencies import (
from tests.conftest import build_test_app, make_audit_log
def _build_client(repo, *, metadata_admin=None, metadata_user=None) -> TestClient:
def _build_client(
repo,
*,
metadata_admin=None,
metadata_user=None,
) -> TestClient:
app = build_test_app(audit_endpoint.router, "/audit")
app.dependency_overrides[audit_endpoint.get_audit_repository] = lambda: repo
if metadata_admin is not None:
+1 -1
View File
@@ -54,7 +54,7 @@ def test_meta_project_returns_map_extent(monkeypatch):
app = build_test_app(module.router, "/api/v1")
app.dependency_overrides[module.get_project_context] = lambda: SimpleNamespace(
project_id=project_id,
project_role="editor",
project_role="member",
)
app.dependency_overrides[module.get_metadata_repository] = lambda: repo
client = TestClient(app)
+100
View File
@@ -0,0 +1,100 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.v1.endpoints import model_import
from app.auth.metadata_dependencies import (
get_current_metadata_admin,
get_metadata_repository,
)
from tests.conftest import build_test_app
VALID_INP = b"[TITLE]\nDesktop model\n[JUNCTIONS]\n;ID Elev Demand\n"
def _client(*, admin=None, repo=None) -> TestClient:
app = build_test_app(model_import.router, "/api/v1")
if admin is not None:
app.dependency_overrides[get_current_metadata_admin] = lambda: admin
if repo is not None:
app.dependency_overrides[get_metadata_repository] = lambda: repo
return TestClient(app)
def test_system_admin_can_import_model_without_project_membership(
monkeypatch,
):
project_id = uuid4()
project = SimpleNamespace(id=project_id, code="demo", status="active")
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=project),
)
admin = SimpleNamespace(id=uuid4(), role="admin", is_superuser=False)
monkeypatch.setattr(
model_import,
"_run_uploaded_inp",
AsyncMock(return_value="imported"),
)
monkeypatch.setattr(model_import, "log_audit_event", AsyncMock())
client = _client(admin=admin, repo=repo)
response = client.post(
f"/api/v1/admin/projects/{project_id}/model/import",
files={"file": ("desktop-model.inp", VALID_INP)},
)
assert response.status_code == 200
assert response.json()["project_id"] == str(project_id)
assert response.json()["result"] == "imported"
repo.get_project_by_id.assert_awaited_once_with(project_id)
model_import.log_audit_event.assert_awaited_once()
def test_non_admin_is_denied_model_import():
def deny_admin():
raise HTTPException(status_code=403, detail="Admin access required")
app = build_test_app(model_import.router, "/api/v1")
app.dependency_overrides[get_current_metadata_admin] = deny_admin
client = TestClient(app)
response = client.post(
f"/api/v1/admin/projects/{uuid4()}/model/import",
files={"file": ("desktop-model.inp", VALID_INP)},
)
assert response.status_code == 403
assert response.json()["detail"] == "Admin access required"
def test_model_import_rejects_non_inp_file(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(
return_value=SimpleNamespace(
id=project_id,
code="demo",
status="active",
)
),
)
monkeypatch.setattr(model_import, "log_audit_event", AsyncMock())
client = _client(
admin=SimpleNamespace(id=uuid4(), role="admin", is_superuser=False),
repo=repo,
)
response = client.post(
f"/api/v1/admin/projects/{project_id}/model/import",
files={"file": ("desktop-model.txt", VALID_INP)},
)
assert response.status_code == 400
assert response.json()["detail"] == "Only .inp model files are accepted"
model_import.log_audit_event.assert_not_awaited()
+19 -14
View File
@@ -2,6 +2,7 @@ from datetime import datetime, timezone
from io import BytesIO
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from tests.conftest import build_test_app, install_stub, load_module_from_path
@@ -254,21 +255,25 @@ def test_optimize_rejects_viewer_project_role(monkeypatch):
assert response.status_code == 403
def test_project_owner_and_admin_can_optimize(monkeypatch):
@pytest.mark.parametrize(
"project_role",
["owner", "admin", "modeler", "dispatcher", "auditor"],
)
def test_legacy_project_roles_cannot_optimize(monkeypatch, project_role):
module = _load_module(monkeypatch)
for project_role in ("owner", "admin"):
response = _client(module, project_role=project_role).post(
"/api/v1/sensor-placement-schemes/optimize",
json={
"network": "tjwater",
"scheme_name": f"{project_role}方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
)
assert response.status_code == 200
response = _client(module, project_role=project_role).post(
"/api/v1/sensor-placement-schemes/optimize",
json={
"network": "tjwater",
"scheme_name": f"{project_role}方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
)
assert response.status_code == 403
def test_optimize_maps_running_project_job_to_409(monkeypatch):
-21
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from datetime import datetime, timezone
from fastapi.testclient import TestClient
@@ -199,26 +198,6 @@ def test_project_management_maps_named_arguments(monkeypatch):
}
def test_network_update_surfaces_service_error(monkeypatch, tmp_path):
module = _load_simulation_module(monkeypatch)
monkeypatch.chdir(tmp_path)
def boom(_path):
raise RuntimeError("write failed")
monkeypatch.setattr(module, "network_update", boom)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/network_update/",
files={"file": ("update.txt", b"payload")},
)
assert response.status_code == 500
assert "数据库操作失败: write failed" in response.json()["detail"]
assert list(Path(tmp_path).glob("network_update_*"))
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
module = _load_simulation_module(monkeypatch)
captured_calls = []