Files
2026-01-23 06:12:26 +01:00

258 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Verify basic NACM permission enforcement
Test that NACM groups (admin, operator, guest) correctly enforce
access control with permissive defaults and targeted denials.
The NACM design is "permit by default, deny sensitive items":
- admin: Full unrestricted access (permit-all rule)
- operator: Can configure everything EXCEPT passwords, keystore, truststore
- guest: Read-only access (explicit deny of create/update/delete/exec)
Verifies that:
- Operators can read and modify most configuration (hostname, interfaces)
- Operators CANNOT read or write password hashes (protected path)
- Guests can read but cannot modify any configuration
- Admin can access everything including passwords
"""
import infamy
OPERATOR_USER = "oper"
OPERATOR_PASS = "oper123"
GUEST_USER = "guest"
GUEST_PASS = "guest123"
with infamy.Test() as test:
with test.step("Set up topology and attach to target"):
env = infamy.Env()
target = env.attach("target", "mgmt")
with test.step("Configure NACM groups, rules, and test users"):
operator_hash = "$1$gwU5mRgP$1/ASdRwD5ycqdmWpTKHSa0"
guest_hash = "$1$4rdUOhNN$vw3i4FyPvIkzRFwrUXQod1"
target.put_config_dicts({
"ietf-system": {
"system": {
"authentication": {
"user": [{
"name": OPERATOR_USER,
"password": operator_hash,
"shell": "infix-system:bash"
}, {
"name": GUEST_USER,
"password": guest_hash,
"shell": "infix-system:bash"
}]
}
}
},
"ietf-netconf-acm": {
"nacm": {
"enable-nacm": True,
"read-default": "permit",
"write-default": "permit",
"exec-default": "permit",
"groups": {
"group": [{
"name": "admin",
"user-name": ["admin"]
}, {
"name": "operator",
"user-name": [OPERATOR_USER]
}, {
"name": "guest",
"user-name": [GUEST_USER]
}]
},
"rule-list": [{
"name": "admin-acl",
"group": ["admin"],
"rule": [{
"name": "permit-all",
"module-name": "*",
"access-operations": "*",
"action": "permit",
"comment": "Admin has full access"
}]
}, {
"name": "operator-acl",
"group": ["operator"],
"rule": [{
"name": "permit-system-rpcs",
"module-name": "ietf-system",
"access-operations": "exec",
"action": "permit",
"comment": "Operators can reboot, shutdown, and set system time"
}]
}, {
"name": "guest-acl",
"group": ["guest"],
"rule": [{
"name": "deny-all-exec",
"module-name": "*",
"access-operations": "create update delete exec",
"action": "deny",
"comment": "Guests cannot change anything or call rpcs"
}]
}, {
"name": "default-deny-all",
"group": ["*"],
"rule": [{
"name": "deny-password-access",
"path": "/ietf-system:system/authentication/user/password",
"access-operations": "*",
"action": "deny",
"comment": "No user except admins can access password hashes."
}, {
"name": "deny-keystore-access",
"module-name": "ietf-keystore",
"access-operations": "*",
"action": "deny",
"comment": "No user except admins can access cryptographic keys."
}, {
"name": "deny-truststore-access",
"module-name": "ietf-truststore",
"access-operations": "*",
"action": "deny",
"comment": "No user except admins can access trust store."
}]
}]
}
}
})
with test.step("Verify operator can read configuration"):
# Attach as operator user
operator = env.attach("target", "mgmt", username=OPERATOR_USER,
password=OPERATOR_PASS, test_reset=False)
# Operator should be able to read (read-default: permit)
ifaces = operator.get_config_dict("/ietf-interfaces:interfaces")
num_ifaces = len(ifaces.get('interfaces', {}).get('interface', []))
print(f"Operator successfully read {num_ifaces} interfaces")
with test.step("Verify operator can modify interface configuration"):
# Use patch_config() which PATCHes directly to running datastore
# This avoids full datastore copy that requires broader permissions
operator.patch_config("ietf-interfaces", {
"interfaces": {
"interface": [{
"name": "lo",
"description": "Modified by operator"
}]
}
})
# Verify the change
ifaces = operator.get_config_dict("/ietf-interfaces:interfaces")
lo_iface = None
for iface in ifaces.get('interfaces', {}).get('interface', []):
if iface.get('name') == 'lo':
lo_iface = iface
break
assert lo_iface and lo_iface.get('description') == "Modified by operator", \
"Operator failed to modify interface"
print("Operator successfully modified interface configuration")
with test.step("Verify operator can modify hostname"):
# Operators can now modify most system config (write-default: permit)
operator.patch_config("ietf-system", {
"system": {
"hostname": "operator-test"
}
})
# Verify the change
cfg = operator.get_config_dict("/ietf-system:system")
assert cfg.get("system", {}).get("hostname") == "operator-test", \
"Operator failed to modify hostname"
print("Operator successfully modified hostname")
with test.step("Verify operator cannot read password hashes"):
# Password hashes are protected by deny-password-access rule
cfg = operator.get_config_dict("/ietf-system:system")
users = cfg.get("system", {}).get("authentication", {}).get("user", [])
# Check that no user entry contains a password field
for user in users:
assert "password" not in user, \
f"Operator should NOT be able to read password for user '{user.get('name')}'"
print("Operator correctly denied read access to password hashes")
with test.step("Verify operator cannot write password hashes"):
# Try to change a password - should fail due to NACM deny rule
# Use a valid MD5 hash format to ensure we test NACM, not YANG validation
valid_hash = "$1$testsalt$YvPTBnV5RhkWwXLzR7kK/1"
try:
operator.patch_config("ietf-system", {
"system": {
"authentication": {
"user": [{
"name": OPERATOR_USER,
"password": valid_hash
}]
}
}
}, retries=1)
assert False, "Operator should NOT be able to modify passwords!"
except Exception as e:
error_msg = str(e)
assert any(keyword in error_msg for keyword in ["403", "Forbidden", "denied", "authorization failed"]), \
f"Expected NACM denial, got: {e}"
print("Operator correctly denied write access to password hashes")
with test.step("Verify guest can read configuration"):
# Attach as guest user
guest = env.attach("target", "mgmt", username=GUEST_USER,
password=GUEST_PASS, test_reset=False)
# Guest should be able to read (read-default: permit)
ifaces = guest.get_config_dict("/ietf-interfaces:interfaces")
num_ifaces = len(ifaces.get('interfaces', {}).get('interface', []))
print(f"Guest successfully read {num_ifaces} interfaces")
with test.step("Verify guest cannot modify configuration"):
# Try to modify hostname - should fail (write-default: deny)
# Use patch_config with retries=1 since NACM denials won't succeed on retry
try:
guest.patch_config("ietf-system", {
"system": {
"hostname": "hacked"
}
}, retries=1)
assert False, "Guest should NOT be able to modify configuration!"
except Exception as e:
error_msg = str(e)
assert any(keyword in error_msg for keyword in ["403", "Forbidden", "denied", "authorization failed"]), \
f"Expected NACM denial, got: {e}"
print("Guest correctly denied write access")
with test.step("Verify admin can access passwords"):
# Admin should have full access including protected paths
cfg = target.get_config_dict("/ietf-system:system")
users = cfg.get("system", {}).get("authentication", {}).get("user", [])
# Admin should be able to see password hashes
has_password = any("password" in user for user in users)
assert has_password, "Admin should be able to read password hashes"
print("Admin successfully read password hashes")
# Admin can also modify passwords
target.put_config_dicts({
"ietf-system": {
"system": {
"hostname": "admin-test"
}
}
})
cfg = target.get_config_dict("/ietf-system:system")
assert cfg.get("system", {}).get("hostname") == "admin-test", \
"Admin hostname change not applied"
print("Admin successfully modified hostname")
test.succeed()