diff --git a/changelog.d/20231127_161505_derek_guest_collection_creation_sc_14410.md b/changelog.d/20231127_161505_derek_guest_collection_creation_sc_14410.md new file mode 100644 index 000000000..3767a6b94 --- /dev/null +++ b/changelog.d/20231127_161505_derek_guest_collection_creation_sc_14410.md @@ -0,0 +1,8 @@ + +### Enhancements + +* Added a new command for non-admins to create GCSv5 Guest Collections. + + ``` + globus collection create guest + ``` diff --git a/src/globus_cli/commands/collection/__init__.py b/src/globus_cli/commands/collection/__init__.py index 33a003f73..48179bf68 100644 --- a/src/globus_cli/commands/collection/__init__.py +++ b/src/globus_cli/commands/collection/__init__.py @@ -4,6 +4,7 @@ @group( "collection", lazy_subcommands={ + "create": (".create", "collection_create"), "delete": (".delete", "collection_delete"), "list": (".list", "collection_list"), "show": (".show", "collection_show"), diff --git a/src/globus_cli/commands/collection/_common.py b/src/globus_cli/commands/collection/_common.py new file mode 100644 index 000000000..ab6db185c --- /dev/null +++ b/src/globus_cli/commands/collection/_common.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import globus_sdk + +from globus_cli.termio import Field, formatters +from globus_cli.types import DATA_CONTAINER_T + + +def filter_fields(check_fields: list[Field], data: DATA_CONTAINER_T) -> list[Field]: + return [f for f in check_fields if f.get_value(data) is not None] + + +def standard_collection_fields(auth_client: globus_sdk.AuthClient) -> list[Field]: + from globus_cli.services.gcs import ConnectorIdFormatter + + return [ + Field("Display Name", "display_name"), + Field( + "Owner", + "identity_id", + formatter=formatters.auth.IdentityIDFormatter(auth_client), + ), + Field("ID", "id"), + Field("Collection Type", "collection_type"), + Field("Mapped Collection ID", "mapped_collection_id"), + Field("User Credential ID", "user_credential_id"), + Field("Storage Gateway ID", "storage_gateway_id"), + Field("Connector", "connector_id", formatter=ConnectorIdFormatter()), + Field("Allow Guest Collections", "allow_guest_collections"), + Field("Disable Anonymous Writes", "disable_anonymous_writes"), + Field("High Assurance", "high_assurance"), + Field("Authentication Timeout (Minutes)", "authentication_timeout_mins"), + Field("Multi-factor Authentication", "require_mfa"), + Field("Manager URL", "manager_url"), + Field("HTTPS URL", "https_url"), + Field("TLSFTP URL", "tlsftp_url"), + Field("Force Encryption", "force_encryption"), + Field("Public", "public"), + Field("Organization", "organization"), + Field("Department", "department"), + Field("Keywords", "keywords"), + Field("Description", "description"), + Field("Contact E-mail", "contact_email"), + Field("Contact Info", "contact_info"), + Field("Collection Info Link", "info_link"), + Field("User Message", "user_message"), + Field("User Message Link", "user_message_link"), + ] diff --git a/src/globus_cli/commands/collection/create/__init__.py b/src/globus_cli/commands/collection/create/__init__.py new file mode 100644 index 000000000..e9fa07ad1 --- /dev/null +++ b/src/globus_cli/commands/collection/create/__init__.py @@ -0,0 +1,11 @@ +from globus_cli.parsing import group + + +@group( + "create", + lazy_subcommands={ + "guest": (".guest", "collection_create_guest"), + }, +) +def collection_create() -> None: + """Create a new Collection""" diff --git a/src/globus_cli/commands/collection/create/guest.py b/src/globus_cli/commands/collection/create/guest.py new file mode 100644 index 000000000..bbc29f440 --- /dev/null +++ b/src/globus_cli/commands/collection/create/guest.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import typing as t +import uuid + +import click +import globus_sdk +import globus_sdk.experimental.auth_requirements_error + +from globus_cli.commands.collection._common import ( + filter_fields, + standard_collection_fields, +) +from globus_cli.constants import ExplicitNullType +from globus_cli.endpointish import EntityType +from globus_cli.login_manager import ( + LoginManager, + MissingLoginError, + read_well_known_config, +) +from globus_cli.login_manager.context import LoginContext +from globus_cli.parsing import command, endpointish_params, mutex_option_group +from globus_cli.services.gcs import CustomGCSClient +from globus_cli.termio import TextMode, display + + +@command("guest", short_help="Create a GCSv5 Guest Collection") +@click.argument("MAPPED_COLLECTION_ID", type=click.UUID) +@click.argument("COLLECTION_BASE_PATH", type=str) +@click.option( + "--user-credential-id", + type=click.UUID, + default=None, + help="ID identifying a registered local user to associate with the new collection", +) +@click.option( + "--local-username", + type=str, + default=None, + help=( + "[Alternative to --user-credential-id] Local username to associate with the new" + " collection (must match exactly one pre-registered User Credential ID)" + ), +) +@mutex_option_group("--user-credential-id", "--local-username") +@endpointish_params.create(name="collection") +@click.option( + "--identity-id", + default=None, + help="User who should own the collection (defaults to the current user)", +) +@click.option( + "--public/--private", + "public", + default=True, + help="Set the collection to be public or private", +) +@click.option( + "--enable-https/--disable-https", + "enable_https", + default=None, + help=( + "Explicitly enable or disable HTTPS support (requires a managed endpoint " + "with API v1.1.0)" + ), +) +@LoginManager.requires_login("auth", "transfer") +def collection_create_guest( + login_manager: LoginManager, + *, + mapped_collection_id: uuid.UUID, + collection_base_path: str, + user_credential_id: uuid.UUID | None, + local_username: str | None, + contact_info: str | None | ExplicitNullType, + contact_email: str | None | ExplicitNullType, + default_directory: str | None | ExplicitNullType, + department: str | None | ExplicitNullType, + description: str | None | ExplicitNullType, + display_name: str, + enable_https: bool | None, + force_encryption: bool | None, + identity_id: str | None, + info_link: str | None | ExplicitNullType, + keywords: list[str] | None, + public: bool, + organization: str | None | ExplicitNullType, + user_message: str | None | ExplicitNullType, + user_message_link: str | None | ExplicitNullType, + verify: dict[str, bool], +) -> None: + """ + Create a GCSv5 Guest Collection. + + Create a new guest collection, named DISPLAY_NAME, as a child of + MAPPED_COLLECTION_ID. This new guest collection's file system will be rooted at + COLLECTION_BASE_PATH, a file path on the mapped collection. + """ + gcs_client = login_manager.get_gcs_client( + collection_id=mapped_collection_id, + include_data_access=True, + assert_entity_type=(EntityType.GCSV5_MAPPED,), + ) + + if not user_credential_id: + user_credential_id = _select_user_credential_id( + gcs_client, mapped_collection_id, local_username, identity_id + ) + + converted_kwargs: dict[str, t.Any] = ExplicitNullType.nullify_dict( + { + "collection_base_path": collection_base_path, + "contact_info": contact_info, + "contact_email": contact_email, + "default_directory": default_directory, + "department": department, + "description": description, + "display_name": display_name, + "enable_https": enable_https, + "force_encryption": force_encryption, + "identity_id": identity_id, + "info_link": info_link, + "keywords": keywords, + "mapped_collection_id": mapped_collection_id, + "public": public, + "organization": organization, + "user_credential_id": user_credential_id, + "user_message": user_message, + "user_message_link": user_message_link, + } + ) + converted_kwargs.update(verify) + + try: + res = gcs_client.create_collection( + globus_sdk.GuestCollectionDocument(**converted_kwargs) + ) + except globus_sdk.GCSAPIError as e: + # Detect session timeouts related to HA collections. + # This is a hacky workaround until we have better GARE support across the CLI. + if _is_session_timeout_error(e): + endpoint_id = gcs_client.source_epish.get_collection_endpoint_id() + login_gcs_id = endpoint_id + if gcs_client.source_epish.requires_data_access_scope: + login_gcs_id = f"{endpoint_id}:{mapped_collection_id}" + context = LoginContext( + error_message="Session timeout detected; Re-authentication required.", + login_command=f"globus login --gcs {login_gcs_id} --force", + ) + raise MissingLoginError([endpoint_id], context=context) + raise + + fields = standard_collection_fields(login_manager.get_auth_client()) + display(res, text_mode=TextMode.text_record, fields=filter_fields(fields, res)) + + +def _select_user_credential_id( + gcs_client: CustomGCSClient, + mapped_collection_id: uuid.UUID, + local_username: str | None, + identity_id: str | None, +) -> uuid.UUID: + """ + In the case that the user didn't specify a user credential id, see if we can select + one automatically. + + A User Credential is only eligible if it is the only candidate matching the given + request parameters (which may be omitted). + """ + mapped_collection = gcs_client.get_collection(mapped_collection_id) + storage_gateway_id = mapped_collection["storage_gateway_id"] + + if not identity_id: + user_data = read_well_known_config("auth_user_data", allow_null=False) + identity_id = user_data["sub"] + + # Grab the list of user credentials which match the endpoint, storage gateway, + # identity id, and local username (if specified) + user_creds = [ + user_cred + for user_cred in gcs_client.get_user_credential_list( + storage_gateway=storage_gateway_id + ) + if ( + user_cred["identity_id"] == identity_id + and (local_username is None or user_cred.get("username") == local_username) + ) + ] + + if len(user_creds) > 1: + # Only instruct them to include --local-username if they didn't already + local_username_or = "either --local-username or " if not local_username else "" + raise ValueError( + "More than one gcs user credential valid for creation. " + f"Please specify which user credential you'd like to use with " + f"{local_username_or}--user-credential-id." + ) + if len(user_creds) == 0: + endpoint_id = gcs_client.source_epish.get_collection_endpoint_id() + raise ValueError( + "No valid gcs user credentials discovered.\n\n" + "Please first create a user credential on this endpoint:\n\n" + f"\tCommand: globus endpoint user-credential create ...\n" + f"\tEndpoint ID: {endpoint_id}\n" + f"\tStorage Gateway ID: {storage_gateway_id}\n" + ) + + return uuid.UUID(user_creds[0]["id"]) + + +def _is_session_timeout_error(e: globus_sdk.GCSAPIError) -> bool: + """ + Detect session timeouts related to HA collections. + This is a hacky workaround until we have better GARE support across the CLI. + """ + detail_type = getattr(e, "detail", {}).get("DATA_TYPE") + return ( + e.http_status == 403 + and isinstance(detail_type, str) + and detail_type.startswith("authentication_timeout") + ) diff --git a/src/globus_cli/commands/collection/show.py b/src/globus_cli/commands/collection/show.py index d04687548..44d577231 100644 --- a/src/globus_cli/commands/collection/show.py +++ b/src/globus_cli/commands/collection/show.py @@ -3,51 +3,14 @@ import uuid import click -import globus_sdk +from globus_cli.commands.collection._common import ( + filter_fields, + standard_collection_fields, +) from globus_cli.login_manager import LoginManager from globus_cli.parsing import collection_id_arg, command from globus_cli.termio import Field, TextMode, display, formatters -from globus_cli.types import DATA_CONTAINER_T - - -def _filter_fields(check_fields: list[Field], data: DATA_CONTAINER_T) -> list[Field]: - return [f for f in check_fields if f.get_value(data) is not None] - - -def _get_standard_fields(auth_client: globus_sdk.AuthClient) -> list[Field]: - from globus_cli.services.gcs import ConnectorIdFormatter - - return [ - Field("Display Name", "display_name"), - Field( - "Owner", - "identity_id", - formatter=formatters.auth.IdentityIDFormatter(auth_client), - ), - Field("ID", "id"), - Field("Collection Type", "collection_type"), - Field("Storage Gateway ID", "storage_gateway_id"), - Field("Connector", "connector_id", formatter=ConnectorIdFormatter()), - Field("Allow Guest Collections", "allow_guest_collections"), - Field("Disable Anonymous Writes", "disable_anonymous_writes"), - Field("High Assurance", "high_assurance"), - Field("Authentication Timeout", "authentication_timeout_mins"), - Field("Multi-factor Authentication", "require_mfa"), - Field("Manager URL", "manager_url"), - Field("HTTPS URL", "https_url"), - Field("TLSFTP URL", "tlsftp_url"), - Field("Force Encryption", "force_encryption"), - Field("Public", "public"), - Field("Organization", "organization"), - Field("Department", "department"), - Field("Keywords", "keywords"), - Field("Description", "description"), - Field("Contact E-mail", "contact_email"), - Field("Contact Info", "contact_info"), - Field("Collection Info Link", "info_link"), - ] - PRIVATE_FIELDS: list[Field] = [ Field("Root Path", "root_path"), @@ -87,7 +50,7 @@ def collection_show( gcs_client = login_manager.get_gcs_client(collection_id=collection_id) query_params = {} - fields: list[Field] = _get_standard_fields(login_manager.get_auth_client()) + fields: list[Field] = standard_collection_fields(login_manager.get_auth_client()) if include_private_policies: query_params["include"] = "private_policies" @@ -97,7 +60,7 @@ def collection_show( # walk the list of all known fields and reduce the rendering to only look # for fields which are actually present - real_fields = _filter_fields(fields, res) + real_fields = filter_fields(fields, res) display( res, diff --git a/src/globus_cli/commands/collection/update.py b/src/globus_cli/commands/collection/update.py index 632d5e71b..b3fb3ae10 100644 --- a/src/globus_cli/commands/collection/update.py +++ b/src/globus_cli/commands/collection/update.py @@ -17,7 +17,6 @@ collection_id_arg, command, endpointish_params, - mutex_option_group, nullable_multi_callback, ) from globus_cli.termio import Field, TextMode, display @@ -124,7 +123,6 @@ def get_value(self, data: t.Any) -> t.Any: cls=AnnotatedOption, type_annotation=t.Union[ListType[str], None, ExplicitNullType], ) -@mutex_option_group("--enable-https", "--disable-https") @LoginManager.requires_login("auth", "transfer") def collection_update( login_manager: LoginManager, diff --git a/src/globus_cli/commands/login.py b/src/globus_cli/commands/login.py index 5200a72d4..b714a5039 100644 --- a/src/globus_cli/commands/login.py +++ b/src/globus_cli/commands/login.py @@ -1,9 +1,11 @@ from __future__ import annotations +import typing as t import uuid import click -from globus_sdk.scopes import GCSEndpointScopeBuilder +from click import Context, Parameter +from globus_sdk.scopes import GCSCollectionScopeBuilder, GCSEndpointScopeBuilder from globus_sdk.services.flows import SpecificFlowClient from globus_cli.login_manager import LoginManager, is_client_login @@ -51,6 +53,46 @@ """ +class GCSEndpointType(click.ParamType): + name = "GCS Server" + + def get_type_annotation(self, _: click.Parameter) -> type: + return t.cast(type, t.Union[uuid.UUID, tuple[uuid.UUID, uuid.UUID]]) + + def get_metavar(self, _: t.Optional[click.Parameter]) -> str: + return "[:]" + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> t.Any: + if isinstance(value, uuid.UUID): + return value + elif isinstance(value, tuple) and len(value) == 2: + if isinstance(value[0], uuid.UUID) and isinstance(value[1], uuid.UUID): + return value + + values = value.split(":") + if len(values) < 1 or len(values) > 2: + self.fail( + ( + "Invalid GCS Specification. Must be supplied in the form " + "[:]" + ), + param, + ctx, + ) + try: + endpoint_id = uuid.UUID(values[0]) + except ValueError: + self.fail(f"Endpoint ID ({values[0]}) is not a valid UUID", param, ctx) + try: + collection_id = uuid.UUID(values[1]) if len(values) == 2 else None + except ValueError: + self.fail(f"Collection ID ({values[1]}) is not a valid UUID", param, ctx) + + return endpoint_id if not collection_id else (endpoint_id, collection_id) + + @command( "login", short_help="Log into Globus to get credentials for the Globus CLI", @@ -65,10 +107,12 @@ @click.option( "gcs_servers", "--gcs", - type=click.UUID, + type=GCSEndpointType(), help=( - "A GCS Endpoint ID, for which manage_collections permissions " - "will be requested. This option may be given multiple times" + "A GCS Endpoint ID and optional GCS Mapped Collection ID " + "([:]). For each endpoint, a 'manage_collection' " + "will be added with a dependent 'data_access' scope if the collection id is" + "specified" ), multiple=True, ) @@ -85,7 +129,7 @@ def login_command( no_local_server: bool, force: bool, - gcs_servers: tuple[uuid.UUID, ...], + gcs_servers: tuple[t.Union[uuid.UUID, tuple[uuid.UUID, uuid.UUID]], ...], flow_ids: tuple[uuid.UUID, ...], ) -> None: """ @@ -120,10 +164,17 @@ def login_command( # add GCS servers to LoginManager requirements so that the login check and login # flow will make use of the requested GCS servers if gcs_servers: - for server_id in gcs_servers: + for gcs_server in gcs_servers: + if isinstance(gcs_server, uuid.UUID): + server_id, collection_id = gcs_server, None + else: + server_id, collection_id = gcs_server rs_name = str(server_id) - scopes = [GCSEndpointScopeBuilder(rs_name).manage_collections] - manager.add_requirement(rs_name, scopes) + scope = GCSEndpointScopeBuilder(rs_name).make_mutable("manage_collections") + if collection_id: + data_access = GCSCollectionScopeBuilder(str(collection_id)).data_access + scope.add_dependency(data_access) + manager.add_requirement(rs_name, [str(scope)]) for flow_id in flow_ids: # Rely on the SpecificFlowClient's scope builder. diff --git a/src/globus_cli/endpointish/endpointish.py b/src/globus_cli/endpointish/endpointish.py index 80d3aae3c..3506bc330 100644 --- a/src/globus_cli/endpointish/endpointish.py +++ b/src/globus_cli/endpointish/endpointish.py @@ -83,13 +83,11 @@ def get_gcs_address(self) -> str: @property def requires_data_access_scope(self) -> bool: - if self.entity_type is EntityType.GCSV5_MAPPED: - if self.data.get("high_assurance") is False: - return True - return False + return ( + self.entity_type is EntityType.GCSV5_MAPPED + and self.data.get("high_assurance") is False + ) @property def is_managed(self) -> bool: - if self.data.get("subscription_id") is None: - return False - return True + return self.data.get("subscription_id") is not None diff --git a/src/globus_cli/login_manager/context.py b/src/globus_cli/login_manager/context.py new file mode 100644 index 000000000..8e3d4d940 --- /dev/null +++ b/src/globus_cli/login_manager/context.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class LoginContext: + # A string representing the shell command a user should issue to resolve their + # login-related issue + login_command: str = "globus login" + + # Error message to display if the asserted login fails. + error_message: str | None = None diff --git a/src/globus_cli/login_manager/errors.py b/src/globus_cli/login_manager/errors.py index b30201df7..e41641d9f 100644 --- a/src/globus_cli/login_manager/errors.py +++ b/src/globus_cli/login_manager/errors.py @@ -2,6 +2,7 @@ from globus_cli import utils +from .context import LoginContext from .scopes import CLI_SCOPE_REQUIREMENTS @@ -9,41 +10,32 @@ class MissingLoginError(ValueError): def __init__( self, missing_servers: t.Sequence[str], - *, - assume_gcs: bool = False, - assume_flow: bool = False, + context: LoginContext, ): self.missing_servers = missing_servers - self.assume_gcs = assume_gcs - self.assume_flow = assume_flow - - self.server_names = sorted(_resolve_server_names(missing_servers)) - - server_string = utils.format_list_of_words(*self.server_names) - message_prefix = utils.format_plural_str( - "Missing {login}", - {"login": "logins"}, - len(missing_servers) != 1, - ) - - login_cmd = "globus login" - if assume_gcs: - login_cmd = "globus login " + " ".join( - [f"--gcs {s}" for s in missing_servers] - ) - elif assume_flow: - login_cmd = "globus login " + " ".join( - f"--flow {server}" for server in missing_servers - ) - - self.message = ( - message_prefix + f" for {server_string}, please run:\n\n {login_cmd}\n" - ) + + error_message = context.error_message or self._default_error_message() + + self.message = f"{error_message}\nPlease run:\n\n {context.login_command}\n" super().__init__(self.message) def __str__(self) -> str: return self.message + def _default_error_message(self) -> str: + """ + Default error message if the context doesn't provide one. + + :returns: error message in the format: + "Missing logins for Globus Auth and 12b3a34c-b818-4e5c-87e9-a294f43a845c." + """ + + server_names = sorted(_resolve_server_names(self.missing_servers)) + formatted_server_names = utils.format_list_of_words(*server_names) + + login = "login" if len(self.missing_servers) == 1 else "logins" + return f"Missing {login} for {formatted_server_names}." + def _resolve_server_names(server_names: t.Sequence[str]) -> t.Iterator[str]: for name in server_names: diff --git a/src/globus_cli/login_manager/manager.py b/src/globus_cli/login_manager/manager.py index 09d943e66..5ccd628bd 100644 --- a/src/globus_cli/login_manager/manager.py +++ b/src/globus_cli/login_manager/manager.py @@ -7,9 +7,11 @@ import click import globus_sdk +from globus_sdk.experimental.scope_parser import Scope from globus_sdk.scopes import ( AuthScopes, FlowsScopes, + GCSCollectionScopeBuilder, GCSEndpointScopeBuilder, GroupsScopes, MutableScope, @@ -24,6 +26,7 @@ from .. import version from .auth_flows import do_link_auth_flow, do_local_server_auth_flow from .client_login import get_client_login, is_client_login +from .context import LoginContext from .errors import MissingLoginError from .scopes import CLI_SCOPE_REQUIREMENTS from .tokenstore import ( @@ -34,7 +37,7 @@ from .utils import is_remote_session if t.TYPE_CHECKING: - from ..services.auth import CustomAuthClient + from ..services.auth import ConsentForestResponse, CustomAuthClient from ..services.gcs import CustomGCSClient from ..services.transfer import CustomTransferClient @@ -60,7 +63,7 @@ def add_requirement( @property def login_requirements(self) -> t.Iterator[tuple[str, list[str | MutableScope]]]: for req in CLI_SCOPE_REQUIREMENTS.values(): - yield (req["resource_server"], req["scopes"]) + yield req["resource_server"], req["scopes"] yield from self._nonstatic_requirements.items() @property @@ -93,8 +96,11 @@ def _validate_token(self, token: str) -> bool: def has_login(self, resource_server: str) -> bool: """ - Determines if the user has a valid refresh token for the given - resource server + Determines whether the user + 1. has an active refresh token for the given server in the local tokenstore + which meets all root scope requirements + 2. has sufficient consents for all dependent scope requirements (determined + by a call to Auth Consents API) """ # client identities are always logged in if is_client_login(): @@ -104,37 +110,79 @@ def has_login(self, resource_server: str) -> bool: if tokens is None or "refresh_token" not in tokens: return False - # for resource servers in the static scope set, check that the scope - # requirements are satisfied by the token data - if resource_server in CLI_SCOPE_REQUIREMENTS.resource_servers(): - requirement_data = CLI_SCOPE_REQUIREMENTS.get_by_resource_server( - resource_server - ) + if not self._tokens_meet_static_requirements(resource_server, tokens): + return False + + if not self._tokens_meet_nonstatic_requirements(resource_server, tokens): + return False + + return self._validate_token(tokens["refresh_token"]) + + def _tokens_meet_static_requirements( + self, resource_server: str, tokens: dict[str, t.Any] + ) -> bool: + if resource_server not in CLI_SCOPE_REQUIREMENTS.resource_servers(): + # By definition, if there are no requirements, those requirements are met. + return True + + requirements = CLI_SCOPE_REQUIREMENTS.get_by_resource_server(resource_server) + + # evaluate scope contract version requirements for this service + + # first, fetch the version data and if it is missing, treat it as empty + contract_versions = read_well_known_config("scope_contract_versions") or {} + # determine which version we need, and compare against the version in + # storage with a default of 0 + # if the comparison fails, reject the token as not a valid login for the + # service + version_required = requirements["min_contract_version"] + if contract_versions.get(resource_server, 0) < version_required: + return False + + token_scopes = set(tokens["scope"].split(" ")) + required_scopes: set[str] = set() + for scope in requirements["scopes"]: + if isinstance(scope, str): + required_scopes.add(scope) + else: + required_scopes.add(scope.scope_string) + return required_scopes - token_scopes == set() + + def _tokens_meet_nonstatic_requirements( + self, resource_server: str, tokens: dict[str, t.Any] + ) -> bool: + if resource_server not in self._nonstatic_requirements: + # By definition, if there are no requirements, those requirements are met. + return True + + requirements = self._nonstatic_requirements[resource_server] + + # Parse the requirements into a list of Scope objects + # This may expand the list of requirements if, for instance, a single scope + # string represents multiple roots (eg "openid profile email") + required_scopes: list[Scope] = [] + for scope in requirements: + scope_string = scope if isinstance(scope, str) else str(scope) + required_scopes.extend(Scope.parse(scope_string=scope_string)) + + if not any(scope.dependencies for scope in required_scopes): + # If there are no dependent scopes, simply verify local scope strings match + required_scope_strings = {scope.scope_string for scope in required_scopes} - # evaluate scope contract version requirements for this service - - # first, fetch the version data and if it is missing, treat it as empty - contract_versions = read_well_known_config("scope_contract_versions") or {} - # determine which version we need, and compare against the version in - # storage with a default of 0 - # if the comparison fails, reject the token as not a valid login for the - # service - version_required = requirement_data["min_contract_version"] - if contract_versions.get(resource_server, 0) < version_required: - return False - - token_scopes = set(tokens["scope"].split(" ")) - required_scopes: set[str] = set() - for scope in requirement_data["scopes"]: - if isinstance(scope, str): - required_scopes.add(scope) - else: - required_scopes.add(scope.scope_string) - if required_scopes - token_scopes: - return False - - rt = tokens["refresh_token"] - return self._validate_token(rt) + token_scope_strings = set(tokens["scope"].split(" ")) + return required_scope_strings - token_scope_strings == set() + else: + # If there are dependent scopes all required scope paths are present in the + # user's cached consent forest. + return self._cached_consent_forest.contains_scopes(required_scopes) + + @property + @functools.lru_cache(maxsize=1) # noqa: B019 + def _cached_consent_forest(self) -> ConsentForestResponse: + user_data = read_well_known_config("auth_user_data", allow_null=False) + user_identity_id = user_data["sub"] + + return self.get_auth_client().get_consents(user_identity_id) def run_login_flow( self, @@ -177,18 +225,27 @@ def run_login_flow( def assert_logins( self, *resource_servers: str, - assume_gcs: bool = False, - assume_flow: bool = False, + login_context: LoginContext | None = None, ) -> None: - # determine the set of resource servers missing logins + """ + Verify all registered root & dependent scopes requirements are met for the given + resource servers. + + :param resource_servers: a list of resource servers to check for logins + :param login_context: an optional LoginContext object to use for + custom formatting of error messaging. If omitted, default error messaging + will be used instead. + :raises: a MissingLoginError if any root or dependent scope requirements in the + given resource servers are not met. + """ + login_context = login_context or LoginContext() + + # Determine the set of resource servers still requiring logins. missing_servers = {s for s in resource_servers if not self.has_login(s)} - # if we are missing logins, assemble error text - # text is slightly different for 1, 2, or 3+ missing servers + # If any resource servers do require logins, raise those as a MissingLoginError. if missing_servers: - raise MissingLoginError( - list(missing_servers), assume_gcs=assume_gcs, assume_flow=assume_flow - ) + raise MissingLoginError(list(missing_servers), login_context) @classmethod def requires_login( @@ -331,7 +388,7 @@ def _get_gcs_info( resolved_ep_id = str(endpoint_id) else: # pragma: no cover raise ValueError("Internal Error! collection_id or endpoint_id is required") - return (resolved_ep_id, epish) + return resolved_ep_id, epish def get_specific_flow_client( self, @@ -342,7 +399,12 @@ def get_specific_flow_client( client = globus_sdk.SpecificFlowClient(flow_id, app_name=version.app_name) assert client.scopes is not None self.add_requirement(client.scopes.resource_server, [client.scopes.user]) - self.assert_logins(client.scopes.resource_server, assume_flow=True) + + login_context = LoginContext( + login_command=f"globus login --flow {flow_id}", + error_message="Missing 'user' consent for a flow.", + ) + self.assert_logins(client.scopes.resource_server, login_context=login_context) # Create and assign an authorizer now that scope requirements are registered. client.authorizer = self._get_client_authorizer( @@ -359,25 +421,59 @@ def get_gcs_client( *, collection_id: uuid.UUID | None = None, endpoint_id: uuid.UUID | None = None, + include_data_access: bool = False, + assert_entity_type: tuple[EntityType] | None = None, ) -> CustomGCSClient: + """ + Retrieve a gcs client for either a collection or an endpoint. + + If a user is determined to not have the required consents for the collection or + endpoint, raises a MissingLoginError which includes instructions for + obtaining the required consents. + + :param collection_id: UUID of a mapped or guest collection + :param endpoint_id: UUID of a GCSv5 endpoint + :param include_data_access: Whether to include the data_access scope as a + required dependency if the collection is determined to require it. + :param assert_entity_type: An optional tuple of expected entity types. If + supplied & the entity type does not match, raises a WrongEntityTypeError. + """ from ..services.gcs import CustomGCSClient gcs_id, epish = self._get_gcs_info( collection_id=collection_id, endpoint_id=endpoint_id ) + if assert_entity_type is not None: + epish.assert_entity_type(expect_types=assert_entity_type) + include_data_access = include_data_access and epish.requires_data_access_scope + + if not include_data_access: + # Just require an endpoint:manage_collections scope + scope = GCSEndpointScopeBuilder(gcs_id).make_mutable("manage_collections") + login_context = LoginContext( + login_command=f"globus login --gcs {gcs_id}", + error_message="Missing 'manage_collections' consent on an endpoint.", + ) + else: + # Require an endpoint:manage_collections scope with a dependent + # collection[data_access] scope + scope = GCSEndpointScopeBuilder(gcs_id).make_mutable("manage_collections") + data_access = GCSCollectionScopeBuilder(str(collection_id)).data_access + scope.add_dependency(data_access) + + login_context = LoginContext( + login_command=f"globus login --gcs {gcs_id}:{str(collection_id)}", + error_message="Missing 'data_access' consent on a mapped collection.", + ) - # client identities need to have this scope added as a requirement - # so that they correctly request it when building authorizers - self.add_requirement( - gcs_id, scopes=[GCSEndpointScopeBuilder(gcs_id).manage_collections] - ) - self.assert_logins(gcs_id, assume_gcs=True) + self.add_requirement(gcs_id, scopes=[scope]) + self.assert_logins(gcs_id, login_context=login_context) authorizer = self._get_client_authorizer( gcs_id, no_tokens_msg=( - f"Could not get login data for GCS {gcs_id}. " - f"Try login with '--gcs {gcs_id}' to fix." + f"{login_context.error_message}\n" + f"Please run:\n\n {login_context.login_command}\n" ), ) return CustomGCSClient( diff --git a/tests/conftest.py b/tests/conftest.py index d433caecf..3c54b22df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,6 +70,10 @@ def go_ep2_id(): def _mock_token_response_data(rs_name, scope, token_blob=None): if token_blob is None: token_blob = rs_name.split(".")[0] + if isinstance(scope, list): + # Serialize lists of scopes to a space delimited string to correctly mirror + # auth response structure. + scope = " ".join(scope) return { "scope": scope, "refresh_token": f"{token_blob}RT", @@ -175,7 +179,7 @@ def func(gcs_id): mock_token_res = mock.Mock() mock_token_res.by_resource_server = { gcs_id: _mock_token_response_data( - gcs_id, f"urn:globus:auth:scopes:{gcs_id}:manage_collections" + gcs_id, f"urn:globus:auth:scope:{gcs_id}:manage_collections" ) } test_token_storage.store(mock_token_res) diff --git a/tests/files/api_fixtures/collection_operations.yaml b/tests/files/api_fixtures/collection_operations.yaml index b719a328b..77719036a 100644 --- a/tests/files/api_fixtures/collection_operations.yaml +++ b/tests/files/api_fixtures/collection_operations.yaml @@ -4,8 +4,10 @@ metadata: endpoint_id: "cf37806c-572c-47ff-88e2-511c646ef1a4" gcp_endpoint_id: "06e2c959-d311-4bab-b2ea-25ad77d9fc12" username: "sirosen@globusid.org" + local_username: "recyclops" identity_id: "e926d510-cb98-11e5-a6ac-0b0216052512" gcs_hostname: "abc.xyz.data.globus.org" + guest_display_name: "Happy Fun Guest Collection Name" transfer: - path: /endpoint/1405823f-0597-4a16-b296-46d4f0ae4b15 @@ -30,6 +32,7 @@ transfer: "force_encryption": false, "gcs_manager_url": "https://abc.xyz.data.globus.org", "gcs_version": "5.4.10", + "high_assurance": false, "host_endpoint_id": null, "id": "1405823f-0597-4a16-b296-46d4f0ae4b15", "is_globus_connect": false, @@ -66,6 +69,7 @@ transfer: "force_encryption": false, "gcs_manager_url": "https://abc.xyz.data.globus.org", "gcs_version": "5.4.10", + "high_assurance": false, "host_endpoint_id": "1405823f-0597-4a16-b296-46d4f0ae4b15", "id": "0e4a77f8-b778-4d5c-abaa-e1254e71427f", "is_globus_connect": false, @@ -102,6 +106,7 @@ transfer: "force_encryption": false, "gcs_manager_url": "https://abc.xyz.data.globus.org", "gcs_version": "5.4.10", + "high_assurance": false, "host_endpoint_id": null, "id": "cf37806c-572c-47ff-88e2-511c646ef1a4", "is_globus_connect": false, @@ -164,8 +169,88 @@ auth: } ] } + - path: /v2/api/identities/25de0aed-aa83-4600-a1be-a62a910af116/consents + json: + { + "consents": [ + { + "id": 428930, + "scope_name": "urn:globus:auth:scope:cf37806c-572c-47ff-88e2-511c646ef1a4:manage_collections", + "atomically_revocable": false, + "status": "approved", + "created": "2023-11-20T17:33:38.678819+00:00", + "effective_identity": "25de0aed-aa83-4600-a1be-a62a910af116", + "auto_approved": false, + "last_used": "2023-11-21T02:20:57.672942+00:00", + "client": "19addcb5-3e0d-473f-9615-2cbcd275c93e", + "allows_refresh": true, + "updated": "2023-11-20T17:33:38.678819+00:00", + "scope": "98cafe1e-b9d0-4748-9089-46575e36a7f0", + "dependency_path": [ + 428930 + ] + }, + { + "id": 428931, + "scope_name": "https://auth.globus.org/scopes/1405823f-0597-4a16-b296-46d4f0ae4b15/data_access", + "atomically_revocable": false, + "status": "approved", + "created": "2023-11-20T17:33:38.678819+00:00", + "effective_identity": "25de0aed-aa83-4600-a1be-a62a910af116", + "auto_approved": false, + "last_used": "2023-11-21T02:20:57.672942+00:00", + "client": "3c713c0d-c207-4b86-a7c1-b9050aae0a8d", + "allows_refresh": true, + "updated": "2023-11-20T17:33:38.678819+00:00", + "scope": "bb3865ac-b228-454c-ae00-7c783fea2d2a", + "dependency_path": [ + 428930, + 428931 + ] + } + ] + } gcs: + - path: /collections + method: post + json: + { + "DATA_TYPE": "result#1.0.0", + "code": "success", + "detail": "success", + "http_response_code": 200, + "data": [ + { + "DATA_TYPE": "collection#1.0.0", + "authentication_assurance_timeout": 30, + "authentication_timeout_mins": 30, + "collection_type": "guest", + "contact_email": "user@example.com", + "default_directory": "/", + "department": "globus", + "description": "example collection", + "display_name": "Happy Fun Guest Collection Name", + "force_encryption": true, + "gcs_version": "5.4.10", + "host_endpoint_id": "1405823f-0597-4a16-b296-46d4f0ae4b15", + "https_url": "http://example.com", + "id": "0e4a77f8-b778-4d5c-abaa-e1254e71427f", + "identity_id": "e926d510-cb98-11e5-a6ac-0b0216052512", + "info_link": "http://example.com", + "is_globus_connect": false, + "keywords": "example", + "manager_url": "https://gcs.data.globus.org/", + "non_functional": false, + "organization": "uchicago", + "owner_id": "cf37806c-572c-47ff-88e2-511c646ef1a4", + "policies": {}, + "public": true, + "root_path": "/", + "tlsftp_url": "http://example.com" + } + ] + } - path: /collections method: get json: @@ -319,3 +404,29 @@ gcs: } ] } + - path: /user_credentials + method: get + json: + { + "DATA_TYPE": "result#1.0.0", + "code": "Success", + "detail": "Success", + "has_next_page": false, + "http_response_code": 200, + "data": [ + { + "DATA_TYPE": "user_credential#1.0.0", + "connector_id": "145812c8-decc-41f1-83cf-bb2a85a2a70b", + "display_name": "sirosen-main", + "id": "4ac6579b-9c4d-4a45-a794-adcb86eeb868", + "identity_id": "25de0aed-aa83-4600-a1be-a62a910af116", + "invalid": false, + "policies": { + "DATA_TYPE": "posix_user_credential_policies#1.0.0" + }, + "provisioned": true, + "storage_gateway_id": "6ebdbaa3-9c60-4637-9d26-1bcfa3921f6b", + "username": "recyclops" + } + ] + } diff --git a/tests/functional/collection/test_collection_create_guest.py b/tests/functional/collection/test_collection_create_guest.py new file mode 100644 index 000000000..d2957554a --- /dev/null +++ b/tests/functional/collection/test_collection_create_guest.py @@ -0,0 +1,230 @@ +import uuid +from copy import copy + +import pytest +import requests +import responses +from globus_sdk._testing import load_response_set +from globus_sdk.config import get_service_url + +from globus_cli.endpointish import EntityType + + +def test_guest_collection_create(run_line, add_gcs_login): + meta = load_response_set("cli.collection_operations").metadata + endpoint_id = meta["endpoint_id"] + mapped_collection_id = meta["mapped_collection_id"] + guest_collection_id = meta["guest_collection_id"] + display_name = meta["guest_display_name"] + add_gcs_login(endpoint_id) + + params = f"{mapped_collection_id} /home/ '{display_name}'" + result = run_line(f"globus collection create guest {params}") + + assert display_name in result.output + assert guest_collection_id in result.output + assert "guest" in result.output + + +def test_guest_collection_create__when_missing_login(run_line): + meta = load_response_set("cli.collection_operations").metadata + endpoint_id = meta["endpoint_id"] + mapped_collection_id = meta["mapped_collection_id"] + display_name = meta["guest_display_name"] + + params = f"{mapped_collection_id} /home/ '{display_name}'" + result = run_line(f"globus collection create guest {params}", assert_exit_code=4) + + assert "MissingLoginError" in result.stderr + assert f"globus login --gcs {endpoint_id}:{mapped_collection_id}" in result.stderr + + +def test_guest_collection_create__when_missing_consent( + run_line, add_gcs_login, mock_user_data +): + """ + Creating guest collections may require a data_access scope consent (based on whether + the mapped collection is HA or not). + + This test simulates a situation where + 1. The data_access scope is required + 2. The user has valid token already for the `collection_manager` scope but hasn't + consented to `collection_manager[data_access]` + """ + meta = load_response_set("cli.collection_operations").metadata + endpoint_id = meta["endpoint_id"] + mapped_collection_id = meta["mapped_collection_id"] + display_name = meta["guest_display_name"] + add_gcs_login(endpoint_id) + + # Remove any `data_access` consents from the consents response + consent_route = ( + f"{get_service_url('auth')}v2/api/identities/{mock_user_data['sub']}/consents" + ) + registered_consents = requests.get(consent_route).json()["consents"] + responses.replace( + "GET", + consent_route, + json={ + "consents": [ + consent + for consent in registered_consents + if not consent["scope_name"].endswith("data_access") + ] + }, + ) + + params = f"{mapped_collection_id} /home/ '{display_name}'" + result = run_line(f"globus collection create guest {params}", assert_exit_code=4) + + assert "MissingLoginError" in result.stderr + assert f"globus login --gcs {endpoint_id}:{mapped_collection_id}" in result.stderr + + +@pytest.mark.parametrize("explicit_local_username", (True, False)) +def test_guest_collection_create__when_multiple_matching_user_credentials( + explicit_local_username, run_line, add_gcs_login +): + """ + The requisite API call for command test requires an explicit `user_credential_id`. + The CLI supports this but additionally tries to implicitly supply one if omitted + from existing user credentials. + + This test verifies that the CLI will error with a helpful message if multiple + valid user credentials are discovered when `--user-credential-id` is omitted. + """ + meta = load_response_set("cli.collection_operations").metadata + endpoint_id = meta["endpoint_id"] + mapped_collection_id = meta["mapped_collection_id"] + display_name = meta["guest_display_name"] + local_username = meta["local_username"] + gcs_hostname = meta["gcs_hostname"] + add_gcs_login(endpoint_id) + + # Duplicate the single registered user credential + user_credentials_route = f"https://{gcs_hostname}/api/user_credentials" + registered_credential_resp = requests.get(user_credentials_route).json() + registered_credential_copy = copy(registered_credential_resp["data"][0]) + registered_credential_copy["id"] = str(uuid.uuid4()) + registered_credential_copy["display_name"] = "backup" + + registered_credential_resp["data"].append(registered_credential_copy) + responses.replace("GET", user_credentials_route, json=registered_credential_resp) + + params = f"{mapped_collection_id} /home/ '{display_name}'" + if explicit_local_username: + params += f" --local-username {local_username}" + result = run_line(f"globus collection create guest {params}", assert_exit_code=1) + + assert "More than one gcs user credential valid for creation." in result.stderr + suggestion = "Please try again supplying " + if explicit_local_username: + suggestion += "either --local-username or " + suggestion += "--user-credential-id." + + +def test_guest_collection_create__when_no_matching_user_credentials( + run_line, add_gcs_login +): + """ + The requisite API call for command test requires an explicit `user_credential_id`. + The CLI supports this but additionally tries to implicitly supply one if omitted + from existing user credentials. + + This test verifies that the CLI will error with a helpful message if no valid + user credentials are discovered when `--user-credential-id` is omitted. + """ + meta = load_response_set("cli.collection_operations").metadata + endpoint_id = meta["endpoint_id"] + mapped_collection_id = meta["mapped_collection_id"] + display_name = meta["guest_display_name"] + gcs_hostname = meta["gcs_hostname"] + add_gcs_login(endpoint_id) + + # Remove any registered user credentials + user_credentials_route = f"https://{gcs_hostname}/api/user_credentials" + registered_credential_resp = requests.get(user_credentials_route).json() + registered_credential_resp["data"] = [] + responses.replace("GET", user_credentials_route, json=registered_credential_resp) + + params = f"{mapped_collection_id} /home/ '{display_name}'" + result = run_line(f"globus collection create guest {params}", assert_exit_code=1) + + assert "No valid gcs user credentials discovered." in result.stderr + assert endpoint_id in result.stderr + assert "globus endpoint user-credential create" in result.stderr + + +@pytest.mark.parametrize( + "collection_type", [e for e in EntityType if e is not EntityType.GCSV5_MAPPED] +) +def test_guest_collection_create__when_mapped_collection_type_is_unsupported( + collection_type, + run_line, +): + meta = load_response_set("cli.collection_operations").metadata + mapped_collection_id = meta["mapped_collection_id"] + display_name = meta["guest_display_name"] + + get_endpoint_route = ( + f"{get_service_url('transfer')}v0.10/endpoint/{mapped_collection_id}" + ) + get_endpoint_resp = requests.get(get_endpoint_route).json() + get_endpoint_resp["entity_type"] = collection_type.value + responses.replace("GET", get_endpoint_route, json=get_endpoint_resp) + + params = f"{mapped_collection_id} /home/ '{display_name}'" + result = run_line(f"globus collection create guest {params}", assert_exit_code=3) + + assert f"Expected {mapped_collection_id} to be a" in result.stderr + msg = f"Instead, found it was of type '{EntityType.nice_name(collection_type)}'." + assert msg in result.stderr + + +def test_guest_collection_create__when_session_times_out_against_ha_mapped_collection( + run_line, + mock_user_data, + add_gcs_login, +): + meta = load_response_set("cli.collection_operations").metadata + mapped_collection_id = meta["mapped_collection_id"] + display_name = meta["guest_display_name"] + gcs_hostname = meta["gcs_hostname"] + endpoint_id = meta["endpoint_id"] + add_gcs_login(endpoint_id) + + create_guest_collection_route = f"https://{gcs_hostname}/api/collections" + responses.replace( + "POST", + create_guest_collection_route, + status=403, + json={ + "DATA_TYPE": "result#1.0.0", + "code": "permission_denied", + "detail": { + "DATA_TYPE": "authentication_timeout#1.1.0", + "high_assurance": True, + "identities": [mock_user_data["sub"]], + "require_mfa": False, + }, + "has_next_page": False, + "http_response_code": 403, + "message": ( + "You must reauthenticate one of your identities (sirosen@globus.org) " + "in order to access this resource" + ), + }, + ) + + get_endpoint_route = ( + f"{get_service_url('transfer')}v0.10/endpoint/{mapped_collection_id}" + ) + get_endpoint_resp = requests.get(get_endpoint_route).json() + get_endpoint_resp["high_assurance"] = True + responses.replace("GET", get_endpoint_route, json=get_endpoint_resp) + + params = f"{mapped_collection_id} /home/ '{display_name}'" + result = run_line(f"globus collection create guest {params}", assert_exit_code=4) + + assert "Session timeout detected; Re-authentication required." in result.stderr + assert f"globus login --gcs {endpoint_id} --force" in result.stderr diff --git a/tests/functional/collection/test_collection_delete.py b/tests/functional/collection/test_collection_delete.py index 94e88facb..3651c76db 100644 --- a/tests/functional/collection/test_collection_delete.py +++ b/tests/functional/collection/test_collection_delete.py @@ -35,7 +35,7 @@ def test_collection_delete_missing_login(run_line, base_command): result = run_line(f"{base_command} {cid}", assert_exit_code=4) assert "success" not in result.output - assert f"Missing login for {epid}" in result.stderr + assert "Missing 'manage_collections' consent on an endpoint." in result.stderr assert f" globus login --gcs {epid}" in result.stderr diff --git a/tests/unit/test_login_manager.py b/tests/unit/test_login_manager.py index 2d03c6761..c06fc8509 100644 --- a/tests/unit/test_login_manager.py +++ b/tests/unit/test_login_manager.py @@ -13,6 +13,7 @@ MissingLoginError, compute_timer_scope, ) +from globus_cli.login_manager.context import LoginContext from globus_cli.login_manager.scopes import ( CLI_SCOPE_REQUIREMENTS, CURRENT_SCOPE_CONTRACT_VERSION, @@ -130,7 +131,7 @@ def dummy_command(login_manager): dummy_command() assert str(ex.value) == ( - "Missing login for c.globus.org, please run:\n\n globus login\n" + "Missing login for c.globus.org.\nPlease run:\n\n globus login\n" ) @@ -145,7 +146,7 @@ def dummy_command(login_manager): dummy_command() assert str(ex.value) == ( - "Missing logins for A is for Awesome and B Cool, please run:" + "Missing logins for A is for Awesome and B Cool.\nPlease run:" "\n\n globus login\n" ) @@ -161,7 +162,7 @@ def dummy_command(login_manager): dummy_command() assert str(ex.value) == ( - "Missing login for A is for Awesome, please run:\n\n globus login\n" + "Missing login for A is for Awesome.\nPlease run:\n\n globus login\n" ) @@ -176,7 +177,7 @@ def dummy_command(login_manager): dummy_command() assert str(ex.value) == ( - "Missing login for A is for Awesome, please run:\n\n globus login\n" + "Missing login for A is for Awesome.\nPlease run:\n\n globus login\n" ) @@ -191,8 +192,8 @@ def dummy_command(login_manager): dummy_command() assert re.match( - "Missing logins for ..globus.org and ..globus.org, " - "please run:\n\n globus login\n", + "Missing logins for ..globus.org and ..globus.org.\n" + "Please run:\n\n globus login\n", str(ex.value), ) for server in ("c.globus.org", "d.globus.org"): @@ -228,30 +229,22 @@ def dummy_command(login_manager): assert dummy_command() -def test_flow_error_message(patched_tokenstorage): +def test_login_manager_respects_context_error_message(patched_tokenstorage): dummy_id = str(uuid.uuid1()) @LoginManager.requires_login() def dummy_command(login_manager): - login_manager.assert_logins(dummy_id, assume_flow=True) - - with pytest.raises(MissingLoginError) as excinfo: - dummy_command() - - assert f"globus login --flow {dummy_id}" in str(excinfo.value) - - -def test_gcs_error_message(patched_tokenstorage): - dummy_id = str(uuid.uuid1()) - - @LoginManager.requires_login() - def dummy_command(login_manager): - login_manager.assert_logins(dummy_id, assume_gcs=True) + login_context = LoginContext( + login_command="globus try-again", + error_message="Well that went pretty poorly!", + ) + login_manager.assert_logins(dummy_id, login_context=login_context) with pytest.raises(MissingLoginError) as excinfo: dummy_command() - assert f"globus login --gcs {dummy_id}" in str(excinfo.value) + expected = "Well that went pretty poorly!\nPlease run:\n\n globus try-again\n" + assert expected == str(excinfo.value) def test_client_login_two_requirements(client_login):