Add 4 audit prompts: phone_inventory, user_audit, inbound_did_audit, hunt_pilot_audit

Builds on the prompts-package extraction. Each new prompt embeds
schema-verified SQL plus a findings template tuned to surface
audit-actionable issues (orphans, drift, capacity outliers, security
posture).

phone_inventory_report(filter=None):
  Aggregates by model / device pool / CSS, then anomaly queries for
  phones with no description, phones whose description echoes their
  MAC-based name, phones with no owner, phones in non-default CSS.
  Cross-references owner status (phones owned by inactive users
  surface as findings).

user_audit(focus=full|admin|inactive|app_users):
  End user + application user inventory, role/group assignments via
  the enduserdirgroupmap → dirgroup → functionroledirgroupmap →
  functionrole join chain. Security-critical findings: app users
  with admin-grade role memberships, local-user accounts with admin
  privileges, phones owned by inactive users.

inbound_did_audit():
  Reusable form of today's cucm-inbound-did-inventory work. XFORM-
  Inbound-DNIS curated list categorized (pass-through, block-trans,
  specific renames, wildcards, catch-all hazard). Cross-checked
  against Internal-PT route patterns and the operator-curated
  PSTN-Screen-PT spam blocklist. Findings for orphan target
  extensions and the silent !-catch-all risk.

hunt_pilot_audit():
  Hunt pilot inventory with queue settings, line group membership,
  and distribution algorithm decoding. Schema knowledge already
  Hamilton-verified: huntpilotqueue joins via fknumplan_pilot, NOT
  fknumplan (the test asserts the correct column appears in the
  rendered prompt). Findings: queue misconfigurations (NULL
  destinations, infinite max-wait), empty line groups, dead pilots
  with no route-list destination.

Implementation notes:
  - Each prompt's SQL was validated against the live cluster
    (cucm-pub.binghammemorial.org, CUCM 15.0.1.12900-234).
  - user_audit originally used UNION ALL with NULL-typed status
    column for the headcounts query; Informix rejected it. Split
    into two simpler queries (commented in the prompt body).
  - phone_inventory_report uses a Hamilton-style SQL escape for
    the optional name_filter (single quotes doubled).
  - All four prompts gracefully degrade when the docs index isn't
    loaded (verified by test_all_new_prompts_render_without_docs).

114 → 123 tests; 5 → 9 prompts. Full live-cluster verification:
  - 12 phone models, 629 Cisco 7841 phones (largest model)
  - 1,246 active end users, 25 application users
  - Hunt pilots with named distribution algorithms (Broadcast, Top
    Down, etc.) — confirms typedistributealgorithm join works
  - Hamilton-fixed huntpilotqueue.fknumplan_pilot column verified
    in the embedded SQL via dedicated regression test.
This commit is contained in:
Ryan Malloy 2026-04-25 23:57:01 -06:00
parent e6aa075793
commit 8aaeb04417
7 changed files with 913 additions and 0 deletions

View File

@ -20,15 +20,23 @@ shim is where the parameter contract lives.
from . import ( from . import (
audit_routing, audit_routing,
cucm_sql_help, cucm_sql_help,
hunt_pilot_audit,
inbound_did_audit,
investigate_pattern, investigate_pattern,
phone_inventory_report,
route_plan_overview, route_plan_overview,
sip_trunk_report, sip_trunk_report,
user_audit,
) )
__all__ = [ __all__ = [
"audit_routing", "audit_routing",
"cucm_sql_help", "cucm_sql_help",
"hunt_pilot_audit",
"inbound_did_audit",
"investigate_pattern", "investigate_pattern",
"phone_inventory_report",
"route_plan_overview", "route_plan_overview",
"sip_trunk_report", "sip_trunk_report",
"user_audit",
] ]

View File

