Add whoami prompt — single-user role chain with AXL service-account default
Operator-suggested prompt: "what does my AXL account *actually* have permission to do?" Resolves the user → access-control-group → function-role chain for a single account, defaulting to the AXL service account from AXL_USER env when no userid is given. The prompt principle came in using table names from older Cisco docs (`enduserauthgroupmap`, `dirgrouprolemap`) that don't exist on CUCM 15. The shipped SQL uses the verified CUCM 15 names (`enduserdirgroupmap`, `functionroledirgroupmap`); a regression test asserts the deprecated names don't appear in the rendered SQL section, so any future "fix" reverting to the older names fires red. Live verification on cucm-pub.binghammemorial.org found the existing AXL service account (`SupportedSystemsReadOnly`) has 4 roles via the `ReadOnly-AXL` access control group: - Standard AXL API Access (full RW — group misnamed) - Standard AXL Read Only API Access (the genuinely-read-only one) - Standard Packet Sniffing (PHI-relevant in healthcare) - Standard RealtimeAndTraceCollection The first finding is structural: the group `ReadOnly-AXL` contains the FULL RW role `Standard AXL API Access` despite its name. The MCP server's structural read-only enforcement (no write methods registered) is what prevents this from mattering — but the account itself is over-privileged relative to what the tool needs. The prompt's findings template surfaces this kind of misnamed-group case explicitly. Also discovered (and documented in the prompt body): AXL auth is case-insensitive for usernames, but SQL `WHERE name = 'X'` is case-sensitive. Step 3 of the prompt handles the case-mismatch fallback so a typo like `SupportedSYstemsReadOnly` (env) vs `SupportedSystemsReadOnly` (cluster canonical) doesn't produce a silently-empty result. 5 new tests: - correct CUCM 15 table names embedded in SQL - explicit userid threads through to the query - default reads AXL_USER from env - missing userid AND missing env → clear instruction - SQL injection defense (single-quote escape) 123 → 128 tests; 9 → 10 prompts. Prompt registration smoke test updated to assert the new shim is wired.
This commit is contained in:
parent
8aaeb04417
commit
8815db06d8
@ -27,6 +27,7 @@ from . import (
|
||||
route_plan_overview,
|
||||
sip_trunk_report,
|
||||
user_audit,
|
||||
whoami,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@ -39,4 +40,5 @@ __all__ = [
|
||||
"route_plan_overview",
|
||||
"sip_trunk_report",
|
||||
"user_audit",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
192
src/mcp_cucm_axl/prompts/whoami.py
Normal file
192
src/mcp_cucm_axl/prompts/whoami.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""Look up a user's role chain — defaults to the calling AXL service account.
|
||||
|
||||
Audit-relevant self-diagnostic: "what does this MCP server's account
|
||||
*actually* have access to via AXL?" Identifies the access control group
|
||||
membership chain (user → dirgroup → functionrole) and surfaces the
|
||||
combination of roles, including any that contradict the group's name
|
||||
(e.g., a "ReadOnly-XXX" group that contains a full RW role).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ._common import render_schema_block
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..docs_loader import DocsIndex
|
||||
|
||||
|
||||
_KEYWORDS = [
|
||||
"application user", "end user", "function role", "directory group",
|
||||
"access control group", "role assignment", "AXL access",
|
||||
]
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("'", "''")
|
||||
|
||||
|
||||
def render(docs: "DocsIndex | None", userid: str | None = None) -> str:
|
||||
"""Look up the role chain for a userid (or the AXL account by default).
|
||||
|
||||
Args:
|
||||
userid: User identity to look up. If None, defaults to the value
|
||||
of the `AXL_USER` environment variable (the account this MCP
|
||||
server uses to talk to the cluster). Names are case-sensitive
|
||||
in SQL, so the value must match the cluster's stored form
|
||||
exactly — operator might need to verify case via
|
||||
`axl_sql("SELECT name FROM applicationuser WHERE LOWER(name) LIKE '%X%'")`.
|
||||
"""
|
||||
schema_block = render_schema_block(
|
||||
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
|
||||
)
|
||||
|
||||
# Default to the AXL service account if no userid given
|
||||
target = userid or os.environ.get("AXL_USER")
|
||||
if not target:
|
||||
target_clause = "<set userid parameter or AXL_USER env var>"
|
||||
scope_note = "no userid supplied"
|
||||
else:
|
||||
target = _esc(target)
|
||||
target_clause = target
|
||||
scope_note = (
|
||||
f"AXL service account (`AXL_USER`)" if userid is None
|
||||
else f"explicit userid `{userid}`"
|
||||
)
|
||||
|
||||
return f"""# whoami — role chain for `{target_clause}` ({scope_note})
|
||||
|
||||
Resolve the access-control-group → function-role chain for a single
|
||||
account. Defaults to the AXL service account so the operator can quickly
|
||||
see what permissions THIS MCP server actually has.
|
||||
|
||||
## Schema knowledge (CUCM 15)
|
||||
|
||||
CUCM stores user-to-role mapping in two parallel hierarchies:
|
||||
|
||||
```
|
||||
enduser ──→ enduserdirgroupmap ──→ dirgroup
|
||||
│
|
||||
↓
|
||||
functionroledirgroupmap
|
||||
│
|
||||
↓
|
||||
functionrole
|
||||
|
||||
applicationuser ──→ applicationuserdirgroupmap ──→ dirgroup ──→ ... (same)
|
||||
```
|
||||
|
||||
A user's effective roles are the UNION of all roles attached to all
|
||||
groups they belong to. Cisco docs sometimes call dirgroups "Access
|
||||
Control Groups" — same thing, different name.
|
||||
|
||||
**Note on table naming**: older Cisco docs reference
|
||||
`enduserauthgroupmap` and `dirgrouprolemap`; CUCM 15 uses
|
||||
`enduserdirgroupmap` and `functionroledirgroupmap`. The queries below
|
||||
use the verified CUCM 15 names.
|
||||
|
||||
## Step 1 — applicationuser lookup (most AXL service accounts are here)
|
||||
|
||||
```sql
|
||||
SELECT au.name AS userid,
|
||||
g.name AS access_control_group,
|
||||
fr.name AS role
|
||||
FROM applicationuser au
|
||||
LEFT OUTER JOIN applicationuserdirgroupmap audgm ON audgm.fkapplicationuser = au.pkid
|
||||
LEFT OUTER JOIN dirgroup g ON g.pkid = audgm.fkdirgroup
|
||||
LEFT OUTER JOIN functionroledirgroupmap grm ON grm.fkdirgroup = g.pkid
|
||||
LEFT OUTER JOIN functionrole fr ON fr.pkid = grm.fkfunctionrole
|
||||
WHERE au.name = '{target_clause}'
|
||||
ORDER BY g.name, fr.name;
|
||||
```
|
||||
|
||||
## Step 2 — enduser lookup (run if Step 1 returns 0 rows)
|
||||
|
||||
```sql
|
||||
SELECT u.userid,
|
||||
g.name AS access_control_group,
|
||||
fr.name AS role
|
||||
FROM enduser u
|
||||
LEFT OUTER JOIN enduserdirgroupmap egm ON egm.fkenduser = u.pkid
|
||||
LEFT OUTER JOIN dirgroup g ON g.pkid = egm.fkdirgroup
|
||||
LEFT OUTER JOIN functionroledirgroupmap grm ON grm.fkdirgroup = g.pkid
|
||||
LEFT OUTER JOIN functionrole fr ON fr.pkid = grm.fkfunctionrole
|
||||
WHERE u.userid = '{target_clause}'
|
||||
ORDER BY g.name, fr.name;
|
||||
```
|
||||
|
||||
## Step 3 — case-insensitive fallback (if both return 0 rows)
|
||||
|
||||
CUCM's authentication is case-insensitive for usernames, but SQL
|
||||
lookups are case-sensitive. If neither query returns rows, search
|
||||
case-insensitively to find the canonical stored form:
|
||||
|
||||
```sql
|
||||
SELECT 'applicationuser' AS source, name AS canonical_name
|
||||
FROM applicationuser WHERE LOWER(name) = LOWER('{target_clause}')
|
||||
UNION
|
||||
SELECT 'enduser' AS source, userid AS canonical_name
|
||||
FROM enduser WHERE LOWER(userid) = LOWER('{target_clause}');
|
||||
```
|
||||
|
||||
Then re-run Step 1 or Step 2 with the canonical name.
|
||||
|
||||
## Findings to call out
|
||||
|
||||
### Misnamed access control groups
|
||||
- A group named `ReadOnly-XXX` containing the role
|
||||
`Standard AXL API Access` (the full read-write role, NOT
|
||||
`Standard AXL Read Only API Access`) is a misconfiguration — the
|
||||
group's name implies read-only intent but the membership grants RW.
|
||||
- Confirm by checking whether the same group ALSO contains the proper
|
||||
read-only role; if both, the group is contradictory and the role
|
||||
set should be reduced.
|
||||
|
||||
### High-privilege roles to flag specifically
|
||||
- `Standard CCM Super Users` — full admin write access. Should be
|
||||
reserved for human admin accounts, not service accounts.
|
||||
- `Standard AXL API Access` — full RW AXL access. Service accounts
|
||||
for read-only tooling should have `Standard AXL Read Only API Access`
|
||||
instead.
|
||||
- `Standard Packet Sniffing` — captures call-setup traffic. In
|
||||
healthcare/regulated environments, may capture PHI in SIP headers.
|
||||
- `Standard CCM Admin Users` — admin write access via CCM Admin UI;
|
||||
combined with API roles, gives a service account broad reach.
|
||||
|
||||
### Excess permission accumulation
|
||||
- Service accounts with > 3-4 roles often indicate "added permissions
|
||||
over time without removing old ones." Review and prune.
|
||||
- An MCP-server-style account should ideally have ONLY
|
||||
`Standard AXL Read Only API Access` (and nothing else).
|
||||
|
||||
### Operational notes
|
||||
- The `.env` value of AXL_USER may differ in *case* from the cluster's
|
||||
canonical stored form. AXL auth is case-insensitive, but SQL is not.
|
||||
Recommend the .env value match the canonical stored form exactly.
|
||||
|
||||
## Suggested follow-up calls
|
||||
|
||||
- For each high-privilege role found, check who *else* has it:
|
||||
`axl_sql("SELECT au.name FROM applicationuser au
|
||||
JOIN applicationuserdirgroupmap m ON m.fkapplicationuser = au.pkid
|
||||
JOIN functionroledirgroupmap r ON r.fkdirgroup = m.fkdirgroup
|
||||
JOIN functionrole fr ON fr.pkid = r.fkfunctionrole
|
||||
WHERE fr.name = '<role>'")`.
|
||||
- `axl_describe_table('applicationuser')` for ACL flag columns
|
||||
(`acl*` controls SIP subscription / OOD-refer / etc. — additional
|
||||
privileges beyond the role assignments).
|
||||
|
||||
## Reference: CUCM data dictionary (users + role chain)
|
||||
|
||||
{schema_block}
|
||||
|
||||
Run Step 1 first. If empty rows, run Step 2. If still empty, run Step 3
|
||||
to find the canonical case form. Then produce a structured report:
|
||||
- Account identity (and what type — applicationuser vs enduser)
|
||||
- Access control groups
|
||||
- Effective roles (deduplicated)
|
||||
- Findings, with severity, especially flagging misnamed groups and
|
||||
excess privilege.
|
||||
"""
|
||||
@ -425,6 +425,21 @@ def hunt_pilot_audit() -> str:
|
||||
return _prompts.hunt_pilot_audit.render(_docs)
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def whoami(userid: str | None = None) -> str:
|
||||
"""Look up the role chain for a single user (defaults to the AXL
|
||||
service account). Surfaces access-control-group membership, attached
|
||||
function roles, and findings around misnamed groups or excess
|
||||
privileges.
|
||||
|
||||
Args:
|
||||
userid: User identity to look up. If omitted, defaults to the
|
||||
value of the AXL_USER environment variable (the account this
|
||||
MCP server uses to talk to the cluster).
|
||||
"""
|
||||
return _prompts.whoami.render(_docs, userid)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Bootstrap
|
||||
# ====================================================================
|
||||
|
||||
@ -168,6 +168,7 @@ def test_all_prompts_registered_in_server():
|
||||
"user_audit",
|
||||
"inbound_did_audit",
|
||||
"hunt_pilot_audit",
|
||||
"whoami",
|
||||
}, f"unexpected prompt set: {names}"
|
||||
|
||||
|
||||
@ -236,8 +237,55 @@ def test_all_new_prompts_render_without_docs():
|
||||
("user_audit", prompts.user_audit.render, ()),
|
||||
("inbound_did_audit", prompts.inbound_did_audit.render, ()),
|
||||
("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()),
|
||||
("whoami", prompts.whoami.render, ()),
|
||||
]:
|
||||
text = fn(None, *args)
|
||||
assert "cisco-docs index is not loaded" in text, (
|
||||
f"{name} failed graceful degradation"
|
||||
)
|
||||
|
||||
|
||||
# ---- whoami specifics ------------------------------------------------------
|
||||
|
||||
def test_whoami_uses_correct_table_names_for_cucm_15(fake_docs):
|
||||
"""The query principle came in with `enduserauthgroupmap` and
|
||||
`dirgrouprolemap`, but those tables don't exist on CUCM 15. The
|
||||
prompt MUST embed the verified names: `enduserdirgroupmap` and
|
||||
`functionroledirgroupmap`. If a future contributor reverts to the
|
||||
older names, this test fires red."""
|
||||
text = prompts.whoami.render(fake_docs, "TestUser")
|
||||
assert "enduserdirgroupmap" in text
|
||||
assert "functionroledirgroupmap" in text
|
||||
# And the older deprecated names should NOT appear in the SQL we ship
|
||||
# (they're fine in the prose section that explains why the names changed)
|
||||
sql_section = text.split("## Schema knowledge")[0] + text.split("## Step")[1]
|
||||
for old_name in ("enduserauthgroupmap", "dirgrouprolemap"):
|
||||
assert old_name not in sql_section, (
|
||||
f"deprecated table name {old_name} appeared in the SQL section"
|
||||
)
|
||||
|
||||
|
||||
def test_whoami_explicit_userid_in_query(fake_docs):
|
||||
text = prompts.whoami.render(fake_docs, "SomeAccount")
|
||||
assert "SomeAccount" in text
|
||||
assert "explicit userid" in text
|
||||
|
||||
|
||||
def test_whoami_default_uses_axl_user_env(fake_docs, monkeypatch):
|
||||
monkeypatch.setenv("AXL_USER", "EnvAccount")
|
||||
text = prompts.whoami.render(fake_docs)
|
||||
assert "EnvAccount" in text
|
||||
assert "AXL service account" in text
|
||||
|
||||
|
||||
def test_whoami_no_userid_no_env(fake_docs, monkeypatch):
|
||||
monkeypatch.delenv("AXL_USER", raising=False)
|
||||
text = prompts.whoami.render(fake_docs)
|
||||
assert "no userid supplied" in text
|
||||
# Tells the LLM what to do — set the param or the env var
|
||||
assert "set userid parameter" in text or "AXL_USER env var" in text
|
||||
|
||||
|
||||
def test_whoami_userid_escaped_for_sql_safety(fake_docs):
|
||||
text = prompts.whoami.render(fake_docs, "O'Brien")
|
||||
assert "O''Brien" in text # single quote doubled per Informix convention
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user