docs: qualify read-only as "against CUCM"; document local-cache exception
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.
This commit is contained in:
parent
639d706200
commit
3cf7dbc785
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user