178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""WP-230A regression tests: the management-API permission matrix itself.
|
|
|
|
Pure unit tests against services/control_api/authz.py, with no FastAPI app
|
|
involved -- see tests/test_wp230_rbac.py for the real-HTTP, real-routing
|
|
counterpart that proves each route actually invokes the action these tests
|
|
assume.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from services.control_api.auth.models import AuthenticatedPrincipal, ManagementRole, PrincipalKind
|
|
from services.control_api.authz import (
|
|
ENDPOINT_PERMISSIONS,
|
|
InsufficientRole,
|
|
check_role,
|
|
)
|
|
|
|
_NOW = datetime.now(UTC)
|
|
_AUTHZ_LOGGER = logging.getLogger("itworx_mcp_hub.management_authz")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ensure_authz_logger_enabled() -> Iterator[None]:
|
|
"""tests/test_migrations.py imports alembic's env.py, which calls
|
|
`logging.config.fileConfig()`; that function's default
|
|
`disable_existing_loggers=True` silently disables every logger not
|
|
explicitly listed in alembic.ini, including this one (it already exists
|
|
by the time fileConfig runs, if test_migrations.py happens to execute
|
|
first in the suite). Reset defensively rather than depending on
|
|
suite-wide test file ordering to avoid tripping this."""
|
|
previous = _AUTHZ_LOGGER.disabled
|
|
_AUTHZ_LOGGER.disabled = False
|
|
yield
|
|
_AUTHZ_LOGGER.disabled = previous
|
|
|
|
|
|
def _principal(*roles: ManagementRole) -> AuthenticatedPrincipal:
|
|
return AuthenticatedPrincipal(
|
|
subject="test-subject",
|
|
issuer="https://fake.oidc.test",
|
|
audience=("itworx-mcp-hub",),
|
|
kind=PrincipalKind.MANAGEMENT_USER,
|
|
expires_at=_NOW,
|
|
issued_at=_NOW,
|
|
roles=frozenset(roles),
|
|
)
|
|
|
|
|
|
def test_matrix_has_no_action_granting_nothing() -> None:
|
|
for action, allowed in ENDPOINT_PERMISSIONS.items():
|
|
assert allowed, f"{action} grants no role at all -- unreachable by design or a typo"
|
|
|
|
|
|
def test_matrix_never_grants_an_action_to_nobody_but_also_never_to_everyone_implicitly() -> None:
|
|
# Every action's allowed set must be an explicit, real subset of the
|
|
# four defined roles -- catches an accidental typo'd role reference.
|
|
for action, allowed in ENDPOINT_PERMISSIONS.items():
|
|
assert allowed <= set(ManagementRole), action
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"action",
|
|
["dashboard.read", "resource.read", "policy.simulate", "catalog.preview", "client.read"],
|
|
)
|
|
@pytest.mark.parametrize("role", list(ManagementRole))
|
|
def test_baseline_reads_available_to_every_role(action: str, role: ManagementRole) -> None:
|
|
check_role(_principal(role), action) # must not raise
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role", [ManagementRole.AUDITOR, ManagementRole.OPERATOR, ManagementRole.ADMIN]
|
|
)
|
|
def test_audit_read_allowed_for_auditor_and_above(role: ManagementRole) -> None:
|
|
check_role(_principal(role), "audit.read")
|
|
|
|
|
|
def test_audit_read_denied_for_viewer_alone() -> None:
|
|
with pytest.raises(InsufficientRole):
|
|
check_role(_principal(ManagementRole.VIEWER), "audit.read")
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.OPERATOR, ManagementRole.ADMIN])
|
|
@pytest.mark.parametrize(
|
|
"action",
|
|
[
|
|
"tool.test",
|
|
"tool.invoke",
|
|
"operations.check",
|
|
"client.bootstrap",
|
|
"client.disable",
|
|
"client.revoke",
|
|
],
|
|
)
|
|
def test_client_lifecycle_actions_allowed_for_operator_and_admin(
|
|
role: ManagementRole, action: str
|
|
) -> None:
|
|
check_role(_principal(role), action)
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
|
|
@pytest.mark.parametrize(
|
|
"action",
|
|
[
|
|
"tool.test",
|
|
"tool.invoke",
|
|
"operations.check",
|
|
"client.bootstrap",
|
|
"client.disable",
|
|
"client.revoke",
|
|
],
|
|
)
|
|
def test_client_lifecycle_actions_denied_below_operator(role: ManagementRole, action: str) -> None:
|
|
with pytest.raises(InsufficientRole):
|
|
check_role(_principal(role), action)
|
|
|
|
|
|
@pytest.mark.parametrize("action", ["client.create", "catalog.apply"])
|
|
def test_admin_only_actions_allowed_for_admin(action: str) -> None:
|
|
check_role(_principal(ManagementRole.ADMIN), action)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role", [ManagementRole.VIEWER, ManagementRole.AUDITOR, ManagementRole.OPERATOR]
|
|
)
|
|
@pytest.mark.parametrize("action", ["client.create", "catalog.apply"])
|
|
def test_admin_only_actions_denied_below_admin(role: ManagementRole, action: str) -> None:
|
|
with pytest.raises(InsufficientRole):
|
|
check_role(_principal(role), action)
|
|
|
|
|
|
def test_multiple_roles_grant_via_any_matching_role() -> None:
|
|
# A principal holding both viewer and admin roles can do admin things --
|
|
# roles are additive, the union of what each individual role grants.
|
|
check_role(_principal(ManagementRole.VIEWER, ManagementRole.ADMIN), "catalog.apply")
|
|
|
|
|
|
def test_principal_with_no_roles_is_denied_every_action() -> None:
|
|
principal = _principal()
|
|
for action in ENDPOINT_PERMISSIONS:
|
|
with pytest.raises(InsufficientRole):
|
|
check_role(principal, action)
|
|
|
|
|
|
def test_unknown_action_raises_value_error_not_silently_denied_or_allowed() -> None:
|
|
with pytest.raises(ValueError, match="unknown management action"):
|
|
check_role(_principal(ManagementRole.ADMIN), "not-a-real-action")
|
|
|
|
|
|
def test_denial_is_logged_as_a_warning(caplog: pytest.LogCaptureFixture) -> None:
|
|
with caplog.at_level(logging.WARNING, logger="itworx_mcp_hub.management_authz"):
|
|
with pytest.raises(InsufficientRole):
|
|
check_role(_principal(ManagementRole.VIEWER), "catalog.apply")
|
|
assert any(record.levelno == logging.WARNING for record in caplog.records)
|
|
assert any("management_authz_denied" in record.getMessage() for record in caplog.records)
|
|
|
|
|
|
def test_privileged_allow_is_logged_as_info(caplog: pytest.LogCaptureFixture) -> None:
|
|
with caplog.at_level(logging.INFO, logger="itworx_mcp_hub.management_authz"):
|
|
check_role(_principal(ManagementRole.ADMIN), "catalog.apply")
|
|
assert any(
|
|
record.levelno == logging.INFO and "management_authz_allowed" in record.getMessage()
|
|
for record in caplog.records
|
|
)
|
|
|
|
|
|
def test_baseline_read_allow_is_not_logged_at_info(caplog: pytest.LogCaptureFixture) -> None:
|
|
# Dashboard polling by every role, every few seconds, is not a
|
|
# "privileged action" -- logging every allow here would be pure noise.
|
|
with caplog.at_level(logging.INFO, logger="itworx_mcp_hub.management_authz"):
|
|
check_role(_principal(ManagementRole.VIEWER), "dashboard.read")
|
|
assert not any("management_authz_allowed" in record.getMessage() for record in caplog.records)
|