From ac31e7290c54dcf83300846a7f259b1e8203721a Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Thu, 5 Jun 2025 09:43:19 -0400 Subject: [PATCH 1/3] Added support for IAM endpoints (#552) --- linode_api4/groups/iam.py | 98 +++++++ linode_api4/linode_client.py | 4 + linode_api4/objects/__init__.py | 1 + linode_api4/objects/iam.py | 34 +++ test/fixtures/entities.json | 77 ++++++ test/fixtures/iam_role-permissions.json | 81 ++++++ ...iam_users_myusername_role-permissions.json | 23 ++ test/unit/groups/iam_test.py | 242 ++++++++++++++++++ 8 files changed, 560 insertions(+) create mode 100644 linode_api4/groups/iam.py create mode 100644 linode_api4/objects/iam.py create mode 100644 test/fixtures/entities.json create mode 100644 test/fixtures/iam_role-permissions.json create mode 100644 test/fixtures/iam_users_myusername_role-permissions.json create mode 100644 test/unit/groups/iam_test.py diff --git a/linode_api4/groups/iam.py b/linode_api4/groups/iam.py new file mode 100644 index 000000000..c2bb5d7d6 --- /dev/null +++ b/linode_api4/groups/iam.py @@ -0,0 +1,98 @@ +from typing import Any, Dict, List, Optional, Union + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import EntityAccess, LinodeEntity + + +class IAMGroup(Group): + def role_permissions(self): + """ + Returns the permissions available on the account assigned to any user of the account. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + permissions = client.role_permissions() + + API Documentation: TODO + + :returns: The JSON role permissions for the account. + """ + return self.client.get("/iam/role-permissions", model=self) + + def role_permissions_user_get(self, username): + """ + Returns the permissions available on the account assigned to the specified user. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + permissions = client.role_permissions_user_get("myusername") + + API Documentation: TODO + + :returns: The JSON role permissions for the user. + """ + return self.client.get( + f"/iam/users/{username}/role-permissions", model=self + ) + + def role_permissions_user_set( + self, + username, + account_access: Optional[List[str]] = None, + entity_access: Optional[ + Union[List[EntityAccess], Dict[str, Any]] + ] = None, + ): + """ + Assigns the specified permissions to the specified user, and returns them. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + permissions = client.role_permissions_user_set("muusername") + + API Documentation: TODO + + :returns: The JSON role permissions for the user. + """ + params = { + "account_access": account_access, + "entity_access": entity_access, + } + + result = self.client.put( + f"/iam/users/{username}/role-permissions", + data=params, + ) + + if "account_access" not in result: + raise UnexpectedResponseError( + "Unexpected response updating role permissions!", json=result + ) + + return result + + def entities(self, *filters): + """ + Returns the current entities of the account. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + permissions = client.entities() + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of entities that match the query. + :rtype: PaginatedList of Entity + """ + return self.client._get_and_filter( + LinodeEntity, *filters, endpoint="/entities" + ) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 19e6f3900..894e05ba6 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -32,6 +32,7 @@ ) from linode_api4.objects import Image, and_ +from .groups.iam import IAMGroup from .groups.placement import PlacementAPIGroup from .paginated_list import PaginatedList @@ -171,6 +172,9 @@ def __init__( #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. self.database = DatabaseGroup(self) + #: Access methods related to IAM - see :any:`IAMGroup` for more information. + self.iam = IAMGroup(self) + #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. self.nodebalancers = NodeBalancerGroup(self) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index b13fac51a..3ca3e2267 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -4,6 +4,7 @@ from .serializable import JSONObject from .filtering import and_, or_ from .region import Region +from .iam import * from .image import Image from .linode import * from .volume import * diff --git a/linode_api4/objects/iam.py b/linode_api4/objects/iam.py new file mode 100644 index 000000000..48220c0ff --- /dev/null +++ b/linode_api4/objects/iam.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +from linode_api4.objects.base import Base, JSONObject, Property + + +class LinodeEntity(Base): + """ + An Entity represents an entity of the account. + + Currently the Entity can only be retrieved by listing, i.e.: + entities = client.iam.entities() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "type": Property(), + } + + +@dataclass +class EntityAccess(JSONObject): + """ + EntityAccess represents a user's access to an entity. + """ + + id: int + type: str + roles: List[str] diff --git a/test/fixtures/entities.json b/test/fixtures/entities.json new file mode 100644 index 000000000..f5471f4c9 --- /dev/null +++ b/test/fixtures/entities.json @@ -0,0 +1,77 @@ +{ + "data": [ + { + "id": 7, + "label": "linode7", + "type": "linode" + }, + { + "id": 10, + "label": "linode10", + "type": "linode" + }, + { + "id": 1, + "label": "no_devices", + "type": "firewall" + }, + { + "id": 2, + "label": "active_with_nodebalancer", + "type": "firewall" + }, + { + "id": 1, + "label": "nodebalancer-active", + "type": "nodebalancer" + }, + { + "id": 1, + "label": "active", + "type": "longview" + }, + { + "id": 3, + "label": "LongviewClientTest", + "type": "longview" + }, + { + "id": 1, + "label": "linDomTest1.com", + "type": "domain" + }, + { + "id": 1, + "label": "API Test", + "type": "stackscript" + }, + { + "id": 1, + "label": "Test image - mine", + "type": "image" + }, + { + "id": 3, + "label": "Test image - mine - creating", + "type": "image" + }, + { + "id": 1, + "label": "volume1", + "type": "volume" + }, + { + "id": 1, + "label": "mongo_cluster", + "type": "database" + }, + { + "id": 3, + "label": "empty-vpc", + "type": "vpc" + } + ], + "page": 1, + "pages": 1, + "results": 14 +} \ No newline at end of file diff --git a/test/fixtures/iam_role-permissions.json b/test/fixtures/iam_role-permissions.json new file mode 100644 index 000000000..203190f25 --- /dev/null +++ b/test/fixtures/iam_role-permissions.json @@ -0,0 +1,81 @@ +{ + "account_access": [ + { + "type": "account", + "roles": [ + { + "name": "account_admin", + "description": "Access to perform any supported action on all entities of the account", + "permissions": [ + "create_linode", + "update_linode", + "update_firewall" + ] + } + ] + }, + { + "type": "linode", + "roles": [ + { + "name": "account_linode_admin", + "description": "Access to perform any supported action on all linode instances of the account", + "permissions": [ + "create_linode", + "update_linode", + "delete_linode" + ] + } + ] + }, + { + "type": "firewall", + "roles": [ + { + "name": "firewall_creator", + "description": "Access to create a firewall instance", + "permissions": [ + "update_linode", + "view_linode" + ] + } + ] + } + ], + "entity_access": [ + { + "type": "linode", + "roles": [ + { + "name": "linode_contributor", + "description": "Access to update a linode instance", + "permissions": [ + "update_linode", + "view_linode" + ] + } + ] + }, + { + "type": "firewall", + "roles": [ + { + "name": "firewall_viewer", + "description": "Access to view a firewall instance", + "permissions": [ + "update_linode", + "view_linode" + ] + }, + { + "name": "firewall_admin", + "description": "Access to perform any supported action on a firewall instance", + "permissions": [ + "update_linode", + "view_linode" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/test/fixtures/iam_users_myusername_role-permissions.json b/test/fixtures/iam_users_myusername_role-permissions.json new file mode 100644 index 000000000..0c3c8aeb9 --- /dev/null +++ b/test/fixtures/iam_users_myusername_role-permissions.json @@ -0,0 +1,23 @@ +{ + "account_access": [ + "account_linode_admin", + "linode_creator", + "firewall_creator" + ], + "entity_access": [ + { + "id": 1, + "type": "linode", + "roles": [ + "linode_contributor" + ] + }, + { + "id": 1, + "type": "firewall", + "roles": [ + "firewall_admin" + ] + } + ] +} \ No newline at end of file diff --git a/test/unit/groups/iam_test.py b/test/unit/groups/iam_test.py new file mode 100644 index 000000000..c9242b48c --- /dev/null +++ b/test/unit/groups/iam_test.py @@ -0,0 +1,242 @@ +import logging +from test.unit.base import ClientBaseCase + +logger = logging.getLogger(__name__) + + +class IAMTest(ClientBaseCase): + """ + Tests methods of the IAMGroup class + """ + + def test_entities(self): + """ + Test that entities can be properly retrieved + """ + entities = self.client.iam.entities() + + self.assertEqual(len(entities), 14) + + self.assertEqual(entities[0].id, 7) + self.assertEqual(entities[0].label, "linode7") + self.assertEqual(entities[0].type, "linode") + + self.assertEqual(entities[1].id, 10) + self.assertEqual(entities[1].label, "linode10") + self.assertEqual(entities[1].type, "linode") + + self.assertEqual(entities[2].id, 1) + self.assertEqual(entities[2].label, "no_devices") + self.assertEqual(entities[2].type, "firewall") + + self.assertEqual(entities[3].id, 2) + self.assertEqual(entities[3].label, "active_with_nodebalancer") + self.assertEqual(entities[3].type, "firewall") + + self.assertEqual(entities[4].id, 1) + self.assertEqual(entities[4].label, "nodebalancer-active") + self.assertEqual(entities[4].type, "nodebalancer") + + self.assertEqual(entities[5].id, 1) + self.assertEqual(entities[5].label, "active") + self.assertEqual(entities[5].type, "longview") + + self.assertEqual(entities[6].id, 3) + self.assertEqual(entities[6].label, "LongviewClientTest") + self.assertEqual(entities[6].type, "longview") + + self.assertEqual(entities[7].id, 1) + self.assertEqual(entities[7].label, "linDomTest1.com") + self.assertEqual(entities[7].type, "domain") + + self.assertEqual(entities[8].id, 1) + self.assertEqual(entities[8].label, "API Test") + self.assertEqual(entities[8].type, "stackscript") + + self.assertEqual(entities[9].id, 1) + self.assertEqual(entities[9].label, "Test image - mine") + self.assertEqual(entities[9].type, "image") + + self.assertEqual(entities[10].id, 3) + self.assertEqual(entities[10].label, "Test image - mine - creating") + self.assertEqual(entities[10].type, "image") + + self.assertEqual(entities[11].id, 1) + self.assertEqual(entities[11].label, "volume1") + self.assertEqual(entities[11].type, "volume") + + self.assertEqual(entities[12].id, 1) + self.assertEqual(entities[12].label, "mongo_cluster") + self.assertEqual(entities[12].type, "database") + + self.assertEqual(entities[13].id, 3) + self.assertEqual(entities[13].label, "empty-vpc") + self.assertEqual(entities[13].type, "vpc") + + def test_role_permissions(self): + """ + Test that account role permissions can be properly retrieved + """ + role_permissions = self.client.iam.role_permissions() + + self.assertEqual(len(role_permissions["account_access"]), 3) + self.assertEqual(len(role_permissions["entity_access"]), 2) + + self.assertEqual( + role_permissions["account_access"][0]["type"], "account" + ) + self.assertEqual(len(role_permissions["account_access"][0]["roles"]), 1) + self.assertEqual( + role_permissions["account_access"][0]["roles"][0]["name"], + "account_admin", + ) + self.assertEqual( + role_permissions["account_access"][0]["roles"][0]["description"], + "Access to perform any supported action on all entities of the account", + ) + self.assertCountEqual( + role_permissions["account_access"][0]["roles"][0]["permissions"], + ["create_linode", "update_linode", "update_firewall"], + ) + + self.assertEqual( + role_permissions["account_access"][1]["type"], "linode" + ) + self.assertEqual(len(role_permissions["account_access"][1]["roles"]), 1) + self.assertEqual( + role_permissions["account_access"][1]["roles"][0]["name"], + "account_linode_admin", + ) + self.assertEqual( + role_permissions["account_access"][1]["roles"][0]["description"], + "Access to perform any supported action on all linode instances of the account", + ) + self.assertCountEqual( + role_permissions["account_access"][1]["roles"][0]["permissions"], + ["create_linode", "update_linode", "delete_linode"], + ) + + self.assertEqual( + role_permissions["account_access"][2]["type"], "firewall" + ) + self.assertEqual(len(role_permissions["account_access"][2]["roles"]), 1) + self.assertEqual( + role_permissions["account_access"][2]["roles"][0]["name"], + "firewall_creator", + ) + self.assertEqual( + role_permissions["account_access"][2]["roles"][0]["description"], + "Access to create a firewall instance", + ) + self.assertCountEqual( + role_permissions["account_access"][2]["roles"][0]["permissions"], + ["update_linode", "view_linode"], + ) + + self.assertEqual(role_permissions["entity_access"][0]["type"], "linode") + self.assertEqual(len(role_permissions["entity_access"][0]["roles"]), 1) + self.assertEqual( + role_permissions["entity_access"][0]["roles"][0]["name"], + "linode_contributor", + ) + self.assertEqual( + role_permissions["entity_access"][0]["roles"][0]["description"], + "Access to update a linode instance", + ) + self.assertCountEqual( + role_permissions["entity_access"][0]["roles"][0]["permissions"], + ["update_linode", "view_linode"], + ) + + self.assertEqual( + role_permissions["entity_access"][1]["type"], "firewall" + ) + self.assertEqual(len(role_permissions["entity_access"][1]["roles"]), 2) + self.assertEqual( + role_permissions["entity_access"][1]["roles"][0]["name"], + "firewall_viewer", + ) + self.assertEqual( + role_permissions["entity_access"][1]["roles"][0]["description"], + "Access to view a firewall instance", + ) + self.assertCountEqual( + role_permissions["entity_access"][1]["roles"][0]["permissions"], + ["update_linode", "view_linode"], + ) + self.assertEqual( + role_permissions["entity_access"][1]["roles"][1]["name"], + "firewall_admin", + ) + self.assertEqual( + role_permissions["entity_access"][1]["roles"][1]["description"], + "Access to perform any supported action on a firewall instance", + ) + self.assertCountEqual( + role_permissions["entity_access"][1]["roles"][1]["permissions"], + ["update_linode", "view_linode"], + ) + + def test_role_permissions_user_get(self): + """ + Test that user role permissions can be properly retrieved + """ + permissions = self.client.iam.role_permissions_user_get("myusername") + + self.assertIn("account_linode_admin", permissions["account_access"]) + self.assertIn("linode_creator", permissions["account_access"]) + self.assertIn("firewall_creator", permissions["account_access"]) + self.assertEqual(len(permissions["account_access"]), 3) + + self.assertEqual(len(permissions["entity_access"]), 2) + + self.assertEqual(permissions["entity_access"][0]["id"], 1) + self.assertEqual(permissions["entity_access"][0]["type"], "linode") + self.assertEqual( + permissions["entity_access"][0]["roles"], ["linode_contributor"] + ) + + self.assertEqual(permissions["entity_access"][1]["id"], 1) + self.assertEqual(permissions["entity_access"][1]["type"], "firewall") + self.assertEqual( + permissions["entity_access"][1]["roles"], ["firewall_admin"] + ) + + def test_role_permissions_user_set(self): + with self.mock_put("/iam/users/myusername/role-permissions") as m: + self.client.iam.role_permissions_user_set( + "myusername", + ["account_linode_admin", "linode_creator", "firewall_creator"], + [ + { + "id": 1, + "type": "linode", + "roles": ["linode_contributor"], + }, + {"id": 1, "type": "firewall", "roles": ["firewall_admin"]}, + ], + ) + + self.assertEqual(m.method, "put") + self.assertEqual(m.call_url, "/iam/users/myusername/role-permissions") + + self.assertIn("account_access", m.call_data) + self.assertEqual( + m.call_data["account_access"], + ["account_linode_admin", "linode_creator", "firewall_creator"], + ) + + self.assertIn("entity_access", m.call_data) + self.assertEqual(len(m.call_data["entity_access"]), 2) + + self.assertEqual(m.call_data["entity_access"][0]["id"], 1) + self.assertEqual(m.call_data["entity_access"][0]["type"], "linode") + self.assertEqual( + m.call_data["entity_access"][0]["roles"], ["linode_contributor"] + ) + + self.assertEqual(m.call_data["entity_access"][1]["id"], 1) + self.assertEqual(m.call_data["entity_access"][1]["type"], "firewall") + self.assertEqual( + m.call_data["entity_access"][1]["roles"], ["firewall_admin"] + ) From 7716db907049122d0bedd729169c5291e912b7c3 Mon Sep 17 00:00:00 2001 From: vshanthe Date: Thu, 16 Oct 2025 16:15:22 +0530 Subject: [PATCH 2/3] iam_tests --- test/integration/models/iam/iam_test.py | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 test/integration/models/iam/iam_test.py diff --git a/test/integration/models/iam/iam_test.py b/test/integration/models/iam/iam_test.py new file mode 100644 index 000000000..5c1c2df6d --- /dev/null +++ b/test/integration/models/iam/iam_test.py @@ -0,0 +1,63 @@ +import pytest + +from linode_api4.objects import EntityAccess, LinodeEntity + + +@pytest.mark.smoke +def test_get_role_permissions(test_linode_client): + client = test_linode_client + iam = client.iam + + permissions = iam.role_permissions() + + assert "account_access" in permissions + assert isinstance(permissions["account_access"], list) + + +@pytest.mark.smoke +def test_get_user_role_permissions(test_linode_client): + client = test_linode_client + iam = client.iam + + username = client.profile().username + user_permissions = iam.role_permissions_user_get(username) + + assert "account_access" in user_permissions + assert isinstance(user_permissions["account_access"], list) + + +@pytest.mark.skip( + reason="Updating IAM role permissions may require elevated privileges." +) +def test_set_user_role_permissions(test_linode_client): + client = test_linode_client + iam = client.iam + + username = client.profile().username + entity_access = [EntityAccess(id=1, type="linode", roles=["read_only"])] + + updated = iam.role_permissions_user_set( + username, + account_access=["read_only"], + entity_access=entity_access, + ) + + assert "account_access" in updated + assert "entity_access" in updated + + +@pytest.mark.smoke +def test_list_entities(test_linode_client): + client = test_linode_client + iam = client.iam + + entities = iam.entities() + + if len(entities) > 0: + entity = entities[0] + assert isinstance(entity, LinodeEntity) + assert hasattr(entity, "id") + assert hasattr(entity, "label") + assert hasattr(entity, "type") + else: + pytest.skip("No entities found in IAM response.") From 7e518d084bc7fa3e3fc99326dc0b5617fc4f613c Mon Sep 17 00:00:00 2001 From: vshanthe Date: Thu, 16 Oct 2025 16:30:05 +0530 Subject: [PATCH 3/3] fix --- test/integration/models/iam/iam_test.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/integration/models/iam/iam_test.py b/test/integration/models/iam/iam_test.py index 5c1c2df6d..9b054bf64 100644 --- a/test/integration/models/iam/iam_test.py +++ b/test/integration/models/iam/iam_test.py @@ -3,7 +3,6 @@ from linode_api4.objects import EntityAccess, LinodeEntity -@pytest.mark.smoke def test_get_role_permissions(test_linode_client): client = test_linode_client iam = client.iam @@ -14,7 +13,6 @@ def test_get_role_permissions(test_linode_client): assert isinstance(permissions["account_access"], list) -@pytest.mark.smoke def test_get_user_role_permissions(test_linode_client): client = test_linode_client iam = client.iam @@ -46,7 +44,6 @@ def test_set_user_role_permissions(test_linode_client): assert "entity_access" in updated -@pytest.mark.smoke def test_list_entities(test_linode_client): client = test_linode_client iam = client.iam