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,
|
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",
|
||||||
]
|
]
|
||||||
|
|||||||
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)
|
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
|
||||||
# ====================================================================
|
# ====================================================================
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user