From f0d7e03fd7548305c70638007f5bfc73829a9154 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 7 Aug 2023 10:55:29 +0100 Subject: [PATCH] Add docs and tests --- docs/source/simulation_structure.rst | 52 +++++++++++++++++++ src/primaite/simulator/domain/account.py | 18 ++++--- src/primaite/simulator/domain/controller.py | 33 ++++++++++-- .../test_permission_system.py | 51 ++++++++++++------ .../_primaite/_simulator/_domain/__init__.py | 0 .../_simulator/_domain/test_account.py | 18 +++++++ .../_simulator/_domain/test_controller.py | 0 7 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_domain/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_domain/test_account.py create mode 100644 tests/unit_tests/_primaite/_simulator/_domain/test_controller.py diff --git a/docs/source/simulation_structure.rst b/docs/source/simulation_structure.rst index 65373a72..479b3e7b 100644 --- a/docs/source/simulation_structure.rst +++ b/docs/source/simulation_structure.rst @@ -11,3 +11,55 @@ top level, there is an object called the ``SimulationController`` _(doesn't exis and a software controller for managing software and users. Each node of the simulation 'tree' has responsibility for creating, deleting, and updating its direct descendants. + + + +Actions +======= +Agents can interact with the simulation by using actions. Actions are standardised with the +:py:class:`primaite.simulation.core.Action` class, which just holds a reference to two special functions. + +1. The action function itself, it must accept a `request` parameters which is a list of strings that describe what the + action should do. It must also accept a `context` dict which can house additional information surrounding the action. + For example, the context will typically include information about which entity intiated the action. +2. A validator function. This function should return a boolean value that decides if the request is permitted or not. + It uses the same paramters as the action function. + +Action Permissions +------------------ +When an agent tries to perform an action on a simulation component, that action will only be executed if the request is +validated. For example, some actions can require that an agent is logged into an admin account. Each action defines its +own permissions using an instance of :py:class:`primaite.simulation.core.ActionPermissionValidator`. The below code +snippet demonstrates usage of the ``ActionPermissionValidator``. + +.. code:: python + + from primaite.simulator.core import Action, ActionManager, SimComponent + from primaite.simulator.domain.controller import AccountGroup, GroupMembershipValidator + + class Smartphone(SimComponent): + name: str + apps = [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.action_manager = ActionManager() + + self.action_manager.add_action( + "reset_factory_settings", + Action( + func = lambda request, context: self.reset_factory_settings(), + validator = GroupMembershipValidator([AccountGroup.domain_admin]), + ), + ) + + def reset_factory_settings(self): + self.apps = [] + + phone = Smartphone(name="phone1") + + # try to wipe the phone as a domain user, this will have no effect + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["domain_user"]}) + + # try to wipe the phone as an admin user, this will wipe the phone + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["domain_admin"]}) diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index 0f59db2e..086022e6 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -1,6 +1,6 @@ """User account simulation.""" from enum import Enum -from typing import Any, Callable, Dict, List +from typing import Dict from primaite import getLogger from primaite.simulator.core import SimComponent @@ -49,6 +49,10 @@ class Account(SimComponent): "Account Type, currently this can be service account (used by apps) or user account." status: AccountStatus = AccountStatus.disabled + def describe_state(self) -> Dict: + """Describe state for agent observations.""" + return super().describe_state() + def enable(self): """Set the status to enabled.""" self.status = AccountStatus.enabled @@ -57,8 +61,10 @@ class Account(SimComponent): """Set the status to disabled.""" self.status = AccountStatus.disabled - def _possible_actions(self) -> Dict[str, Callable[[List[str]], None]]: - return { - "enable": self.enable, - "disable": self.disable, - } + def log_on(self) -> None: + """TODO.""" + self.num_logons += 1 + + def log_off(self) -> None: + """TODO.""" + self.num_logoffs += 1 diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 7cb3f4a6..e4a73b4e 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Dict, Final, List +from typing import Dict, Final, List, Literal, Tuple from primaite.simulator.core import ActionPermissionValidator, SimComponent from primaite.simulator.domain.account import Account, AccountType @@ -7,18 +7,26 @@ from primaite.simulator.domain.account import Account, AccountType # placeholder while these objects don't yet exist class temp_node: + """Placeholder for node class for type hinting purposes.""" + pass class temp_application: + """Placeholder for application class for type hinting purposes.""" + pass class temp_folder: + """Placeholder for folder class for type hinting purposes.""" + pass class temp_file: + """Placeholder for file class for type hinting purposes.""" + pass @@ -59,9 +67,12 @@ class DomainController(SimComponent): accounts: List[Account] = [] groups: Final[List[AccountGroup]] = list(AccountGroup) - group_membership: Dict[AccountGroup, List[Account]] + domain_group_membership: Dict[Literal[AccountGroup.domain_admin, AccountGroup.domain_user], List[Account]] = {} + local_group_membership: Dict[ + Tuple(temp_node, Literal[AccountGroup.local_admin, AccountGroup.local_user]), List[Account] + ] = {} - # references to non-owned objects + # references to non-owned objects. Not sure if all are needed here. nodes: List[temp_node] = [] applications: List[temp_application] = [] folders: List[temp_folder] = [] @@ -79,6 +90,10 @@ class DomainController(SimComponent): """TODO.""" ... + def delete_account(self, account: Account) -> None: + """TODO.""" + ... + def rotate_all_credentials(self) -> None: """TODO.""" ... @@ -94,3 +109,15 @@ class DomainController(SimComponent): def remove_account_from_group(self, account: Account, group: AccountGroup) -> None: """TODO.""" ... + + def check_account_permissions(self, account: Account, node: temp_node) -> List[AccountGroup]: + """Return a list of permission groups that this account has on this node.""" + ... + + def register_node(self, node: temp_node) -> None: + """TODO.""" + ... + + def deregister_node(self, node: temp_node) -> None: + """TODO.""" + ... diff --git a/tests/integration_tests/component_creation/test_permission_system.py b/tests/integration_tests/component_creation/test_permission_system.py index acc35b72..93d0267c 100644 --- a/tests/integration_tests/component_creation/test_permission_system.py +++ b/tests/integration_tests/component_creation/test_permission_system.py @@ -8,7 +8,13 @@ from primaite.simulator.domain.controller import AccountGroup, GroupMembershipVa def test_group_action_validation() -> None: - """Check that actions are denied when an unauthorised request is made.""" + """ + Check that actions are denied when an unauthorised request is made. + + This test checks the integration between SimComponent and the permissions validation system. First, we create a + basic node and folder class. We configure the node so that only admins can create a folder. Then, we try to create + a folder as both an admin user and a non-admin user. + """ class Folder(SimComponent): name: str @@ -42,22 +48,28 @@ def test_group_action_validation() -> None: def remove_folder(self, folder: Folder) -> None: self.folders = [x for x in self.folders if x is not folder] + # check that the folder is created when a local admin tried to do it permitted_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_admin"]}} - my_node = Node(uuid="0000-0000-1234", name="pc") my_node.apply_action(["create_folder", "memes"], context=permitted_context) assert len(my_node.folders) == 1 assert my_node.folders[0].name == "memes" + # check that the number of folders is still 1 even after attempting to create a second one without permissions invalid_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_user", "domain_user"]}} - my_node.apply_action(["create_folder", "memes2"], context=invalid_context) assert len(my_node.folders) == 1 assert my_node.folders[0].name == "memes" def test_hierarchical_action_with_validation() -> None: - """Check that validation works with sub-objects""" + """ + Check that validation works with sub-objects. + + This test creates a parent object (Node) and a child object (Application) which both accept actions. The node allows + action passthrough to applications. The purpose of this test is to check that after an action is passed through to + a child object, that the permission system still works as intended. + """ class Application(SimComponent): name: str @@ -100,19 +112,19 @@ def test_hierarchical_action_with_validation() -> None: return super().describe_state() def disable(self) -> None: - self.status = "disabled" + self.state = "disabled" def enable(self) -> None: - if self.status == "disabled": - self.status = "off" + if self.state == "disabled": + self.state = "off" def turn_on(self) -> None: - if self.status == "off": - self.status = "on" + if self.state == "off": + self.state = "on" def turn_off(self) -> None: - if self.status == "on": - self.status = "off" + if self.state == "on": + self.state = "off" class Node(SimComponent): name: str @@ -141,7 +153,7 @@ def test_hierarchical_action_with_validation() -> None: def send_action_to_app(self, app_name: str, options: List[str], context: Dict): for app in self.apps: if app_name == app.name: - app.apply_action(options) + app.apply_action(options, context) break else: msg = f"Node has no app with name {app_name}" @@ -163,9 +175,16 @@ def test_hierarchical_action_with_validation() -> None: } } + # check that a non-admin can't disable this app my_node.apply_action(["apps", "Chrome", "disable"], non_admin_context) - my_node.apply_action(["apps", "Firefox", "turn_on"], non_admin_context) + assert my_node.apps[0].name == "Chrome" # if failure occurs on this line, the test itself is broken + assert my_node.apps[0].state == "off" - assert my_node.apps[0].name == "Chrome" - assert my_node.apps[1].name == "Firefox" - assert my_node.apps[0].state == ... # TODO: finish + # check that a non-admin can turn this app on + my_node.apply_action(["apps", "Firefox", "turn_on"], non_admin_context) + assert my_node.apps[1].name == "Firefox" # if failure occurs on this line, the test itself is broken + assert my_node.apps[1].state == "on" + + # check that an admin can disable this app + my_node.apply_action(["apps", "Chrome", "disable"], admin_context) + assert my_node.apps[0].state == "disabled" diff --git a/tests/unit_tests/_primaite/_simulator/_domain/__init__.py b/tests/unit_tests/_primaite/_simulator/_domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py new file mode 100644 index 00000000..d4a57179 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py @@ -0,0 +1,18 @@ +"""Test the account module of the simulator.""" +from primaite.simulator.domain.account import Account, AccountType + + +def test_account_serialise(): + """Test that an account can be serialised.""" + acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.user) + serialised = acct.model_dump_json() + print(serialised) + + +def test_account_deserialise(): + """Test that an account can be deserialised.""" + acct_json = ( + '{"uuid":"dfb2bcaa-d3a1-48fd-af3f-c943354622b4","num_logons":0,"num_logoffs":0,"num_group_changes":0,' + '"username":"Jake","password":"JakePass1!","account_type":2,"status":2,"action_manager":null}' + ) + acct = Account.model_validate_json(acct_json) diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_controller.py b/tests/unit_tests/_primaite/_simulator/_domain/test_controller.py new file mode 100644 index 00000000..e69de29b