# #region Test.McpAutomationParity [C:4] [TYPE Module] [SEMANTICS test,mcp,automation,rbac,acl,parity] # @BRIEF 046 T019 / DG-2: REST<->MCP admission parity for the four automation reads plus the # disabled==enabled HumanStep rejection parity (DEF-02) on the MCP surface. # @RELATION VERIFIES -> [McpServer.ToolsAutomation.Register] # @RELATION VERIFIES -> [McpServer.RbacServer] # @RELATION VERIFIES -> [ScenarioAutomation.Eligibility.Assert] # @TEST_INVARIANT Reads admit any authenticated principal type with scenario:automation READ # (human) or the mcp:read scope (service); anonymous -> unauthenticated (401), # lacking grant -> permission_denied (403), foreign scenario -> not_found (404). # @TEST_INVARIANT A disabled schedule bound to a human-step revision is rejected with # AUTOMATION_INELIGIBLE_HUMAN_STEP and zero side-effect rows (DEF-02, MCP side). from __future__ import annotations from uuid import uuid4 import pytest from mcp.server.fastmcp import FastMCP from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker import src.mcp_server.rbac_server as rbac_module import src.mcp_server.tools_automation as automation_module from src.mcp_server import server as mcp_server from src.mcp_server.auth import _access_token_context from src.mcp_server.tools_automation import register_automation_tools from src.models.auth import Permission, Role, User from src.models.mapping import Base from src.models.scenario_automation import AutomationPolicy, ScenarioSchedule from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision from src.services.dashboard_testing.scenario.templates import ( ACTION_REGISTRY_VERSION, action_registry_fingerprint, resolve_action_descriptor, ) # #region Test.McpAutomationParity.Fixtures [C:3] [TYPE Block] [SEMANTICS test,mcp,sqlite,fixture] # @ingroup Test.McpAutomationParity # @BRIEF Isolated shared SQLite wired into the automation/rbac SessionLocal seams; hardcoded # owner/foreign fixture rows for ACL assertions. @pytest.fixture def isolated_db(tmp_path, monkeypatch): engine = create_engine(f"sqlite:///{tmp_path / 'mcp_automation.db'}", connect_args={"check_same_thread": False}) event.listen(engine, "connect", lambda connection, _: connection.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine) monkeypatch.setattr(automation_module, "SessionLocal", factory) monkeypatch.setattr(rbac_module, "SessionLocal", factory) try: yield factory finally: engine.dispose() def _seed_user(factory, username: str, permissions: list[tuple[str, str]], *, admin: bool = False) -> None: role = Role(name=f"role-{username}", is_admin=admin) role.permissions = [Permission(resource=resource, action=action) for resource, action in permissions] with factory() as db: db.add(User(username=username, password_hash="x", is_active=True, roles=[role])) db.commit() def _seed_acl_rows(factory) -> dict[str, str]: """Hardcoded fixture: scenario owned by 'acl.reader' plus one foreign scenario, one schedule and one referenced policy each.""" with factory() as db: own_policy = AutomationPolicy(name="mcp-acl-policy-own") foreign_policy = AutomationPolicy(name="mcp-acl-policy-foreign") db.add_all([own_policy, foreign_policy]) db.flush() own_scenario = str(uuid4()) foreign_scenario = str(uuid4()) db.add(ScenarioRegistryEntry( scenario_id=own_scenario, scenario_key=f"own-{uuid4().hex[:8]}", name="own", dashboard_id=81, environment_ids=["env-dev"], owner_id="acl.reader", owner_username="acl.reader", lifecycle_status="READY", validation_status="valid", )) db.add(ScenarioRegistryEntry( scenario_id=foreign_scenario, scenario_key=f"foreign-{uuid4().hex[:8]}", name="foreign", dashboard_id=82, environment_ids=["env-dev"], owner_id="foreign.owner", owner_username="foreign.owner", lifecycle_status="READY", validation_status="valid", )) own_schedule = ScenarioSchedule( scenario_id=own_scenario, environment_id="env-dev", cron_expr="0 7 * * *", policy_id=own_policy.id, ) foreign_schedule = ScenarioSchedule( scenario_id=foreign_scenario, environment_id="env-dev", cron_expr="0 8 * * *", policy_id=foreign_policy.id, ) db.add_all([own_schedule, foreign_schedule]) db.commit() return { "own_scenario": own_scenario, "foreign_scenario": foreign_scenario, "own_schedule": own_schedule.id, "foreign_schedule": foreign_schedule.id, "own_policy": own_policy.id, "foreign_policy": foreign_policy.id, } def _seed_human_scenario(factory, owner: str) -> tuple[str, str]: """Human-step current revision fixture for the DEF-02 disabled==enabled rejection.""" scenario_id = str(uuid4()) revision_id = str(uuid4()) graph = { "action_registry_version": ACTION_REGISTRY_VERSION, "action_registry_hash": action_registry_fingerprint(), "steps": [{ "logical_step_id": "human", "tool": "human", "action": "human_checkpoint", "action_descriptor": resolve_action_descriptor( tool="human", action="human_checkpoint", registry_version=ACTION_REGISTRY_VERSION, registry_hash=action_registry_fingerprint(), ).snapshot(), }], "dependencies": [], "environment_ids": ["env-dev"], } with factory() as db: db.add(ScenarioRegistryEntry( scenario_id=scenario_id, scenario_key=f"human-{uuid4().hex[:8]}", name="human fixture", dashboard_id=83, environment_ids=["env-dev"], owner_id=owner, owner_username=owner, lifecycle_status="READY", validation_status="valid", current_revision_id=revision_id, )) db.add(ScenarioRevision( revision_id=revision_id, scenario_id=scenario_id, content_hash="h" * 64, graph_snapshot=graph, execution_template_hash="", template_version="v1", schema_version=1, compatibility_family="default", change_summary={}, created_by=owner, activation_status="current", )) db.commit() return scenario_id, revision_id def _token(subject: str, *, scopes: list[str], principal_type: str = "user"): return _access_token_context.set(mcp_server.AccessToken( token=f"token-{uuid4().hex[:8]}", client_id=subject, scopes=scopes, subject=subject, claims={"principal_type": principal_type}, )) def _unwrap(value): if isinstance(value, tuple): return _unwrap(value[1]) if isinstance(value, dict) and set(value) == {"result"}: return _unwrap(value["result"]) return value def _plain_server(): """FastMCP without the RbacFastMCP catalog layer — proves tool-body semantic codes.""" server = FastMCP("automation-body-probe") register_automation_tools(server) return server def _rbac_server(): server = mcp_server._build_probe_server() server._record = lambda **_: None return server # #endregion Test.McpAutomationParity.Fixtures # #region Test.McpAutomationParity.BodyCodes [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,semantic-codes] # @ingroup Test.McpAutomationParity # @BRIEF Tool-body admission codes match the REST semantics: 401 unauthenticated, # 403 permission_denied, 404 not_found (foreign == missing). @pytest.mark.asyncio async def test_read_body_semantic_codes(isolated_db) -> None: ids = _seed_acl_rows(isolated_db) _seed_user(isolated_db, "acl.reader", [("scenario:automation", "READ")]) server = _plain_server() # Anonymous (401 semantic): no token context at all. with pytest.raises(Exception, match="unauthenticated"): await server.call_tool("list_scenario_schedules", {}) # Service principal without the mcp:read scope (403 semantic). token = _token("svc-noscope", scopes=["mcp"], principal_type="service") try: with pytest.raises(Exception, match="permission_denied"): await server.call_tool("list_scenario_schedules", {}) finally: _access_token_context.reset(token) # Foreign scenario addressed by a READ holder resolves exactly like a missing one (404). token = _token("acl.reader", scopes=["mcp"]) try: foreign_policy = _unwrap(await server.call_tool( "get_scenario_automation_policy", {"scenario_id": ids["foreign_scenario"]} )) missing_policy = _unwrap(await server.call_tool( "get_scenario_automation_policy", {"scenario_id": f"missing-{uuid4()}"} )) assert foreign_policy == {"status": "not_found", "scenario_id": ids["foreign_scenario"]} # Foreign and missing projections are indistinguishable apart from the echoed id. assert {**foreign_policy, "scenario_id": None} == {**missing_policy, "scenario_id": None} foreign_metrics = _unwrap(await server.call_tool( "get_scenario_automation_metrics", {"scenario_id": ids["foreign_scenario"]} )) assert foreign_metrics == {"status": "not_found", "scenario_id": ids["foreign_scenario"]} finally: _access_token_context.reset(token) # #endregion Test.McpAutomationParity.BodyCodes # #region Test.McpAutomationParity.Acl [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,acl] # @ingroup Test.McpAutomationParity # @BRIEF Per-object ACL on reads: owners see own rows only; service principals with scope are # admitted and see only rows they own; admins see all rows. @pytest.mark.asyncio async def test_read_acl_filters_foreign_rows(isolated_db) -> None: ids = _seed_acl_rows(isolated_db) _seed_user(isolated_db, "acl.reader", [("scenario:automation", "READ")]) _seed_user(isolated_db, "acl.admin", [], admin=True) server = _plain_server() token = _token("acl.reader", scopes=["mcp"]) try: listed = _unwrap(await server.call_tool("list_scenario_schedules", {})) schedule_ids = {item["id"] for item in listed} assert ids["own_schedule"] in schedule_ids assert ids["foreign_schedule"] not in schedule_ids # Addressing the foreign scenario directly yields an empty projection (no leak). foreign_only = _unwrap(await server.call_tool( "list_scenario_schedules", {"scenario_id": ids["foreign_scenario"]} )) assert foreign_only == [] own_metrics = _unwrap(await server.call_tool( "get_scenario_automation_metrics", {"scenario_id": ids["own_scenario"]} )) assert own_metrics["schedules_total"] == 1 own_policy = _unwrap(await server.call_tool( "get_scenario_automation_policy", {"scenario_id": ids["own_scenario"]} )) assert own_policy["id"] == ids["own_policy"] finally: _access_token_context.reset(token) token = _token("svc-automation", scopes=["mcp", "mcp:read"], principal_type="service") try: listed = _unwrap(await server.call_tool("list_scenario_schedules", {})) assert listed == [] finally: _access_token_context.reset(token) token = _token("acl.admin", scopes=["mcp"]) try: listed = _unwrap(await server.call_tool("list_scenario_schedules", {})) schedule_ids = {item["id"] for item in listed} assert ids["own_schedule"] in schedule_ids assert ids["foreign_schedule"] in schedule_ids finally: _access_token_context.reset(token) # #endregion Test.McpAutomationParity.Acl # #region Test.McpAutomationParity.RbacCatalog [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,catalog,rbac] # @ingroup Test.McpAutomationParity # @BRIEF RbacFastMCP catalog parity: scoped service principals pass reads and are denied # human-only mutations; humans without READ are denied reads. @pytest.mark.asyncio async def test_catalog_admission_parity(isolated_db) -> None: _seed_user(isolated_db, "acl.reader", [("scenario:automation", "READ")]) _seed_user(isolated_db, "acl.manager", [("scenario:automation", "MANAGE")]) server = _rbac_server() token = _token("svc-automation", scopes=["mcp", "mcp:read"], principal_type="service") try: listed = _unwrap(await server.call_tool("list_scenario_schedules", {})) assert listed == [] with pytest.raises(Exception, match="permission_denied"): await server.call_tool("upsert_scenario_schedule", {"request": { "scenario_id": "sc", "idempotency_key": f"k-{uuid4()}", "environment_id": "env-dev", "revision_id": str(uuid4()), "cron_expr": "0 7 * * *", }}) finally: _access_token_context.reset(token) token = _token("acl.manager", scopes=["mcp"]) try: with pytest.raises(Exception, match="permission_denied"): await server.call_tool("list_scenario_schedules", {}) finally: _access_token_context.reset(token) # Human-only mutation surface unchanged: a service principal is refused by owner() even # when invoked below the catalog layer. plain = _plain_server() token = _token("svc-automation", scopes=["mcp", "mcp:read"], principal_type="service") try: with pytest.raises(Exception, match="human_principal_required"): await plain.call_tool("upsert_scenario_schedule", {"request": { "scenario_id": "sc", "idempotency_key": f"k-{uuid4()}", "environment_id": "env-dev", "revision_id": str(uuid4()), "cron_expr": "0 7 * * *", }}) finally: _access_token_context.reset(token) # #endregion Test.McpAutomationParity.RbacCatalog # #region Test.McpAutomationParity.DisabledReject [C:4] [TYPE Function] [SEMANTICS test,mcp,automation,eligibility,def02] # @ingroup Test.McpAutomationParity # @BRIEF DEF-02 MCP side: a disabled schedule bound to a human-step revision is rejected with # the identical code as enabled and leaves zero side-effect rows. # @TEST_INVARIANT ScenarioAutomation.Eligibility.Assert: enabled=False does not bypass the # revision-bound guard -> VERIFIED_BY: test_disabled_human_schedule_rejected @pytest.mark.asyncio async def test_disabled_human_schedule_rejected(isolated_db) -> None: _seed_user(isolated_db, "acl.manager", [ ("scenario:automation", "READ"), ("scenario:automation", "MANAGE"), ]) scenario_id, revision_id = _seed_human_scenario(isolated_db, owner="acl.manager") server = _rbac_server() token = _token("acl.manager", scopes=["mcp"]) try: with isolated_db() as db: before = db.query(ScenarioSchedule).count() for enabled in (True, False): with pytest.raises(Exception, match="AUTOMATION_INELIGIBLE_HUMAN_STEP"): await server.call_tool("upsert_scenario_schedule", {"request": { "scenario_id": scenario_id, "idempotency_key": f"disabled-{enabled}-{uuid4()}", "environment_id": "env-dev", "revision_id": revision_id, "revision_policy": "pinned", "cron_expr": "0 7 * * *", "enabled": enabled, }}) with isolated_db() as db: assert db.query(ScenarioSchedule).count() == before finally: _access_token_context.reset(token) # #endregion Test.McpAutomationParity.DisabledReject # #endregion Test.McpAutomationParity