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:
Ryan Malloy 2026-04-26 00:05:31 -06:00
parent 8aaeb04417
commit 8815db06d8
4 changed files with 257 additions and 0 deletions

View File

@ -27,6 +27,7 @@ from . import (
route_plan_overview, route_plan_overview,
sip_trunk_report, sip_trunk_report,
user_audit, user_audit,
whoami,
) )
__all__ = [ __all__ = [
@ -39,4 +40,5 @@ __all__ = [
"route_plan_overview", "route_plan_overview",
"sip_trunk_report", "sip_trunk_report",
"user_audit", "user_audit",
"whoami",
] ]

View 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.
"""

View File

@ -425,6 +425,21 @@ def hunt_pilot_audit() -> str:
return _prompts.hunt_pilot_audit.render(_docs) 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 # Bootstrap
# ==================================================================== # ====================================================================

View File

@ -168,6 +168,7 @@ def test_all_prompts_registered_in_server():
"user_audit", "user_audit",
"inbound_did_audit", "inbound_did_audit",
"hunt_pilot_audit", "hunt_pilot_audit",
"whoami",
}, f"unexpected prompt set: {names}" }, f"unexpected prompt set: {names}"
@ -236,8 +237,55 @@ def test_all_new_prompts_render_without_docs():
("user_audit", prompts.user_audit.render, ()), ("user_audit", prompts.user_audit.render, ()),
("inbound_did_audit", prompts.inbound_did_audit.render, ()), ("inbound_did_audit", prompts.inbound_did_audit.render, ()),
("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()), ("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()),
("whoami", prompts.whoami.render, ()),
]: ]:
text = fn(None, *args) text = fn(None, *args)
assert "cisco-docs index is not loaded" in text, ( assert "cisco-docs index is not loaded" in text, (
f"{name} failed graceful degradation" 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