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
|
1. After stripping comments and trimming whitespace, the query must
|
||||||
start with `SELECT` or `WITH` (case-insensitive)
|
start with `SELECT` or `WITH` (case-insensitive)
|
||||||
2. The query must not contain any of: `INSERT`, `UPDATE`, `DELETE`,
|
2. The query must not contain any of: `INSERT`, `UPDATE`, `DELETE`,
|
||||||
`DROP`, `CREATE`, `ALTER`, `TRUNCATE`, `EXEC`, `EXECUTE`, `CALL`
|
`DROP`, `CREATE`, `ALTER`, `TRUNCATE`, `MERGE`, `REPLACE`, `RENAME`,
|
||||||
3. Trailing semicolons are stripped before submission
|
`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
|
The validator uses the `sqlparse` tokenizer rather than regex, so
|
||||||
try to be — the structural read-only guarantee is the primary defense,
|
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.
|
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
|
## Operational consequence: minimal-privilege service account
|
||||||
|
|
||||||
Because the server is structurally incapable of writes, the AXL service
|
Because the server is structurally incapable of writes, the AXL service
|
||||||
|
|||||||
@ -10,10 +10,19 @@ sidebar:
|
|||||||
(partitions, CSSs, patterns, lists, groups, transformations, filters),
|
(partitions, CSSs, patterns, lists, groups, transformations, filters),
|
||||||
and **real-time registration** (RisPort70 device state).
|
and **real-time registration** (RisPort70 device state).
|
||||||
|
|
||||||
Every tool is read-only. The server never registers AXL write methods —
|
Every tool is read-only **against CUCM**. The server never registers AXL
|
||||||
no `executeSQLUpdate`, no `add*` / `update*` / `remove*` / `apply*` /
|
write methods — no `executeSQLUpdate`, no `add*` / `update*` / `remove*`
|
||||||
`reset*` / `restart*`. See [Read-only by structure](/explanation/read-only-by-structure/)
|
/ `apply*` / `reset*` / `restart*`. As of 2026-04, this is enforced at
|
||||||
for the rationale.
|
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
|
## Foundational
|
||||||
|
|
||||||
|
|||||||
@ -123,6 +123,13 @@ def cache_stats() -> dict:
|
|||||||
def cache_clear(method_pattern: str | None = None) -> dict:
|
def cache_clear(method_pattern: str | None = None) -> dict:
|
||||||
"""Clear cache entries for the current cluster.
|
"""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:
|
Args:
|
||||||
method_pattern: Optional method-name pattern (% wildcards). If omitted,
|
method_pattern: Optional method-name pattern (% wildcards). If omitted,
|
||||||
clears the entire cache. Use after a known config change to force
|
clears the entire cache. Use after a known config change to force
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user