diff --git a/src/mcp_cucm_axl/prompts/__init__.py b/src/mcp_cucm_axl/prompts/__init__.py index 3bff36a..7c72f30 100644 --- a/src/mcp_cucm_axl/prompts/__init__.py +++ b/src/mcp_cucm_axl/prompts/__init__.py @@ -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", ] diff --git a/src/mcp_cucm_axl/prompts/whoami.py b/src/mcp_cucm_axl/prompts/whoami.py new file mode 100644 index 0000000..b5dd1dc --- /dev/null +++ b/src/mcp_cucm_axl/prompts/whoami.py @@ -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 = "" + 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 = ''")`. +- `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. +""" diff --git a/src/mcp_cucm_axl/server.py b/src/mcp_cucm_axl/server.py index 159884b..2904652 100644 --- a/src/mcp_cucm_axl/server.py +++ b/src/mcp_cucm_axl/server.py @@ -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 # ==================================================================== diff --git a/tests/test_prompts_package.py b/tests/test_prompts_package.py index e5eafbe..cd3245e 100644 --- a/tests/test_prompts_package.py +++ b/tests/test_prompts_package.py @@ -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