cli: expand 'show nacm [user NAME | group NAME]'

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-01-23 06:12:24 +01:00
parent 353cbdc9eb
commit e0ee26a6cb
4 changed files with 504 additions and 16 deletions
+421 -7
View File
@@ -2906,6 +2906,198 @@ def show_ntp_source(json, address=None):
print(row)
def _analyze_group_permissions(nacm):
"""Analyze NACM rules to determine effective permissions per group.
NACM rule evaluation order:
1. Rule-lists are processed sequentially in configuration order
2. Within each rule-list, rules are evaluated sequentially
3. First matching rule wins - no further rules are evaluated
4. If no rule matches, global defaults apply
5. permit-all (module-name=*, access-operations=*) bypasses everything
"""
read_default = nacm.get('read-default', 'permit') == 'permit'
write_default = nacm.get('write-default', 'permit') == 'permit'
exec_default = nacm.get('exec-default', 'permit') == 'permit'
rule_lists = nacm.get('rule-list', [])
groups_config = nacm.get('groups', {}).get('group', [])
# Collect all deny rules that apply to "*" (all groups)
# These create restrictions for groups without permit-all
global_denials = []
for rule_list in rule_lists:
if '*' in rule_list.get('group', []):
for rule in rule_list.get('rule', []):
if rule.get('action') == 'deny':
global_denials.append(rule)
results = []
for group in groups_config:
group_name = group.get('name', '')
# Defaults
can_read = read_default
can_write = write_default
can_exec = exec_default
restrictions = []
has_permit_all = False
# Process rule-lists in order (first match wins)
for rule_list in rule_lists:
list_groups = rule_list.get('group', [])
# Check if this rule-list applies to this group specifically
if group_name not in list_groups:
continue
for rule in rule_list.get('rule', []):
action = rule.get('action')
access_ops = rule.get('access-operations', '')
module_name = rule.get('module-name', '')
# permit-all pattern: bypasses ALL other rules including global denials
if action == 'permit' and module_name == '*' and access_ops == '*':
has_permit_all = True
break
# deny pattern for write+exec (like guest)
if action == 'deny' and module_name == '*':
ops = access_ops.lower()
if all(op in ops for op in ['create', 'update', 'delete']):
can_write = False
if 'exec' in ops:
can_exec = False
if has_permit_all:
break
# If not permit-all, global denials create restrictions
# These affect READ too (access-operations: "*" includes read)
if not has_permit_all:
for rule in global_denials:
path = rule.get('path', '')
module = rule.get('module-name', '')
if path:
# Extract meaningful part: /ietf-system:system/.../password -> password
restriction = path.rstrip('/').split('/')[-1]
elif module:
# Remove ietf- prefix for brevity: ietf-keystore -> keystore
restriction = module.replace('ietf-', '')
else:
continue
if restriction and restriction not in restrictions:
restrictions.append(restriction)
results.append({
'group': group_name,
'read': can_read,
'write': can_write,
'exec': can_exec,
'restrictions': restrictions,
'has_permit_all': has_permit_all
})
return results
def _nacm_permissions_matrix(nacm, width=None):
"""Render NACM group permissions as a colored matrix.
Symbols: ✓=full access, ⚠=restricted, ✗=denied
Returns:
Multi-line string containing the rendered matrix, or None if no groups
"""
permissions = _analyze_group_permissions(nacm)
if not permissions:
return None
# Column widths
group_col_width = max(len(p['group']) for p in permissions)
group_col_width = max(group_col_width, 5) # Minimum for "GROUP"
cell_width = 7 # Width for READ/WRITE/EXEC columns
# Box drawing
top_border = "" + "" * (group_col_width + 2) + ""
top_border += "" * (cell_width + 2) + ""
top_border += "" * (cell_width + 2) + ""
top_border += "" * (cell_width + 2) + ""
header_border = "" + "" * (group_col_width + 2) + ""
header_border += "" * (cell_width + 2) + ""
header_border += "" * (cell_width + 2) + ""
header_border += "" * (cell_width + 2) + ""
bottom_border = "" + "" * (group_col_width + 2) + ""
bottom_border += "" * (cell_width + 2) + ""
bottom_border += "" * (cell_width + 2) + ""
bottom_border += "" * (cell_width + 2) + ""
# Header row
header = f"{'GROUP':<{group_col_width}}"
header += f" {'READ':^{cell_width}}"
header += f" {'WRITE':^{cell_width}}"
header += f" {'EXEC':^{cell_width}}"
# Calculate centering
matrix_width = len(top_border)
if width and width > matrix_width:
padding = (width - matrix_width) // 2
indent = " " * padding
else:
indent = ""
lines = []
lines.append(indent + top_border)
lines.append(indent + header)
lines.append(indent + header_border)
# Data rows
for perm in permissions:
group_name = perm['group']
has_restrictions = bool(perm['restrictions'])
# Determine symbols and colors for each cell
def get_cell(has_access, has_restrictions):
if not has_access:
return Decore.red_bg(f" {'':^{cell_width}} ")
elif has_restrictions:
return Decore.yellow_bg(f" {'':^{cell_width}} ")
else:
return Decore.green_bg(f" {'':^{cell_width}} ")
read_cell = get_cell(perm['read'], has_restrictions)
write_cell = get_cell(perm['write'], has_restrictions)
exec_cell = get_cell(perm['exec'], has_restrictions)
row = f"{group_name:<{group_col_width}}{read_cell}{write_cell}{exec_cell}"
lines.append(indent + row)
lines.append(indent + bottom_border)
# Legend
legend_data = [
("✓ Full", Decore.green_bg),
("⚠ Restricted", Decore.yellow_bg),
("✗ Denied", Decore.red_bg)
]
colorized_parts = [bg_func(f" {text} ") for text, bg_func in legend_data]
legend = " ".join(colorized_parts)
if width:
visible_legend = " ".join([f" {text} " for text, _ in legend_data])
legend_padding = max(0, (width - len(visible_legend)) // 2)
lines.append(" " * legend_padding + legend)
else:
lines.append(indent + legend)
return "\n".join(lines)
def show_nacm(json):
"""Display users and NACM (Network Configuration Access Control) groups"""
min_width = 62
@@ -2917,15 +3109,23 @@ def show_nacm(json):
if nacm:
enabled = "yes" if nacm.get("enable-nacm", True) else "no"
print(f"{'enabled':<25}: {enabled}")
print(f"{'default read access':<25}: {nacm.get('read-default', 'permit')}")
print(f"{'default write access':<25}: {nacm.get('write-default', 'deny')}")
print(f"{'default exec access':<25}: {nacm.get('exec-default', 'permit')}")
print(f"{'denied operations':<25}: {nacm.get('denied-operations', 0)}")
print(f"{'denied data writes':<25}: {nacm.get('denied-data-writes', 0)}")
print(f"{'denied notifications':<25}: {nacm.get('denied-notifications', 0)}")
print(f"{'enabled':<21}: {enabled}")
print(f"{'default read access':<21}: {nacm.get('read-default', 'permit')}")
print(f"{'default write access':<21}: {nacm.get('write-default', 'deny')}")
print(f"{'default exec access':<21}: {nacm.get('exec-default', 'permit')}")
print(f"{'denied operations':<21}: {nacm.get('denied-operations', 0)}")
print(f"{'denied data writes':<21}: {nacm.get('denied-data-writes', 0)}")
print(f"{'denied notifications':<21}: {nacm.get('denied-notifications', 0)}")
print()
# Group permissions matrix
if nacm:
matrix = _nacm_permissions_matrix(nacm, min_width)
if matrix:
print(matrix)
print()
# Users table
system = json.get("ietf-system:system", {})
users = system.get("authentication", {}).get("user", [])
@@ -2971,6 +3171,214 @@ def show_nacm(json):
group_table.row(name, members)
group_table.print()
print()
def show_nacm_group(json):
"""Display detailed information about a specific NACM group."""
group_name = json.get('_group_name')
nacm = json.get("ietf-netconf-acm:nacm", {})
if not nacm:
print("NACM not configured.")
return
# Find the group
groups = nacm.get("groups", {}).get("group", [])
group = None
for g in groups:
if g.get("name") == group_name:
group = g
break
if not group:
print(f"Group '{group_name}' not found.")
return
# Group info
members = group.get("user-name", [])
members_str = ", ".join(members) if members else "(none)"
print(f"{'members':<17}: {members_str}")
# Analyze permissions
permissions = _analyze_group_permissions(nacm)
group_perm = None
for p in permissions:
if p['group'] == group_name:
group_perm = p
break
if group_perm:
def perm_str(has_access, has_restrictions):
if not has_access:
return Decore.red("no")
elif has_restrictions:
return Decore.yellow("restricted")
else:
return Decore.green("yes")
has_restrictions = bool(group_perm['restrictions'])
print(f"{'read permission':<17}: {perm_str(group_perm['read'], has_restrictions)}")
print(f"{'write permission':<17}: {perm_str(group_perm['write'], has_restrictions)}")
print(f"{'exec permission':<17}: {perm_str(group_perm['exec'], has_restrictions)}")
# Find applicable rules, respecting NACM evaluation order
# Rule-lists are evaluated in order; first matching rule wins
# If a permit-all rule is found, no further rules would be evaluated
rule_lists = nacm.get("rule-list", [])
applicable_rules = []
has_permit_all = group_perm.get('has_permit_all', False) if group_perm else False
for rule_list in rule_lists:
list_groups = rule_list.get("group", [])
# Check if this rule-list applies to this group specifically (not via "*")
if group_name in list_groups:
list_name = rule_list.get("name", "")
for rule in rule_list.get("rule", []):
applicable_rules.append({
'list': list_name,
'rule': rule,
'applies_via': group_name
})
# Check if this is a permit-all rule
action = rule.get('action')
module_name = rule.get('module-name', '')
access_ops = rule.get('access-operations', '')
if action == 'permit' and module_name == '*' and access_ops == '*':
# permit-all matches everything, stop here
break
if has_permit_all:
# Don't process any more rule-lists
break
# Only add rules from "*" group if we don't have permit-all
if not has_permit_all:
for rule_list in rule_lists:
list_groups = rule_list.get("group", [])
if "*" in list_groups and group_name not in list_groups:
list_name = rule_list.get("name", "")
for rule in rule_list.get("rule", []):
applicable_rules.append({
'list': list_name,
'rule': rule,
'applies_via': "*"
})
if applicable_rules:
print(f"{'applicable rules':<17}: {len(applicable_rules)}")
for item in applicable_rules:
rule = item['rule']
rule_name = rule.get('name', '(unnamed)')
action = rule.get('action', 'permit')
access_ops = rule.get('access-operations', '*')
module_name = rule.get('module-name', '')
path = rule.get('path', '')
rpc_name = rule.get('rpc-name', '')
comment = rule.get('comment', '')
# Format action with color
if action == 'permit':
action_str = Decore.green("permit")
else:
action_str = Decore.red("deny")
# Build target description
target = ""
if path:
target = path
elif module_name:
target = module_name
if rpc_name:
target += f" (rpc: {rpc_name})"
applies_note = f" (via '*')" if item['applies_via'] == '*' else ""
Decore.title(f"{rule_name}{applies_note}", width=70)
print(f"{' action':<13}: {action_str}")
print(f"{' operations':<13}: {access_ops}")
if target:
print(f"{' target':<13}: {target}")
if comment:
print(f"{' comment':<13}: {comment}")
print()
def show_nacm_user(json):
"""Display detailed information about a specific user's NACM permissions."""
user_name = json.get('_user_name')
nacm = json.get("ietf-netconf-acm:nacm", {})
system = json.get("ietf-system:system", {})
# Find the user
users = system.get("authentication", {}).get("user", [])
user = None
for u in users:
if u.get("name") == user_name:
user = u
break
if not user:
print(f"User '{user_name}' not found.")
return
# User details
shell_data = user.get("infix-system:shell", "false")
shell = shell_data.split(":")[-1] if ":" in shell_data else shell_data
print(f"{'shell':<17}: {shell}")
has_password = bool(user.get("password"))
has_keys = bool(user.get("authorized-key"))
if has_password and has_keys:
login = "password + SSH key"
elif has_password:
login = "password"
elif has_keys:
login = "SSH key"
else:
login = "(none configured)"
print(f"{'login':<17}: {login}")
# Find which NACM groups this user belongs to
if not nacm:
print("NACM not configured.")
return
groups = nacm.get("groups", {}).get("group", [])
user_groups = []
for group in groups:
if user_name in group.get("user-name", []):
user_groups.append(group.get("name"))
groups_str = ", ".join(user_groups) if user_groups else "(none)"
print(f"{'nacm group':<17}: {groups_str}")
# Show effective permissions for each group
if user_groups:
permissions = _analyze_group_permissions(nacm)
for group_name in user_groups:
for p in permissions:
if p['group'] == group_name:
def perm_str(has_access, has_restrictions):
if not has_access:
return Decore.red("no")
elif has_restrictions:
return Decore.yellow("restricted")
else:
return Decore.green("yes")
has_restrictions = bool(p['restrictions'])
print(f"{'read permission':<17}: {perm_str(p['read'], has_restrictions)}")
print(f"{'write permission':<17}: {perm_str(p['write'], has_restrictions)}")
print(f"{'exec permission':<17}: {perm_str(p['exec'], has_restrictions)}")
break
print()
print("For detailed rules, use: show nacm group <name>")
def show_system(json):
@@ -4958,6 +5366,8 @@ def main():
.add_argument('limit', nargs='?', help='Last N lines, default: all')
subparsers.add_parser('show-nacm', help='Show NACM status and groups')
subparsers.add_parser('show-nacm-group', help='Show NACM group details')
subparsers.add_parser('show-nacm-user', help='Show NACM user details')
subparsers.add_parser('show-ntp', help='Show NTP status') \
.add_argument('-a', '--address', help='Show details for specific address')
@@ -5024,6 +5434,10 @@ def main():
show_firewall_logs(args.limit)
elif args.command == "show-nacm":
show_nacm(json_data)
elif args.command == "show-nacm-group":
show_nacm_group(json_data)
elif args.command == "show-nacm-user":
show_nacm_user(json_data)
elif args.command == "show-ntp":
show_ntp(json_data, args.address)
elif args.command == "show-ntp-tracking":