diff --git a/changelog.d/20250927_194304_james_SC_38762_Collection_Role_Create.md b/changelog.d/20250927_194304_james_SC_38762_Collection_Role_Create.md new file mode 100644 index 000000000..0cf7ab625 --- /dev/null +++ b/changelog.d/20250927_194304_james_SC_38762_Collection_Role_Create.md @@ -0,0 +1,4 @@ +### Enhancements + +* Add a new command for interacting with collection roles, + `globus gcs collection role create` diff --git a/src/globus_cli/commands/collection/role/__init__.py b/src/globus_cli/commands/collection/role/__init__.py index b16aa84bb..3bfbd8e5e 100644 --- a/src/globus_cli/commands/collection/role/__init__.py +++ b/src/globus_cli/commands/collection/role/__init__.py @@ -6,6 +6,7 @@ lazy_subcommands={ "list": (".list", "list_command"), "show": (".show", "show_command"), + "create": (".create", "create_command"), "delete": (".delete", "delete_command"), }, ) diff --git a/src/globus_cli/commands/collection/role/_fields.py b/src/globus_cli/commands/collection/role/_fields.py new file mode 100644 index 000000000..f771331fe --- /dev/null +++ b/src/globus_cli/commands/collection/role/_fields.py @@ -0,0 +1,39 @@ +import typing as t + +import globus_sdk + +from globus_cli.termio import Field +from globus_cli.termio.formatters.auth import PrincipalURNFormatter + + +class CollectionRoleFormatter(PrincipalURNFormatter): + """ + A formatter for collection roles + """ + + def __init__( + self, auth_client: globus_sdk.AuthClient, collection_role: dict[str, t.Any] + ) -> None: + super().__init__(auth_client) + self.add_items(collection_role.get("id")) + self.add_items(collection_role.get("role", ())) + self.add_items(collection_role.get("principal")) + + +def collection_role_format_fields( + auth_client: globus_sdk.AuthClient, + collection_role: dict[str, t.Any], +) -> list[Field]: + """ + The standard list of fields to render for a collection role. + + :param auth_client: An AuthClient, used to resolve principal URNs. + :param collection_role: The collection role assignment to format + """ + principal = CollectionRoleFormatter(auth_client, collection_role) + + return [ + Field("ID", "id"), + Field("Role", "role"), + Field("Principal", "principal", formatter=principal), + ] diff --git a/src/globus_cli/commands/collection/role/create.py b/src/globus_cli/commands/collection/role/create.py new file mode 100644 index 000000000..404ddab6e --- /dev/null +++ b/src/globus_cli/commands/collection/role/create.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import typing as t +import uuid + +import click +import globus_sdk + +from globus_cli.commands.collection.role._fields import collection_role_format_fields +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import collection_id_arg, command +from globus_cli.termio import display +from globus_cli.utils import resolve_principal_urn + +_VALID_ROLES = t.Literal[ + "access_manager", "activity_manager", "activity_monitor", "administrator" +] + + +@command("create") +@collection_id_arg +@click.argument("ROLE", type=click.Choice(t.get_args(_VALID_ROLES)), metavar="ROLE") +@click.argument("PRINCIPAL", type=str, required=False) +@click.option( + "--principal-type", + type=click.Choice(["identity", "group"]), + help="Qualifier to specify what type of principal (identity or group) is provided.", +) +@LoginManager.requires_login("transfer") +def create_command( + login_manager: LoginManager, + *, + collection_id: uuid.UUID, + role: t.Literal[ + "access_manager", "activity_manager", "activity_monitor", "administrator" + ], + principal: str | None, + principal_type: t.Literal["identity", "group"] | None, +) -> None: + """ + Create a role assignment on a Collection. + + ROLE must be one of: + + "administrator", + "access_manager", + "activity_manager", + "activity_monitor" + + If a PRINCIPAL value is not provided the primary identity of the logged in user will + be used. + + If a PRINCIPAL value is provided, it must be a username, UUID, or URN associated + with a globus identity or group. + + If UUID, use `--principal-type` to specify the type (defaults to "identity"). + """ + + gcs_client = login_manager.get_gcs_client(collection_id=collection_id) + auth_client = login_manager.get_auth_client() + + # If Principal argument isn't provided, determine user's primary identity + if principal is None: + userinfo = auth_client.userinfo() + principal = userinfo["sub"] + + # Format the principal into a URN + principal_urn = resolve_principal_urn( + auth_client=auth_client, + principal_type=principal_type, + principal=principal, + ) + + res = gcs_client.create_role( + globus_sdk.GCSRoleDocument( + DATA_TYPE="role#1.0.0", + collection=collection_id, + role=role, + principal=principal_urn, + ) + ) + + fields = collection_role_format_fields(auth_client, res.data) + + display(res, text_mode=display.RECORD, fields=fields) diff --git a/src/globus_cli/commands/collection/role/list.py b/src/globus_cli/commands/collection/role/list.py index e68b64b61..3a3c55a18 100644 --- a/src/globus_cli/commands/collection/role/list.py +++ b/src/globus_cli/commands/collection/role/list.py @@ -2,10 +2,10 @@ import click +from globus_cli.commands.collection.role._fields import collection_role_format_fields from globus_cli.login_manager import LoginManager from globus_cli.parsing import collection_id_arg, command -from globus_cli.termio import Field, display -from globus_cli.termio.formatters.auth import PrincipalURNFormatter +from globus_cli.termio import display @command("list") @@ -27,14 +27,9 @@ def list_command( else: res = gcs_client.get_role_list(collection_id) + fields = collection_role_format_fields(auth_client, res.data) display( res, text_mode=display.RECORD_LIST, - fields=[ - Field("ID", "id"), - Field("Role", "role"), - Field( - "Principal", "principal", formatter=PrincipalURNFormatter(auth_client) - ), - ], + fields=fields, ) diff --git a/src/globus_cli/commands/collection/role/show.py b/src/globus_cli/commands/collection/role/show.py index 3619d26fd..366bc3de3 100644 --- a/src/globus_cli/commands/collection/role/show.py +++ b/src/globus_cli/commands/collection/role/show.py @@ -2,10 +2,10 @@ import click +from globus_cli.commands.collection.role._fields import collection_role_format_fields from globus_cli.login_manager import LoginManager from globus_cli.parsing import collection_id_arg, command -from globus_cli.termio import Field, display -from globus_cli.termio.formatters.auth import PrincipalURNFormatter +from globus_cli.termio import display @command("show") @@ -24,14 +24,10 @@ def show_command( res = gcs_client.get_role(role_id) + fields = collection_role_format_fields(auth_client, res.data) + display( res, text_mode=display.RECORD, - fields=[ - Field("ID", "id"), - Field("Role", "role"), - Field( - "Principal", "principal", formatter=PrincipalURNFormatter(auth_client) - ), - ], + fields=fields, ) diff --git a/tests/files/api_fixtures/collection_operations.yaml b/tests/files/api_fixtures/collection_operations.yaml index 84c3bb4e3..770e65700 100644 --- a/tests/files/api_fixtures/collection_operations.yaml +++ b/tests/files/api_fixtures/collection_operations.yaml @@ -1,6 +1,6 @@ metadata: + role: "activity_monitor" role_id: "0174edd0-998c-4147-a123-99ea38068145" - role_identity_id: "081cfa08-b857-4f26-bf2b-13d08e581545" mapped_collection_id: "1405823f-0597-4a16-b296-46d4f0ae4b15" guest_collection_id: "0e4a77f8-b778-4d5c-abaa-e1254e71427f" endpoint_id: "cf37806c-572c-47ff-88e2-511c646ef1a4" @@ -196,7 +196,23 @@ auth: } ] } - + - path: /v2/api/identities + method: get + json: + { + "identities": [ + { + "email": "shrek@globus.org", + "id": "e926d510-cb98-11e5-a6ac-0b0216052512", + "identity_provider": "927d7238-f917-4eb2-9ace-c523fa9ba34e", + "identity_type": "login", + "name": "Test User", + "organization": "Globus", + "status": "used", + "username": "shrek@globus.org" + } + ] + } gcs: - path: /collections method: post @@ -431,6 +447,64 @@ gcs: } ] } + - path: /roles + method: get + json: + { + "DATA_TYPE": "result#1.1.0", + "code": "success", + "data": [ + { + "DATA_TYPE": "role#1.0.0", + "collection": "1405823f-0597-4a16-b296-46d4f0ae4b15", + "id": "0174edd0-998c-4147-a123-99ea38068145", + "principal": "urn:globus:auth:identity:e926d510-cb98-11e5-a6ac-0b0216052512", + "role": "activity_monitor" + } + ], + "detail": "success", + "has_next_page": False, + "http_response_code": 200, + } + - path: /roles + method: post + json: + { + "DATA_TYPE": "result#1.1.0", + "code": "success", + "data": [ + { + "DATA_TYPE": "role#1.0.0", + "collection": "1405823f-0597-4a16-b296-46d4f0ae4b15", + "id": "0174edd0-998c-4147-a123-99ea38068145", + "principal": "urn:globus:auth:identity:e926d510-cb98-11e5-a6ac-0b0216052512", + "role": "activity_monitor" + } + ], + "detail": "success", + "has_next_page": False, + "http_response_code" : 200, + "message": "Created new role 0174edd0-998c-4147-a123-99ea38068145" + } + - path: /roles/0174edd0-998c-4147-a123-99ea38068145 + method: get + json: + { + "DATA_TYPE": "result#1.1.0", + "code": "success", + "data": [ + { + "DATA_TYPE": "role#1.0.0", + "collection": "1405823f-0597-4a16-b296-46d4f0ae4b15", + "id": "0174edd0-998c-4147-a123-99ea38068145", + "principal": "urn:globus:auth:identity:e926d510-cb98-11e5-a6ac-0b0216052512", + "role": "activity_monitor" + } + ], + "detail": "success", + "has_next_page": False, + "http_response_code": 200, + } - path: /roles/0174edd0-998c-4147-a123-99ea38068145 method: delete json: diff --git a/tests/functional/collection/role/test_role_create.py b/tests/functional/collection/role/test_role_create.py new file mode 100644 index 000000000..8eda9a3de --- /dev/null +++ b/tests/functional/collection/role/test_role_create.py @@ -0,0 +1,35 @@ +from globus_sdk.testing import load_response_set + + +def test_successful_gcs_collection_role_creation( + run_line, + add_gcs_login, + get_identities_mocker, +): + # setup data for the collection_id -> endpoint_id lookup + # and create dummy credentials for the test to run against that GCS + meta = load_response_set("cli.collection_operations").metadata + + collection_id = meta["mapped_collection_id"] + endpoint_id = meta["endpoint_id"] + role = meta["role"] + role_id = meta["role_id"] + user_id = meta["identity_id"] + add_gcs_login(endpoint_id) + + role = "activity_monitor" + + # Mock the Get Identities API (Auth) + # so that CLI output rendering can show a username + user_meta = get_identities_mocker.configure_one(id=user_id).metadata + username = user_meta["username"] + + # now test the command and confirm that a successful role creation is reported + run_line( + ["globus", "gcs", "collection", "role", "create", collection_id, role, user_id], + search_stdout=[ + ("ID", role_id), + ("Role", role), + ("Principal", username), + ], + ) diff --git a/tests/functional/collection/role/test_role_list.py b/tests/functional/collection/role/test_role_list.py index 84a05566d..a8431dc02 100644 --- a/tests/functional/collection/role/test_role_list.py +++ b/tests/functional/collection/role/test_role_list.py @@ -1,6 +1,4 @@ -import uuid - -from globus_sdk.testing import RegisteredResponse, load_response_set +from globus_sdk.testing import load_response_set def test_successful_gcs_collection_role_list( @@ -13,32 +11,11 @@ def test_successful_gcs_collection_role_list( meta = load_response_set("cli.collection_operations").metadata endpoint_id = meta["endpoint_id"] collection_id = meta["mapped_collection_id"] + role = meta["role"] + role_id = meta["role_id"] + user_id = meta["identity_id"] add_gcs_login(endpoint_id) - user_id = str(uuid.UUID(int=2)) - - # mock the responses for the Get Role API (GCS) - RegisteredResponse( - service="gcs", - path="/roles", - json={ - "DATA_TYPE": "result#1.1.0", - "code": "success", - "data": [ - { - "DATA_TYPE": "role#1.0.0", - "collection": f"{collection_id}", - "id": f"{user_id}", - "principal": f"urn:globus:auth:identity:{user_id}", - "role": "administrator", - } - ], - "detail": "success", - "has_next_page": False, - "http_response_code": 200, - }, - ).add() - # Mock the Get Identities API (Auth) # so that CLI output rendering can show a username user_meta = get_identities_mocker.configure_one(id=user_id).metadata @@ -49,8 +26,8 @@ def test_successful_gcs_collection_role_list( run_line( ["globus", "gcs", "collection", "role", "list", collection_id], search_stdout=[ - ("ID", user_id), - ("Role", "administrator"), + ("ID", role_id), + ("Role", role), ("Principal", username), ], ) diff --git a/tests/functional/collection/role/test_role_show.py b/tests/functional/collection/role/test_role_show.py index 33013363f..77d002bde 100644 --- a/tests/functional/collection/role/test_role_show.py +++ b/tests/functional/collection/role/test_role_show.py @@ -1,6 +1,4 @@ -import uuid - -from globus_sdk.testing import RegisteredResponse, load_response_set +from globus_sdk.testing import load_response_set def test_successful_gcs_collection_role_show( @@ -13,24 +11,11 @@ def test_successful_gcs_collection_role_show( meta = load_response_set("cli.collection_operations").metadata endpoint_id = meta["endpoint_id"] collection_id = meta["mapped_collection_id"] + role = meta["role"] + role_id = meta["role_id"] + user_id = meta["identity_id"] add_gcs_login(endpoint_id) - role_id = str(uuid.UUID(int=1)) - user_id = str(uuid.UUID(int=2)) - - # mock the responses for the Get Role API (GCS) - RegisteredResponse( - service="gcs", - path=f"/roles/{role_id}", - json={ - "DATA_TYPE": "role#1.0.0", - "collection": collection_id, - "id": role_id, - "principal": f"urn:globus:auth:identity:{user_id}", - "role": "administrator", - }, - ).add() - # Mock the Get Identities API (Auth) # so that CLI output rendering can show a username user_meta = get_identities_mocker.configure_one(id=user_id).metadata @@ -41,7 +26,8 @@ def test_successful_gcs_collection_role_show( run_line( ["globus", "gcs", "collection", "role", "show", collection_id, role_id], search_stdout=[ - ("Role", "administrator"), + ("ID", role_id), + ("Role", role), ("Principal", username), ], )