Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
39495c3
Adding in `collection role create`
jkube-globus Sep 23, 2025
9a5d427
Merge branch 'main' into SC-38762_Collection_Role_Create
jkube-globus Sep 23, 2025
cb68ac3
Updated testing as well as formatting in create.py
jkube-globus Sep 24, 2025
ee4569e
Correcting test_role_delete.py and testin test_role_create.py change
jkube-globus Sep 24, 2025
4c09912
Switched to returning the role ID only
jkube-globus Sep 27, 2025
524f90b
Adding Changelog entry
jkube-globus Sep 27, 2025
cb39e0d
Removing unused variable 'identity_username'
jkube-globus Sep 27, 2025
36d7287
Correcting type hints
jkube-globus Oct 3, 2025
1be8820
Merge branch 'main' into SC-38762_Collection_Role_Create
jkube-globus Oct 16, 2025
dbfb4cf
Adding in missing import of annotations
jkube-globus Oct 16, 2025
1a97849
Merge branch 'globus:main' into SC-38762_Collection_Role_Create
jkube-globus Oct 21, 2025
a96a780
Switching display to a fieldset from text
jkube-globus Oct 21, 2025
8e97d2d
Merge branch 'SC-38762_Collection_Role_Create' of github.com:jkube-gl…
jkube-globus Oct 21, 2025
b8e9b1c
Correcting tests
jkube-globus Nov 10, 2025
3524f18
Merge branch 'main' into SC-38762_Collection_Role_Create
jkube-globus Nov 10, 2025
2ff8829
Correcting tests
jkube-globus Nov 10, 2025
6f86379
Correcting tests
jkube-globus Nov 10, 2025
8daa6c5
Making principal argument optional and looking up currently logged in…
jkube-globus Nov 10, 2025
a479543
De-conflcting git pull
jkube-globus Nov 10, 2025
8bd9bc7
Adding verbiage for PRINCIPAL argument handling [default vs explicit …
jkube-globus Nov 10, 2025
37e9570
Making collection roles classier with _fields.py
jkube-globus Nov 12, 2025
02d161e
Updating tests and collection_operations.yaml to include and auth res…
jkube-globus Nov 13, 2025
1dd8494
Adding additional Collection roles routes in support of testing and s…
jkube-globus Nov 13, 2025
ad29988
Update src/globus_cli/commands/collection/role/show.py
jkube-globus Nov 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Enhancements

* Add a new command for interacting with collection roles,
`globus gcs collection role create`
1 change: 1 addition & 0 deletions src/globus_cli/commands/collection/role/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
lazy_subcommands={
"list": (".list", "list_command"),
"show": (".show", "show_command"),
"create": (".create", "create_command"),
"delete": (".delete", "delete_command"),
},
)
Expand Down
39 changes: 39 additions & 0 deletions src/globus_cli/commands/collection/role/_fields.py
Original file line number Diff line number Diff line change
@@ -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"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me why this additional formatter exists. Could you help me understand why it's here?

By my understanding, all three of these calls pass strings, which are then iterated on with an attempt to parse each character in those strings:

for value in values:
try:
principal, principal_type = self.parse(value)
except ValueError:
pass
else:
if principal_type == "identity":
self.resolved_ids.add(principal)

Since no single character will successfully parse as a principal URN, I'd expect the three calls to do nothing.

I see it used below in a field where it is formatting the principal field in a response. Would a PrincipalURNFormatter, which is what was in use in role list prior to this PR, not suffice?



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),
]
85 changes: 85 additions & 0 deletions src/globus_cli/commands/collection/role/create.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have two notes on this being an optional argument.

The first is a general "CLI feel" item: if it's optional, I think it should be an option, e.g. --principal.
It's good for any individual CLI tool to establish some sort of style guide to provide a uniform experience. In globus-cli, we try to keep things 100% mapped to "all required inputs are arguments, all optional inputs are options". I should probably add this to the contrib docs.

The second is... Should this be optional? I'm not convinced that's the correct way to represent the GCS API. I think defaulting to the current user is a very surprising behavior: since the API itself is for manipulating roles, the current user must have some role already. In all likelihood, a user running role create is creating roles for other users and groups.

In summary:

  • We should choose @click.option("--principal") or @click.argument("PRINCIPAL") (no required=False)
  • My current inclination is that @click.argument("PRINCIPAL") is most appropriate

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the first point, option = optional; argument = required.

The second point is a change that I suggested to James. I'll ask in the gcs devchat; seems like a pretty domain specific usage question.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like a pretty domain specific usage question.

Maybe this is where our perspectives diverge. I don't think it's purely domain specific.

I think we should consider this in the context of the following commands:

  • globus endpoint permission create
  • globus endpoint role create
  • globus flows update -- specifically the role options
  • globus group {join,leave,invite}
  • globus group member {add,approve,invite,reject,remove}
  • globus search index role create

Of these, globus group join, globus group leave, globus group invite accept, and globus group invite decline all lookup the current user, and the rest require a user or group as input. globus flows update is the only one I would consider inherently dissimilar from this API.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gcs weighed in, let's go back to your original behavior James (no defaulting to the current user). Sorry for the flip-flop.


From Jason:

Insist on an explicit identity_id or username
that makes it consistent with the gcs cli
admins omitting identity_id can (1) lead to confusing about which identity id will be used and (2) support tickets when things don't behave as expected
But also, in order to run collection role create you already need a role to allow the operation, so the operation is a bit redundant

@click.option(
"--principal-type",
type=click.Choice(["identity", "group"]),
help="Qualifier to specify what type of principal (identity or group) is provided.",
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

For any other reviewers, this --principal-type phrasing is already in use elsewhere in the CLI, and I think we should apply it here (as currently in the PR) for uniformity.

That said, I'm very much interested in developing a better interface for passing these inputs. I think we could design something as a replacement which takes URNs, short-forms like group:<id>, and usernames.

@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)
13 changes: 4 additions & 9 deletions src/globus_cli/commands/collection/role/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
)
14 changes: 5 additions & 9 deletions src/globus_cli/commands/collection/role/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
)
78 changes: 76 additions & 2 deletions tests/files/api_fixtures/collection_operations.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions tests/functional/collection/role/test_role_create.py
Original file line number Diff line number Diff line change
@@ -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),
],
)
35 changes: 6 additions & 29 deletions tests/functional/collection/role/test_role_list.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this moved from being a code construction here to being defined in the YAML fixture files.
Can we revert this change, and the one in the role show test, and leave these data alone?

I've been trying to migrate CLI and SDK off of the YAML fixtures, and onto the pattern used here of imperatively defined test data. It's a slow transition because we can't pull support all at once (or it would break a lot of tests).

Ideally we would not make any additions to the fixture files, but I won't insist on that for all new work at this stage. For now, I just don't want us to migrate things which are moving away from that paradigm "backwards", in the wrong direction.

Note

My rationale for getting away from the YAML is that it has in practice not worked out very well for extensibility and has created some brittle dependencies between test files. Most notably, when multiple distinct files are coordinated, this is done by carefully keeping IDs in sync between distinct files. The newer way to manage data dependencies I've worked up is to construct configurators like the get_identities_mocker (seen below this comment). It still doesn't cover all needs, but I'm interested in growing that suite of tools.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely and will do!

# 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
Expand All @@ -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),
],
)
Loading
Loading