@ -0,0 +1,207 @@
"""Hunt pilot + line group + queue settings audit."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"hunt pilot", "hunt list", "line group", "distribution algorithm",
"queue", "rna", "ring no answer", "huntpilotqueue",
]
def render(docs: "DocsIndex | None") -> str:
"""Hunt pilot inventory and audit. Schema-aware (uses
`huntpilotqueue.fknumplan_pilot`, NOT `fknumplan` verified against
CUCM 15 schema 2026-04-25)."""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
return """# CUCM Hunt Pilot Audit
Hunt pilots distribute incoming calls across a group of phones (hunt
group) with configurable algorithms (top-down, longest-idle, broadcast,
etc.) and queue behavior. Misconfigured hunt pilots are a common source
of "calls disappear into the void" complaints.
## Schema (CUCM 15)
- `numplan` row with `tkpatternusage = 7` is a Hunt Pilot
- `huntpilotqueue` joins to numplan via `fknumplan_pilot` (NOT
`fknumplan` that's a common-mistake column name)
- `linegroup` defines the distribution algorithm and member-handling
- `linegroupnumplanmap` joins line groups to their member DNs
- A hunt pilot points to a "Route List" device (just like route
patterns do) that route list contains route groups containing
line groups containing phones
## Step 1 — Hunt pilot inventory
```sql
SELECT
np.dnorpattern AS hunt_pilot,
rp.name AS partition,
np.description,
np.calledpartytransformationmask AS xform_called,
np.callingpartytransformationmask AS xform_calling,
np.pkid
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE np.tkpatternusage = 7 -- Hunt Pilot
ORDER BY rp.name, np.dnorpattern;
```
Each hunt pilot is a "front door" pattern what callers dial to reach
a group. The transformations columns matter: a hunt pilot may rewrite
the called party (e.g., normalize to a base-extension) before
distribution.
## Step 2 — Queue settings per hunt pilot
```sql
SELECT
np.dnorpattern AS hunt_pilot,
hpq.maxcallersinqueue,
hpq.maxwaittimeinqueue,
hpq.maxwaittimedestination AS overflow_dest,
hpq.noagentdestination AS no_agent_dest,
hpq.queuefulldestination AS queue_full_dest,
css_max.name AS css_for_max_wait,
css_no.name AS css_for_no_agent,
css_full.name AS css_for_queue_full
FROM huntpilotqueue hpq
JOIN numplan np ON hpq.fknumplan_pilot = np.pkid
LEFT OUTER JOIN callingsearchspace css_max ON hpq.fkcallingsearchspace_maxwaittime = css_max.pkid
LEFT OUTER JOIN callingsearchspace css_no ON hpq.fkcallingsearchspace_noagent = css_no.pkid
LEFT OUTER JOIN callingsearchspace css_full ON hpq.fkcallingsearchspace_pilotqueuefull = css_full.pkid
ORDER BY np.dnorpattern;
```
Queue behavior is the most-misconfigured aspect of hunt pilots:
- `maxwaittimeinqueue = 0` means "no max" callers can wait forever.
Usually a misconfiguration; should be set to a sensible value
(e.g., 30-300 seconds) with an overflow destination.
- `maxwaittimedestination` / `noagentdestination` /
`queuefulldestination` define what happens when each condition
triggers. NULL on any of these means "drop the call" almost never
the operator's intent.
## Step 3 — Line groups and their member DNs
```sql
SELECT
lg.name AS line_group,
ta.name AS distribution_algorithm,
np.dnorpattern AS member_dn,
rp.name AS member_partition,
lgnpm.lineselectionorder AS sortorder
FROM linegroup lg
LEFT OUTER JOIN typedistributealgorithm ta ON lg.tkdistributealgorithm = ta.enum
LEFT OUTER JOIN linegroupnumplanmap lgnpm ON lgnpm.fklinegroup = lg.pkid
LEFT OUTER JOIN numplan np ON lgnpm.fknumplan = np.pkid
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
ORDER BY lg.name, lgnpm.lineselectionorder;
```
Distribution algorithms (decoded via `typedistributealgorithm`):
- **Top Down** first member always rings first; predictable but
uneven load.
- **Circular** round-robin starting after the last-rung member.
- **Longest Idle Time** call goes to the member who hasn't rung in
the longest time. Most common for fairness.
- **Broadcast** every member rings simultaneously. Use sparingly;
noisy.
## Step 4 — Hunt pilot → route list / line group destinations
The hunt pilot routes to a Route List that contains the actual line
group(s). Use the existing tool:
```
route_inspect_pattern(<hunt_pilot_dn>, <partition>)
```
This returns the destination chain. For each hunt pilot identified in
Step 1, run this to confirm:
- Destination is the expected route list / line group
- The line group's distribution algorithm matches operational intent
- Member DNs all exist and belong to active phones (cross-reference
with `phone_inventory_report`)
## Step 5 — Hunt pilots with no line group destination (dead pilots)
```sql
-- Hunt pilots that don't appear in any line group routing
SELECT np.dnorpattern, rp.name AS partition, np.description
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE np.tkpatternusage = 7
AND NOT EXISTS (
SELECT 1 FROM devicenumplanmap dnm
JOIN device d ON dnm.fkdevice = d.pkid
JOIN typeclass tc ON d.tkclass = tc.enum
WHERE dnm.fknumplan = np.pkid AND tc.name = 'Route List'
)
ORDER BY rp.name, np.dnorpattern;
```
A hunt pilot that doesn't route to a route list is functionally dead —
calls match the pilot pattern but have nowhere to go. Often vestigial
config.
## Findings to call out
### Queue misconfigurations
- **`maxwaittimeinqueue = 0`** without an explicit overflow rationale
callers can wait forever in queue.
- **NULL `maxwaittimedestination` / `noagentdestination` /
`queuefulldestination`** calls drop without going anywhere.
Recommend explicit destinations (typically voicemail).
- **Mismatched CSSs** on the three queue destinations: the CSSs control
whether the destination is reachable. If the CSS is overly restrictive,
the overflow destination might not be reachable from the hunt pilot's
context.
### Line group hygiene
- **Empty line groups** (no members in `linegroupnumplanmap`) calls
would never ring anywhere.
- **Line groups with one member** fine, but check whether a hunt
pilot is overkill (a direct DN may be simpler).
- **Members in an inactive partition** or pointing to a DN that's
no longer assigned to any phone.
### Distribution algorithm clarity
- **Distribution = Broadcast** for a large group operationally noisy;
confirm with operator whether this is intentional.
- **Distribution = Top Down** with the same first member every time
load concentrates on one phone; operator may want longest-idle.
### Coverage gaps
- **Hunt pilots not pointing to a route list** (Step 5) cleanup.
- **Hunt pilot description missing** operationally opaque; recommend
description annotations.
## Suggested follow-up calls
- `route_inspect_pattern(<pilot>, <partition>)` for each hunt pilot to
trace its destination chain.
- `route_devices_using_css(<each unique queue-destination CSS>)` to
understand the blast radius of the CSSs the queue uses.
- `axl_describe_table('huntpilotqueue')` if the audit needs additional
queue-config columns (announcement, MoH source, etc.).
## Reference: CUCM data dictionary (hunt pilots, line groups)
""" + schema_block + """
Run the queries above and produce a structured findings report. Group
by hunt pilot; under each, list its queue config, line group(s), and
distribution algorithm; then list the audit findings with severity.
"""

View File

@ -0,0 +1,181 @@
"""Inbound DID inventory: XFORM-Inbound-DNIS, screening, executed routing."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"translation pattern", "called party transformation", "DNIS",
"inbound", "route pattern", "transformation mask", "PSTN",
]
def render(docs: "DocsIndex | None") -> str:
"""Inventory of every PSTN DID that *might* be presented to the cluster,
cross-referenced against actual routing destinations.
Pattern: turn the operator-curated XFORM-Inbound-DNIS list into a
"presentable DID inventory" with destination cross-checks. Surfaces
orphan target extensions, undocumented DIDs, and the silent-fallback
catch-all hazard.
"""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
return """# CUCM Inbound DID Audit
The cluster's inbound architecture has multiple transformation layers.
This audit reconstructs the full set of DIDs that "should" be presentable
and cross-checks them against actual routing destinations.
## Conceptual model (verify on this cluster)
Inbound calls usually traverse:
1. PSTN-facing trunk (typically `PSTN-Router-SIP-Trk` or similar)
trunk's primary CSS, e.g. `PSTN-Inbound-CSS`.
2. `PSTN-Inbound-PT` partition with a `!` translation that re-routes to
`PSTN-Screen-CSS` for spam filtering.
3. `PSTN-Screen-PT` with operator-curated `block_enabled=true` translation
patterns for spam blocking, plus a `!` / `\\+!` catch-all that
re-routes via `Internal-CSS`.
4. `Internal-CSS` finds the destination either a 10-digit route pattern
in `Internal-PT` (typically routes to fax trunks) or a directory
number after some upstream transformation.
The XFORM-Inbound-DNIS partition holds 100+ "Called Party Number
Transformation" patterns (`tkpatternusage = 20`) that map external DIDs
to internal extensions. **Whether they fire automatically depends on
the trunk's `fkcallingsearchspace_cntdpntransform` setting** — confirm
this with the operator or via:
```sql
SELECT d.name, sd.fkcallingsearchspace_cntdpntransform
FROM device d JOIN sipdevice sd ON sd.fkdevice = d.pkid
JOIN typeclass tc ON d.tkclass = tc.enum
WHERE tc.name = 'Trunk';
```
## Step 1 — Operator-curated DID inventory (XFORM-Inbound-DNIS)
```sql
SELECT
np.dnorpattern AS inbound_did,
np.calledpartytransformationmask AS xform_to,
np.prefixdigitsout AS prefix_out,
np.description
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE rp.name = 'XFORM-Inbound-DNIS'
ORDER BY np.dnorpattern;
```
Categorize the result:
- **Pass-through 10-digit** (`xform_to IS NULL`, description "remain as 10-digit"):
the DID is expected to match a corresponding route pattern in `Internal-PT`.
- **Block-translation to 4-digit** (`xform_to LIKE '7XXX'` or similar):
rewrites last N digits to an internal extension range.
- **Specific renames** (literal `xform_to` like `7400`, `2523`):
one-off DID-to-extension mappings, often legacy clinic numbers.
- **Wildcard ranges** (pattern contains `[N-M]`, `XXX`, `.`):
efficient block mappings worth reviewing for bounds correctness.
- **Catch-all `!`**: dangerous silent fallback if present flag it.
## Step 2 — Actually-executed routing (Internal-PT route patterns ≥7 digits)
```sql
SELECT np.dnorpattern, np.description, np.blockenable
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE rp.name = 'Internal-PT'
AND np.tkpatternusage = 5 -- Route pattern
AND LENGTH(np.dnorpattern) >= 7
ORDER BY np.dnorpattern;
```
Cross-check:
- Every "remain as 10-digit" DID from Step 1 should have a matching
10-digit route pattern here.
- Every route pattern here should have a known purpose (typically routes
to a fax trunk or hunt list).
## Step 3 — Spam blocklist (operator-curated, audit-relevant)
```sql
SELECT np.dnorpattern, np.description
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE rp.name = 'PSTN-Screen-PT' AND np.blockenable = 't'
ORDER BY np.dnorpattern;
```
Spam blocklists rotate every 30-60 days as campaigns shift. This is
informational, but persistently-stale entries (descriptions like
"Number spamming X's phone" from 1+ year ago) can be retired.
## Step 4 — Verify target extensions exist
For each unique target extension referenced in Step 1's renames, confirm
it actually exists as a real DN:
```sql
-- Replace '7400, 2523, 2532, ...' with the unique targets from Step 1
SELECT FIRST 50 np.dnorpattern, np.description, rp.name AS partition
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE np.tkpatternusage = 2 -- Directory Number
AND np.dnorpattern IN ('7400', '2523', '2532', '...')
ORDER BY np.dnorpattern;
```
A DID that translates to an extension that doesn't exist is a *broken*
inbound DID calls succeed in the transformation step but fail at the
final DN lookup.
## Findings to call out
- **Orphan target extensions**: any DID that translates to a non-existent
DN. High-severity audit finding caller hears "number not found" or
reorder.
- **`!` catch-all in XFORM-Inbound-DNIS**: silently rewrites unrecognized
DIDs to their last 4 digits. Recommend documenting or removing at
minimum, surface the risk.
- **Old clinic / legacy DIDs**: if multiple DIDs forward to the same
internal extension with descriptions referencing legacy locations,
confirm the target is a hunt pilot or reception line, not a single
user receiving misdirected calls.
- **Block-of-N pass-through declarations** (e.g., `20878594XX` declares
100 DIDs): verify carrier allocation matches the block size. Excess
declarations are harmless but noisy.
- **Spam blocklist hygiene**: > 6-month-old entries that are no longer
active campaigns are cleanup candidates.
- **Stale block_enabled outbound DIDs** (`Internal-PT` route patterns
with `blockenable = 't'`): confirm intentional. Single oddballs among
many similar route patterns warrant a description annotation.
## Suggested follow-up calls
- `route_inspect_pattern(<DID>)` for any specific DID where the audit
needs the full route trace (CSS reachability, destination chain).
- `axl_sql("SELECT * FROM numplan WHERE dnorpattern = '<target ext>'")`
to verify each rewrite target exists.
- `route_devices_using_css('PSTN-Inbound-CSS')` to confirm the inbound
trunk(s) using this CSS should typically be just 1.
## Reference: CUCM data dictionary (translation patterns)
""" + schema_block + """
Run the queries above and produce a structured findings report. Group
by DID handling category (Step 1 categorization), then list orphan-
target findings and other hygiene issues. Don't enumerate all 100+
DIDs group by exchange (208-XXX-) and call out exceptions.
"""

