Files

537 lines
21 KiB
Python

"""WP-230A regression tests: real per-endpoint management RBAC.
Exercises the actual FastAPI routes over real HTTP (via TestClient), through
a real PKCE/BFF login against a fake-but-real OIDC provider
(services/control_api/auth/fake_provider.py), against a real SQLite-backed
app built with services/runtime/control_api.py's build_app() -- not a
hand-rolled stand-in for the routing. This is deliberately heavier than a
pure services/control_api/authz.py unit test (see test_wp230_authz.py for
that): it is the only way to prove the *actual wiring* -- that each route
really does call the permission matrix with the action its own design
intends -- rather than just proving the matrix itself is correct in
isolation.
"""
from __future__ import annotations
import os
from collections.abc import Iterator
from dataclasses import dataclass
from http.cookies import SimpleCookie
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from services.control_api.auth.config import OIDCSettings
from services.control_api.auth.fake_provider import FakeOIDCProvider
from services.control_api.auth.models import ManagementRole
from services.control_api.contextforge.fake import FakeContextForge
from services.control_api.contextforge.models import ContextForgeSettings
from services.control_api.persistence import Base, create_engine_from_url, session_factory
from services.runtime.control_api import build_app
from services.runtime.state_sync import sync_runtime_state
ROOT = Path(__file__).resolve().parents[1]
ISSUER = "https://fake.oidc.test"
ROLE_GROUPS: dict[ManagementRole, str] = {
ManagementRole.VIEWER: "hub-viewer",
ManagementRole.AUDITOR: "hub-auditor",
ManagementRole.OPERATOR: "hub-operator",
ManagementRole.ADMIN: "hub-admin",
}
ALL_ROLES = tuple(ROLE_GROUPS)
@dataclass
class RoleSession:
role: ManagementRole
client: TestClient
csrf: str
def get(self, path: str, **kwargs: object) -> object:
return self.client.get(path, **kwargs) # type: ignore[arg-type]
def post(self, path: str, **kwargs: object) -> object:
headers = dict(kwargs.pop("headers", {}) or {}) # type: ignore[arg-type]
headers["X-CSRF-Token"] = self.csrf
return self.client.post(path, headers=headers, **kwargs) # type: ignore[arg-type]
def _login(client: TestClient, provider: FakeOIDCProvider) -> str:
login_response = client.get("/auth/login", follow_redirects=False)
assert login_response.status_code == 307
location = login_response.headers["location"]
query = parse_qs(urlparse(location).query)
code = provider.authorize(location)
callback_response = client.get(
"/auth/callback",
params={"code": code, "state": query["state"][0]},
follow_redirects=False,
)
assert callback_response.status_code == 303
# The session cookie is Secure (required by OIDCSettings), and
# TestClient's default base_url is plain http:// -- its cookie jar
# correctly refuses to store/replay a Secure cookie over that scheme, so
# the cookie is set directly rather than relying on the jar (same
# pattern tests/test_auth.py already uses for the same reason).
cookie = SimpleCookie()
cookie.load(callback_response.headers["set-cookie"])
cookie_name = next(iter(cookie))
client.cookies.set(cookie_name, cookie[cookie_name].value)
session_response = client.get("/auth/session")
assert session_response.status_code == 200, session_response.text
return str(session_response.json()["csrf_token"])
def _build_role_app(db_path: Path, role: ManagementRole) -> tuple[object, FakeOIDCProvider]:
# These module-scoped sessions deliberately cover the complete endpoint
# matrix. Give this fixture the production session ceiling so a slow CI
# runner cannot turn suite duration into unrelated RBAC failures.
provider = FakeOIDCProvider(
issuer=ISSUER,
user_groups=(ROLE_GROUPS[role],),
token_ttl_seconds=28_800,
)
oidc = OIDCSettings(
issuer=ISSUER,
audience=provider.audience,
client_id="hub-bff",
redirect_uri="https://hub.test/auth/callback",
secure_cookie=True,
role_group_map={group: r for r, group in ROLE_GROUPS.items()},
)
contextforge_fake = FakeContextForge()
os.environ["WP230_FAKE_CONTEXTFORGE_TOKEN"] = "fake-token" # noqa: S105
engine = create_engine_from_url(f"sqlite:///{db_path}")
app = build_app(
oidc=oidc,
provider_transport=provider.transport(),
contextforge_settings=ContextForgeSettings(
base_url="https://contextforge.test",
auth_token_env="WP230_FAKE_CONTEXTFORGE_TOKEN", # noqa: S106 -- env var name, not a secret
),
contextforge_transport=contextforge_fake.transport(),
engine=engine,
catalog_root=ROOT / "catalog",
)
return app, provider
@pytest.fixture(scope="module", autouse=True)
def _client_bindings_env() -> Iterator[None]:
# sync_runtime_state/reconcile_runtime_state both read this at call time
# (not just once at seeding) -- catalog.apply re-invokes it per request,
# so it must stay set for the whole module, not just around seeding.
previous = os.environ.get("MCP_CLIENT_BINDINGS_JSON")
os.environ["MCP_CLIENT_BINDINGS_JSON"] = "[]"
yield
if previous is None:
os.environ.pop("MCP_CLIENT_BINDINGS_JSON", None)
else:
os.environ["MCP_CLIENT_BINDINGS_JSON"] = previous
@pytest.fixture(scope="module")
def db_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
path = tmp_path_factory.mktemp("wp230-rbac") / "rbac.db"
engine = create_engine_from_url(f"sqlite:///{path}")
Base.metadata.create_all(engine)
sessions = session_factory(engine)
with sessions() as session, session.begin():
sync_runtime_state(session, ROOT / "catalog", ISSUER)
engine.dispose()
return path
@pytest.fixture(scope="module")
def role_sessions(db_path: Path) -> Iterator[dict[ManagementRole, RoleSession]]:
sessions: dict[ManagementRole, RoleSession] = {}
clients: list[TestClient] = []
for role in ALL_ROLES:
app, provider = _build_role_app(db_path, role)
client = TestClient(app)
clients.append(client)
csrf = _login(client, provider)
sessions[role] = RoleSession(role=role, client=client, csrf=csrf)
yield sessions
for client in clients:
client.close()
# --- Reads: available to every role -----------------------------------
@pytest.mark.parametrize("role", ALL_ROLES)
def test_dashboard_read_available_to_every_role(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].get("/api/dashboard")
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("kind", ["projects", "servers", "tools", "connectors", "policies"])
@pytest.mark.parametrize("role", ALL_ROLES)
def test_resource_read_available_to_every_role(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, kind: str
) -> None:
response = role_sessions[role].get(f"/api/{kind}")
assert response.status_code == 200, (role, kind, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("role", ALL_ROLES)
def test_client_list_available_to_every_role(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].get("/api/client-management")
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("role", ALL_ROLES)
def test_policy_simulate_available_to_every_role(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post(
"/api/policies/simulate",
json={"project": "nonexistent", "environment": "prod", "tool": "nonexistent"},
)
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("role", ALL_ROLES)
def test_catalog_preview_available_to_every_role(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post("/api/catalog/preview", json={"project": "ludarium"})
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
preview = response.json() # type: ignore[union-attr]
assert preview["valid"] is True
assert preview["changes"] == ["reconcile all registered project routes, including ludarium"]
# --- Tool readiness: bounded live probe, operator+ only ------------------
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
def test_tool_readiness_denied_below_operator(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post(
"/api/tools/test",
json={
"project": "mobilityops",
"environment": "prod",
"tool": "mobilityops.operations.summary",
"arguments": {},
},
)
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("role", [ManagementRole.OPERATOR, ManagementRole.ADMIN])
def test_tool_readiness_is_wired_for_operator_and_admin(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post(
"/api/tools/test",
json={
"project": "mobilityops",
"environment": "prod",
"tool": "mobilityops.operations.summary",
"arguments": {},
},
)
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
body = response.json() # type: ignore[union-attr]
assert body["note"] == "Readiness only; no MCP tool invocation was performed."
assert body["checks"]["input"]["valid"] is True
def test_tool_readiness_proves_full_registered_path_after_reconciliation(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
admin = role_sessions[ManagementRole.ADMIN]
applied = admin.post("/api/catalog/apply", json={"project": "mobilityops"})
assert applied.status_code == 200, applied.text # type: ignore[union-attr]
response = admin.post(
"/api/tools/test",
json={
"project": "mobilityops",
"environment": "prod",
"tool": "mobilityops.operations.summary",
"arguments": {},
},
)
assert response.status_code == 200, response.text # type: ignore[union-attr]
body = response.json() # type: ignore[union-attr]
assert body["ready"] is True
assert body["checks"]["binding"]["valid"] is True
assert body["checks"]["policy"]["allowed"] is True
assert body["checks"]["connector"]["reachable"] is True
drift = admin.get("/api/dashboard").json()["drift"] # type: ignore[union-attr]
assert drift == {
"count": 0,
"projects": [],
"known": True,
"detail": "No connector gateway drift measured.",
}
def test_tool_readiness_rejects_an_unverified_catalog_binding(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.OPERATOR].post(
"/api/tools/test",
json={
"project": "devrunbook",
"environment": "prod",
"tool": "knowledge.index.status",
"arguments": {},
},
)
assert response.status_code == 200, response.text # type: ignore[union-attr]
body = response.json() # type: ignore[union-attr]
assert body["ready"] is False
assert body["checks"]["binding"]["valid"] is False
def test_tool_readiness_validates_input_without_echoing_values(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.OPERATOR].post(
"/api/tools/test",
json={
"project": "mobilityops",
"environment": "prod",
"tool": "mobilityops.vehicle.get",
"arguments": {"unexpected": "must-not-be-echoed"},
},
)
assert response.status_code == 200, response.text # type: ignore[union-attr]
serialized = response.text # type: ignore[union-attr]
body = response.json() # type: ignore[union-attr]
assert body["ready"] is False
assert body["checks"]["input"]["valid"] is False
assert "must-not-be-echoed" not in serialized
assert len(body["checks"]["input"]["errors"]) == 2
def test_tool_readiness_reports_cataloged_but_undeployed_connector_type(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.OPERATOR].post(
"/api/tools/test",
json={
"project": "ludarium",
"environment": "prod",
"tool": "deployment.project.status",
"arguments": {},
},
)
assert response.status_code == 200, response.text # type: ignore[union-attr]
body = response.json() # type: ignore[union-attr]
assert body["ready"] is False
assert body["checks"]["connector"] == {
"reachable": False,
"status": "not-deployed",
"detail": "No production connector gateway is registered for this connector type.",
}
def test_tool_readiness_enforces_argument_byte_limit(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.OPERATOR].post(
"/api/tools/test",
json={
"project": "mobilityops",
"environment": "prod",
"tool": "mobilityops.knowledge.search",
"arguments": {"query": "🚗" * 9000},
},
)
assert response.status_code == 422, response.text # type: ignore[union-attr]
def test_tool_readiness_request_contract_fails_closed_on_extra_fields(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.ADMIN].post(
"/api/tools/test",
json={
"project": "mobilityops",
"environment": "prod",
"tool": "mobilityops.operations.summary",
"arguments": {},
"invoke": True,
},
)
assert response.status_code == 422, response.text # type: ignore[union-attr]
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
def test_tool_invocation_denied_below_operator_before_secret_or_network_access(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post(
"/api/tools/invoke",
json={
"project": "mobilityops",
"environment": "prod",
"tool": "mobilityops.operations.summary",
"arguments": {},
},
)
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
def test_live_operations_check_denied_below_operator(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post("/api/operations/check")
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("role", [ManagementRole.OPERATOR, ManagementRole.ADMIN])
def test_live_operations_check_available_to_operator_and_admin(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post("/api/operations/check")
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
assert response.json()["connectors"] # type: ignore[union-attr]
assert {"id": "knowledge-default", "name": "Knowledge Default"} in response.json()[
"connectors"
]["itworx-knowledge"]["connectorRefs"] # type: ignore[union-attr]
# --- Audit read: auditor+ only, viewer denied ---------------------------
def test_audit_read_denied_for_viewer(role_sessions: dict[ManagementRole, RoleSession]) -> None:
response = role_sessions[ManagementRole.VIEWER].get("/api/audit")
assert response.status_code == 403, response.text # type: ignore[union-attr]
@pytest.mark.parametrize(
"role", [ManagementRole.AUDITOR, ManagementRole.OPERATOR, ManagementRole.ADMIN]
)
def test_audit_read_allowed_for_auditor_and_above(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].get("/api/audit")
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
# --- Client lifecycle mutations: exact role boundaries ------------------
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
def test_client_create_denied_below_admin(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post(
"/api/client-management",
json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"},
)
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
def test_client_create_denied_for_operator(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.OPERATOR].post(
"/api/client-management",
json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"},
)
assert response.status_code == 403, response.text # type: ignore[union-attr]
def test_client_create_allowed_for_admin(role_sessions: dict[ManagementRole, RoleSession]) -> None:
response = role_sessions[ManagementRole.ADMIN].post(
"/api/client-management",
json={
"name": "wp230-admin-created",
"project": "ludarium",
"environment": "prod",
"kind": "native-2026",
},
)
assert response.status_code == 201, response.text # type: ignore[union-attr]
@pytest.mark.parametrize("action", ["bootstrap", "verify", "disable", "revoke"])
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
def test_client_lifecycle_actions_denied_below_operator(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, action: str
) -> None:
# A nonexistent client ID is fine here -- the RBAC check happens before
# the client lookup, so a 403 (not a 404) proves the gate fired first.
response = role_sessions[role].post(
f"/api/client-management/00000000-0000-0000-0000-000000000000/{action}"
)
assert response.status_code == 403, (role, action, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("action", ["bootstrap", "verify", "disable"])
def test_client_lifecycle_actions_allowed_for_operator(
role_sessions: dict[ManagementRole, RoleSession], action: str
) -> None:
# Create the client via the admin session (create is admin-only), then
# prove operator can perform the bounded lifecycle action on it.
created = role_sessions[ManagementRole.ADMIN].post(
"/api/client-management",
json={
"name": f"wp230-operator-{action}",
"project": "ludarium",
"environment": "prod",
"kind": "native-2026",
},
)
assert created.status_code == 201, created.text # type: ignore[union-attr]
client_id = created.json()["client"]["id"] # type: ignore[union-attr]
response = role_sessions[ManagementRole.OPERATOR].post(
f"/api/client-management/{client_id}/{action}"
)
assert response.status_code == 200, (action, response.text) # type: ignore[union-attr]
# --- Catalog apply: admin-only -------------------------------------------
@pytest.mark.parametrize(
"role", [ManagementRole.VIEWER, ManagementRole.AUDITOR, ManagementRole.OPERATOR]
)
def test_catalog_apply_denied_below_admin(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None:
response = role_sessions[role].post("/api/catalog/apply", json={"project": "ludarium"})
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
def test_catalog_apply_not_blocked_by_rbac_for_admin(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
# Whether reconciliation itself succeeds depends on the fake
# ContextForge/catalog fixture state, which is out of scope for an RBAC
# test -- what matters here is that admin is never turned away at 403.
response = role_sessions[ManagementRole.ADMIN].post(
"/api/catalog/apply", json={"project": "ludarium"}
)
assert response.status_code != 403, response.text # type: ignore[union-attr]
# --- Unauthenticated access ------------------------------------------------
def test_unauthenticated_request_is_401_not_403(db_path: Path) -> None:
app, _ = _build_role_app(db_path, ManagementRole.VIEWER)
anonymous = TestClient(app)
response = anonymous.get("/api/dashboard")
assert response.status_code == 401, response.text
anonymous.close()