mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-26 18:53:01 +02:00
258 lines
11 KiB
Python
Executable File
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()
|