View File

@ -0,0 +1,197 @@
"""Phone inventory + audit findings: device pools, CSSs, owners, anomalies."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"phone", "device", "device pool", "calling search space",
"model", "owner", "extension mobility",
]
def _esc(s: str) -> str:
return s.replace("'", "''")
def render(docs: "DocsIndex | None", filter: str | None = None) -> str:
"""Phone inventory: counts by class, distribution by pool/CSS/model,
plus findings around orphans, drift, and naming anomalies.
Args:
filter: Optional substring (case-sensitive LIKE on `device.name`
OR `device.description`) to narrow the inventory.
"""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
if filter:
safe = _esc(filter)
name_clause = f"\n AND (d.name LIKE '%{safe}%' OR d.description LIKE '%{safe}%')"
scope_note = f"narrowed to phones matching `%{filter}%`"
else:
name_clause = ""
scope_note = "all phones"
return f"""# CUCM Phone Inventory — {scope_note}
Produce a phone inventory with audit-relevant groupings and findings.
The cluster typically has hundreds-to-thousands of phones; this prompt
asks for *aggregations and anomalies*, not a flat dump.
## Step 1 — Aggregate counts (always run first)
```sql
SELECT tm.name AS model, COUNT(*) AS phone_count
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN typemodel tm ON d.tkmodel = tm.enum
WHERE tc.name = 'Phone'{name_clause}
GROUP BY tm.name ORDER BY 2 DESC;
```
```sql
SELECT dp.name AS device_pool, COUNT(*) AS phone_count
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause}
GROUP BY dp.name ORDER BY 2 DESC;
```
```sql
SELECT css.name AS css_name, COUNT(*) AS phone_count
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid
WHERE tc.name = 'Phone'{name_clause}
GROUP BY css.name ORDER BY 2 DESC;
```
These three give the shape of the fleet. The CSS one in particular is
audit-critical: most phones should land in a small number of CSSs
(typically `National-CSS` for outbound dialing on this kind of cluster).
*Outliers* in the CSS distribution warrant individual inspection.
## Step 2 — Anomaly queries (the audit-actionable findings)
### Phones with no description or auto-generated descriptions
```sql
SELECT FIRST 50 d.name, d.description, dp.name AS device_pool
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause}
AND (d.description IS NULL
OR d.description = ''
OR d.description = d.name
OR d.description LIKE 'AN%' || SUBSTRING(d.name FROM 3) || '%')
ORDER BY d.name;
```
(Phones whose description matches or echoes their MAC address /
auto-generated name. Hospital deployments accumulate these as patient-
room or mobile carts that were quickly added without operator notes.)
### Phones with no associated owner
```sql
SELECT FIRST 50 d.name, d.description, dp.name AS device_pool
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause}
AND d.fkenduser IS NULL
ORDER BY d.name;
```
(Some unassigned phones are intentional common areas, guest phones,
patient rooms. Operator should verify that the count matches expected
shared-use endpoints.)
### Phones in non-default CSS (where "default" means most-common)
After Step 1's CSS distribution query, identify the most common CSS,
then list phones NOT in it:
```sql
SELECT FIRST 50 d.name, d.description, css.name AS css_name
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid
WHERE tc.name = 'Phone'{name_clause}
AND (css.name != '<MOST_COMMON_CSS>' OR css.name IS NULL)
ORDER BY css.name, d.name;
```
(Replace `<MOST_COMMON_CSS>` with the result from Step 1.)
### Phones with no primary CSS (NULL)
```sql
SELECT d.name, d.description, dp.name AS device_pool
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause} AND d.fkcallingsearchspace IS NULL
ORDER BY d.name;
```
(NULL CSS means the phone inherits from device pool. Usually fine, but
worth confirming the count matches expectations a sudden increase
indicates config drift.)
## Step 3 — Cross-reference with users (if owners exist)
For phones with an owner, list owner identities to spot stale
assignments (employee left, phone still tagged to them):
```sql
SELECT FIRST 50 d.name, d.description, eu.userid, eu.lastname, eu.status
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
JOIN enduser eu ON d.fkenduser = eu.pkid
WHERE tc.name = 'Phone'{name_clause}
ORDER BY d.name;
```
`enduser.status` of `0` = inactive; `1` = active. A phone owned by an
inactive user is a finding.
## Findings to call out
- **Naming hygiene gaps**: phones with auto-generated or empty descriptions.
Recommend: operational follow-up to add room/owner/purpose notes.
- **CSS sprawl**: if more than ~3-5 CSSs are in regular use, justify each.
Phones in single-purpose CSSs (e.g., `911CER-CSS` with only 9 devices)
usually have specific reasons document them.
- **Orphan owners**: phones owned by inactive users. Recommend reassignment
or unassignment.
- **Model heterogeneity**: a wide spread of phone models indicates ongoing
refresh or drift. Worth knowing for firmware/upgrade planning.
- **Devicepool concentration**: if 99%+ of phones are in one device pool,
that's the pool that matters for any DP-related change. Flag it as a
high-blast-radius asset.
## Suggested follow-up calls
- `route_devices_using_css(css_name=<each CSS from Step 1>)` to map the
full impact of changing any phone-attached CSS.
- `axl_describe_table('device')` if the LLM needs additional columns
(firmware load, MAC, security profile, etc.).
- `axl_sql("SELECT name FROM typemodel WHERE enum IN (...)")` to decode
any unfamiliar model codes.
## Reference: CUCM data dictionary (devices)
{schema_block}
Run the queries above and produce a structured inventory report:
counts table, distribution tables, anomaly findings with severity, and
a recommendations section. Don't enumerate every phone — focus on
aggregates and exceptions.
"""

View File

@ -0,0 +1,204 @@
"""End user + application user audit: roles, group memberships, security."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"end user", "application user", "directory group", "function role",
"role", "permission", "authentication", "ldap",
]
# Focus → rationale + which audit checklist sections to emphasize
_FOCUSES = {
"full",
"admin", # users with admin/serviceability roles
"inactive", # users with status != active
"app_users", # service accounts
}
def render(docs: "DocsIndex | None", focus: str = "full") -> str:
"""User audit: end users + application users, role assignments,
security-relevant findings.
Args:
focus: One of "full", "admin", "inactive", "app_users". Tunes
which checklist sections the report emphasizes; all queries
still run for context.
"""
if focus not in _FOCUSES:
focus = "full"
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
return f"""# CUCM User Audit — focus: `{focus}`
End users (`enduser`), application users (`applicationuser`), and their
role assignments via the dirgroup/functionrole join chain. Security and
operational hygiene findings.
## Step 1 — Headcounts
End users grouped by status:
```sql
SELECT status, COUNT(*) AS user_count FROM enduser GROUP BY status;
```
Application users (no status column they're configuration objects, not
human accounts):
```sql
SELECT COUNT(*) AS app_user_count FROM applicationuser;
```
`enduser.status`: `1` = active, `0` = inactive.
(Informix's `UNION ALL` rejects mixed-type columns even with `CAST`,
so two separate queries is the simplest portable form.)
## Step 2 — End user inventory
```sql
SELECT FIRST 100
eu.userid,
eu.firstname,
eu.lastname,
eu.displayname,
eu.status,
eu.userrank,
eu.islocaluser
FROM enduser eu
ORDER BY eu.lastname, eu.firstname;
```
`islocaluser` = `t` means CCM-local, `f` means LDAP-synced. Mixing both is
common (admins local, hospital staff LDAP-synced).
`userrank` is a 1-5 integer that gates access to features by rank;
elevated ranks (4-5) usually correspond to admin populations.
## Step 3 — Application users (service accounts)
```sql
SELECT au.name, au.userrank, au.isstandard
FROM applicationuser au
ORDER BY au.userrank DESC, au.name;
```
`isstandard = 't'` is a built-in account (Cisco-shipped); `f` is operator-
created. Operator-created application users with high userrank are the
audit-critical population (these are typically API-access service accounts).
## Step 4 — Role assignments
End users / app users dirgroups function roles:
```sql
-- End users with their group memberships
SELECT FIRST 200
eu.userid,
eu.lastname,
dg.name AS dirgroup_name
FROM enduser eu
JOIN enduserdirgroupmap eudgm ON eudgm.fkenduser = eu.pkid
JOIN dirgroup dg ON eudgm.fkdirgroup = dg.pkid
WHERE dg.isstandard = 'f' OR dg.name LIKE '%Standard CCM%Admin%' OR dg.name LIKE '%Super%'
ORDER BY eu.userid, dg.name;
```
```sql
-- App users with their group memberships (always audit-relevant)
SELECT au.name AS app_user, dg.name AS dirgroup_name
FROM applicationuser au
JOIN applicationuserdirgroupmap audgm ON audgm.fkapplicationuser = au.pkid
JOIN dirgroup dg ON audgm.fkdirgroup = dg.pkid
ORDER BY au.name, dg.name;
```
```sql
-- Dirgroups function roles (the actual permissions)
SELECT dg.name AS group_name, fr.name AS role_name, fr.description
FROM dirgroup dg
JOIN functionroledirgroupmap frdgm ON frdgm.fkdirgroup = dg.pkid
JOIN functionrole fr ON frdgm.fkfunctionrole = fr.pkid
WHERE dg.isstandard = 'f' OR dg.name LIKE '%Admin%' OR dg.name LIKE '%Super%'
ORDER BY dg.name, fr.name;
```
The audit question is: who has admin-grade role membership, and is each
assignment justified?
## Step 5 — Phone owners (cross-reference)
Phones tagged to inactive users:
```sql
SELECT FIRST 50
d.name AS phone_name,
d.description,
eu.userid AS owner_userid,
eu.lastname,
eu.status AS owner_status
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
JOIN enduser eu ON d.fkenduser = eu.pkid
WHERE tc.name = 'Phone' AND eu.status = '0'
ORDER BY eu.lastname, d.name;
```
A phone owned by an inactive user (departed employee, expired account)
is config drift either reassign or unassign.
## Findings to call out
### Security-critical
- **Application users with admin/superuser group membership**: each one
is an API key with elevated privileges. Document why each exists.
- **End users with `Standard CCM Super Users` or similar privileged
group memberships**: these are the people who can write-modify the
cluster. Confirm the population matches the documented admin team.
- **`islocaluser = 't'` accounts with admin privileges**: bypass LDAP/SSO,
may have static passwords. High-priority security review.
- **Application users with default Cisco-shipped passwords**: these don't
show in the schema (no plaintext access), but the existence of standard
app users (e.g., `CCMAdministrator`, `CCMSysUser`) with operator-set
passwords is worth a separate check via the GUI or a pen test.
### Operational hygiene
- **Inactive users (status = 0) still in groups**: cleanup candidates.
- **Phones owned by inactive users**: reassign or unassign.
- **Users with no group membership**: likely just have basic phone
access; not a finding by itself but worth confirming if a sudden
population appears.
### Drift indicators
- **End users created locally vs LDAP-synced ratio**: if local-user count
has grown over time, indicates either an LDAP sync failure or
out-of-band account creation.
- **Operator-created dirgroups (`isstandard = 'f'`)**: list and verify each
has a documented purpose.
## Suggested follow-up calls
- For each app user with API/admin access, run
`axl_sql("SELECT * FROM applicationuser WHERE name = '<name>'")` to
inspect ACL flags (`acl*` columns).
- `axl_describe_table('enduser')` for additional fields (manager,
department, etc. if LDAP populates them).
- `axl_sql("SELECT name, description FROM dirgroup ORDER BY isstandard, name")`
to see all groups including standard Cisco-shipped ones.
## Reference: CUCM data dictionary (users + roles)
{schema_block}
Run the queries above and produce a structured findings report. The
focus parameter (`{focus}`) means: emphasize the corresponding section
in the writeup, but include all sections for context.
"""

