From 3cf7dbc785b2a63fdaf3aae8dd79f2fc9f04595d Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 29 Apr 2026 06:38:52 -0600 Subject: [PATCH] docs: qualify read-only as "against CUCM"; document local-cache exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift between the docs ("every tool is read-only") and reality (cache_clear mutates the local SQLite cache) is the bug being addressed here. The code is fine — cache_clear has zero CUCM-side effect — but the docs over-promised by not naming the local-cache exception explicitly. cache_clear docstring (server.py): now leads with "Local-only: mutates the SQLite response cache ... Does NOT touch CUCM" with a pointer to the explanation page. reference/tools.md: read-only claim qualified as "against CUCM"; the two enforcement layers (sqlparse validator + allowlist proxy) named explicitly; cache_clear flagged as the lone local-mutation tool. explanation/read-only-by-structure.md: validator section updated with the full forbidden-keyword list, multi-statement detection, and an explanation of how sqlparse fixes the regex blindspots. New "Defense-in-depth: read-only allowlist proxy" section describing _ReadOnlyServiceProxy and the parallel RisPort gate. New "What read-only does NOT mean" section enumerating the local-cache exception and the AXL_CACHE_TTL=0 opt-out for read-only-filesystem deployments. --- .../explanation/read-only-by-structure.md | 60 +++++++++++++++++-- docs/src/content/docs/reference/tools.md | 17 ++++-- src/mcaxl/server.py | 7 +++ 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/docs/src/content/docs/explanation/read-only-by-structure.md b/docs/src/content/docs/explanation/read-only-by-structure.md index 570211e..25e43e1 100644 --- a/docs/src/content/docs/explanation/read-only-by-structure.md +++ b/docs/src/content/docs/explanation/read-only-by-structure.md @@ -47,13 +47,65 @@ The validator's rules: 1. After stripping comments and trimming whitespace, the query must start with `SELECT` or `WITH` (case-insensitive) 2. The query must not contain any of: `INSERT`, `UPDATE`, `DELETE`, - `DROP`, `CREATE`, `ALTER`, `TRUNCATE`, `EXEC`, `EXECUTE`, `CALL` -3. Trailing semicolons are stripped before submission + `DROP`, `CREATE`, `ALTER`, `TRUNCATE`, `MERGE`, `REPLACE`, `RENAME`, + `GRANT`, `REVOKE`, `EXEC`, `EXECUTE`, `CALL`, `ATTACH`, `DETACH` +3. Multi-statement input is rejected explicitly (the parser sees + `SELECT 1; SELECT 2` as two statements and refuses) +4. Trailing semicolons are stripped before submission -This catches the obvious cases. It is not a SQL parser, and it doesn't -try to be — the structural read-only guarantee is the primary defense, +The validator uses the `sqlparse` tokenizer rather than regex, so +string literals and comments are correctly bounded — a description +field containing the word `DELETE` doesn't trip the check, and a +column name like `inserted_at` isn't confused with the `INSERT` +keyword. The structural read-only guarantee is the primary defense; the validator is the secondary defense. +## Defense-in-depth: read-only allowlist proxy + +`client.py` wraps the zeep AXL service in a `_ReadOnlyServiceProxy` +whose `__getattr__` refuses any method outside an explicit +allowlist (`getCCMVersion`, `executeSQLQuery`). If a future +contributor adds a tool that accidentally calls +`self._service.addRoutePartition(...)`, the proxy raises +`ReadOnlyViolation` at attribute lookup — before zeep ever +serializes a SOAP envelope. + +The RisPort70 path has the parallel guard. RisPort doesn't go +through zeep — its envelopes are hand-rolled — so the allowlist +chokepoint lives in the envelope builders themselves: every +builder calls `_check_operation_allowed(name)` as its first +statement, and the only allowed operation today is `selectCmDevice`. + +You can verify the proxy is active at runtime via the `health` +tool — it surfaces `axl_connection.read_only_proxy: true` and +`allowed_axl_methods: ["executeSQLQuery", "getCCMVersion"]`. If +either field is missing or false, something has gone wrong with +bootstrap. + +## What read-only does NOT mean + +"Read-only" in this server is precisely scoped: **read-only against +CUCM**. Specifically that means: + +- No SOAP method outside the AXL/RisPort allowlists is dispatched +- No SQL outside `SELECT`/`WITH` reaches `executeSQLQuery` +- No tool call mutates cluster configuration, registration state, + or any other CUCM-side resource + +It does **not** mean "no mutation anywhere." There is exactly one +local mutation point: the `cache_clear` tool deletes entries from +the SQLite response cache at +`~/.cache/mcaxl/responses/axl_responses.sqlite`. CUCM is unaware +this happened — the cache is a local optimization, not a CUCM +artefact. The operation is idempotent, contains no PII beyond what +the corresponding read-only query already returned, and is +reversible by simply waiting for the next tool call to repopulate +the relevant entries from AXL. + +If you need *zero* local writes (e.g., when running mcaxl from a +read-only filesystem), set `AXL_CACHE_TTL=0` to disable the cache +entirely — `cache_clear` then has nothing to clear. + ## Operational consequence: minimal-privilege service account Because the server is structurally incapable of writes, the AXL service diff --git a/docs/src/content/docs/reference/tools.md b/docs/src/content/docs/reference/tools.md index 5bfc9c1..c76b2c4 100644 --- a/docs/src/content/docs/reference/tools.md +++ b/docs/src/content/docs/reference/tools.md @@ -10,10 +10,19 @@ sidebar: (partitions, CSSs, patterns, lists, groups, transformations, filters), and **real-time registration** (RisPort70 device state). -Every tool is read-only. The server never registers AXL write methods — -no `executeSQLUpdate`, no `add*` / `update*` / `remove*` / `apply*` / -`reset*` / `restart*`. See [Read-only by structure](/explanation/read-only-by-structure/) -for the rationale. +Every tool is read-only **against CUCM**. The server never registers AXL +write methods — no `executeSQLUpdate`, no `add*` / `update*` / `remove*` +/ `apply*` / `reset*` / `restart*`. As of 2026-04, this is enforced at +two layers: a `sqlparse`-based validator on `axl_sql` input that rejects +non-`SELECT`/`WITH` statements (and explicit multi-statement input), and +a runtime allowlist proxy around the AXL service that refuses any SOAP +method outside `{getCCMVersion, executeSQLQuery}`. RisPort70 has the +parallel allowlist in its envelope builder. + +`cache_clear` is the **one** tool that mutates any state, and the state +it mutates is the **local SQLite response cache only** — CUCM is +unaware the call happened. See [Read-only by structure](/explanation/read-only-by-structure/) +for the rationale and what read-only does NOT mean. ## Foundational diff --git a/src/mcaxl/server.py b/src/mcaxl/server.py index 0ff194a..3d3b4d1 100644 --- a/src/mcaxl/server.py +++ b/src/mcaxl/server.py @@ -123,6 +123,13 @@ def cache_stats() -> dict: def cache_clear(method_pattern: str | None = None) -> dict: """Clear cache entries for the current cluster. + **Local-only:** mutates the SQLite response cache at + ~/.cache/mcaxl/responses/axl_responses.sqlite. Does NOT touch CUCM — + the cluster is unaware this tool ran. The next tool call will re-fetch + from AXL. This is the only mcaxl tool that mutates any state, and the + state it mutates is local cache only; see /explanation/read-only-by-structure/ + for what "read-only" means in this server's context. + Args: method_pattern: Optional method-name pattern (% wildcards). If omitted, clears the entire cache. Use after a known config change to force