Today, mcaxl is read-only against CUCM by *absence* — the tools
never call write methods. But absence isn't enforced: a future
contributor adding a tool could write
self._service.addRoutePartition(...) and zeep would happily
dispatch it. There's no positive guard.
Two new chokepoints close that gap:
AXL side — _ReadOnlyServiceProxy wraps the zeep service object.
__getattr__ refuses any method outside _ALLOWED_AXL_METHODS
(currently {getCCMVersion, executeSQLQuery}) with a new
ReadOnlyViolation exception, raised at attribute lookup BEFORE
zeep serializes a SOAP envelope. Underscore-prefixed and dunder
attributes pass through (zeep introspects via _binding_options,
__class__, etc., and those don't dispatch SOAP).
RisPort side — RisPort70 envelopes are hand-rolled, so the proxy
pattern doesn't apply directly. The equivalent chokepoint lives in
the envelope builders: _check_operation_allowed(name) is the first
line of every builder, and _ALLOWED_RISPORT_OPERATIONS is the
allowlist (currently {selectCmDevice}).
Operators can verify the proxy is active via the health tool —
connection_status() now reports read_only_proxy: true and
allowed_axl_methods: [...].
Tests:
- new tests/test_readonly_proxy.py (13 tests):
* allowed methods dispatch through to inner service
* 9 parameterized refusals (addRoutePartition, updatePhone,
removeUser, applyPhone, resetPhone, restartPhone,
executeSQLUpdate, doDeviceLogin, wipePhone)
* allowlist drift detection (set must be exactly what we
advertise — accidental widening fails red)
* dunder + underscore-prefixed passthrough
- tests/test_risport.py: +TestReadOnlyAllowlist (7 tests):
* selectCmDevice passes _check_operation_allowed
* 6 parameterized refusals (addCmDevice, removeCmDevice,
resetDevice, restartDevice, applyCmDevice, executeSQLUpdate)
* allowlist drift detection
182 tests pass total (was 161; +13 proxy + 7 risport + 1 allowlist
drift catch).
89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""Tests for _ReadOnlyServiceProxy: defense-in-depth allowlist for AXL methods.
|
|
|
|
The proxy wraps the zeep service object so any SOAP method outside
|
|
{getCCMVersion, executeSQLQuery} raises ReadOnlyViolation at attribute
|
|
lookup, before zeep serializes an envelope. This is a guard against future
|
|
contributors accidentally calling write methods like addRoutePartition().
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from mcaxl.client import (
|
|
ReadOnlyViolation,
|
|
_ALLOWED_AXL_METHODS,
|
|
_ReadOnlyServiceProxy,
|
|
)
|
|
|
|
|
|
class TestAllowlistEnforcement:
|
|
def test_allowed_method_dispatches_through(self):
|
|
# Both methods we currently use must pass through the proxy.
|
|
inner = MagicMock()
|
|
inner.getCCMVersion.return_value = {"version": "15.0(1)"}
|
|
inner.executeSQLQuery.return_value = {"rows": []}
|
|
proxy = _ReadOnlyServiceProxy(inner)
|
|
|
|
assert proxy.getCCMVersion() == {"version": "15.0(1)"}
|
|
assert proxy.executeSQLQuery(sql="SELECT 1") == {"rows": []}
|
|
inner.getCCMVersion.assert_called_once()
|
|
inner.executeSQLQuery.assert_called_once_with(sql="SELECT 1")
|
|
|
|
@pytest.mark.parametrize(
|
|
"method_name",
|
|
[
|
|
"addRoutePartition",
|
|
"updatePhone",
|
|
"removeUser",
|
|
"applyPhone",
|
|
"resetPhone",
|
|
"restartPhone",
|
|
"executeSQLUpdate", # the AXL write counterpart
|
|
"doDeviceLogin",
|
|
"wipePhone",
|
|
],
|
|
)
|
|
def test_disallowed_method_raises(self, method_name):
|
|
inner = MagicMock()
|
|
proxy = _ReadOnlyServiceProxy(inner)
|
|
|
|
with pytest.raises(ReadOnlyViolation, match=method_name):
|
|
getattr(proxy, method_name)
|
|
|
|
# The inner service must NOT have been touched at all — refusal
|
|
# happens before any SOAP serialization.
|
|
assert not getattr(inner, method_name).called
|
|
|
|
def test_allowlist_is_exactly_what_we_advertise(self):
|
|
# If this set ever grows, that's a deliberate decision and the
|
|
# test should be updated alongside the change. Catching unintended
|
|
# widening of the read-only surface is the point.
|
|
assert _ALLOWED_AXL_METHODS == frozenset(
|
|
{"getCCMVersion", "executeSQLQuery"}
|
|
)
|
|
|
|
|
|
class TestAttributePassthrough:
|
|
def test_dunder_attributes_pass_through(self):
|
|
# Zeep introspects services via dunder attributes (__class__,
|
|
# __dict__, etc.). The proxy must not break those.
|
|
inner = MagicMock()
|
|
inner.__class__ = MagicMock
|
|
proxy = _ReadOnlyServiceProxy(inner)
|
|
|
|
# Reading the class doesn't raise
|
|
_ = proxy.__class__
|
|
|
|
def test_underscore_prefixed_attributes_pass_through(self):
|
|
# zeep service internals like `_binding_options`, `_operations`
|
|
# are accessed by name. We don't want to gate those because they
|
|
# don't dispatch SOAP — they read metadata.
|
|
inner = MagicMock()
|
|
inner._binding_options = {"address": "https://cucm/axl/"}
|
|
inner._operations = ["getCCMVersion", "executeSQLQuery", "addPhone"]
|
|
proxy = _ReadOnlyServiceProxy(inner)
|
|
|
|
assert proxy._binding_options == {"address": "https://cucm/axl/"}
|
|
assert "addPhone" in proxy._operations # introspection, not dispatch
|