View File

@ -385,6 +385,46 @@ def sip_trunk_report(name_filter: str | None = None) -> str:
return _prompts.sip_trunk_report.render(_docs, name_filter) return _prompts.sip_trunk_report.render(_docs, name_filter)
@mcp.prompt
def phone_inventory_report(filter: str | None = None) -> str:
"""Phone fleet audit: counts by model/pool/CSS, anomaly findings,
orphan-owner cross-check.
Args:
filter: Optional substring (matches device name OR description) to
narrow the inventory. Omit to include all phones.
"""
return _prompts.phone_inventory_report.render(_docs, filter)
@mcp.prompt
def user_audit(focus: str = "full") -> str:
"""End user + application user audit: roles, group memberships,
security-relevant findings.
Args:
focus: One of "full", "admin", "inactive", "app_users". Tunes
which checklist sections the report emphasizes; all queries
still run for context.
"""
return _prompts.user_audit.render(_docs, focus)
@mcp.prompt
def inbound_did_audit() -> str:
"""Inbound DID inventory: XFORM-Inbound-DNIS curated list, executed
routing in Internal-PT, spam blocklist, orphan-target cross-check."""
return _prompts.inbound_did_audit.render(_docs)
@mcp.prompt
def hunt_pilot_audit() -> str:
"""Hunt pilot audit: queue settings, distribution algorithms, line
group membership, dead-pilot detection. Schema-aware (uses
huntpilotqueue.fknumplan_pilot, the verified column name)."""
return _prompts.hunt_pilot_audit.render(_docs)
# ==================================================================== # ====================================================================
# Bootstrap # Bootstrap
# ==================================================================== # ====================================================================

View File

@ -164,4 +164,80 @@ def test_all_prompts_registered_in_server():
"audit_routing", "audit_routing",
"cucm_sql_help", "cucm_sql_help",
"sip_trunk_report", "sip_trunk_report",
"phone_inventory_report",
"user_audit",
"inbound_did_audit",
"hunt_pilot_audit",
}, f"unexpected prompt set: {names}" }, f"unexpected prompt set: {names}"
# ---- new prompts: render-with-and-without-docs smoke tests -----------------
def test_phone_inventory_report_renders(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs)
assert "Phone Inventory" in text
# Embedded SQL should query the `device` table with tkclass='Phone'
assert "tc.name = 'Phone'" in text
assert "all phones" in text
def test_phone_inventory_report_with_filter(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs, "Lobby")
assert "%Lobby%" in text
assert "narrowed" in text
def test_phone_inventory_report_filter_escaped(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs, "O'Hara")
assert "O''Hara" in text # SQL injection defense
def test_user_audit_default_focus(fake_docs):
text = prompts.user_audit.render(fake_docs)
assert "focus: `full`" in text
assert "applicationuser" in text
assert "enduser" in text
def test_user_audit_admin_focus(fake_docs):
text = prompts.user_audit.render(fake_docs, "admin")
assert "focus: `admin`" in text
def test_user_audit_unknown_focus_falls_back_to_full(fake_docs):
text = prompts.user_audit.render(fake_docs, "bogus_value")
assert "focus: `full`" in text
def test_inbound_did_audit_renders(fake_docs):
text = prompts.inbound_did_audit.render(fake_docs)
assert "Inbound DID Audit" in text
assert "XFORM-Inbound-DNIS" in text
assert "PSTN-Screen-PT" in text
def test_hunt_pilot_audit_uses_correct_column(fake_docs):
"""Hamilton bonus finding: huntpilotqueue joins via fknumplan_pilot,
NOT fknumplan. The prompt's embedded SQL must use the correct column
or every audit run will silently fail one category."""
text = prompts.hunt_pilot_audit.render(fake_docs)
assert "fknumplan_pilot" in text, (
"hunt_pilot_audit must use the verified column name `fknumplan_pilot`; "
"`fknumplan` was the silent-failure version we fixed in Hamilton review"
)
assert "tkpatternusage = 7" in text # Hunt Pilot type code
def test_all_new_prompts_render_without_docs():
"""Graceful degradation: each prompt produces usable output even when
the docs index isn't loaded."""
for name, fn, args in [
("phone_inventory_report", prompts.phone_inventory_report.render, ()),
("user_audit", prompts.user_audit.render, ()),
("inbound_did_audit", prompts.inbound_did_audit.render, ()),
("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()),
]:
text = fn(None, *args)
assert "cisco-docs index is not loaded" in text, (
f"{name} failed graceful degradation"
)