diff --git a/changelog.d/20240229_094608_ada_support_client_creds_in_timer_create.md b/changelog.d/20240229_094608_ada_support_client_creds_in_timer_create.md new file mode 100644 index 000000000..e7109dda1 --- /dev/null +++ b/changelog.d/20240229_094608_ada_support_client_creds_in_timer_create.md @@ -0,0 +1,3 @@ +### Enhancements + +* Add support for client credentials in `globus timer create transfer`. diff --git a/src/globus_cli/commands/timer/create/transfer.py b/src/globus_cli/commands/timer/create/transfer.py index 310efa456..8177bea16 100644 --- a/src/globus_cli/commands/timer/create/transfer.py +++ b/src/globus_cli/commands/timer/create/transfer.py @@ -161,10 +161,9 @@ def transfer_command( If you use `--batch` and supply a SOURCE_PATH and/or DEST_PATH via the commandline, these paths will be used as dir prefixes to any paths read from the `--batch` input. """ - from globus_cli.services.transfer import add_batch_to_transfer_data, autoactivate + from globus_cli.services.transfer import add_batch_to_transfer_data auth_client = login_manager.get_auth_client() - timer_client = login_manager.get_timer_client() transfer_client = login_manager.get_transfer_client() source_endpoint, cmd_source_path = source @@ -218,13 +217,6 @@ def transfer_command( now = datetime.datetime.now().isoformat() name = f"CLI Created Timer [{now}]" - # Check endpoint activation, figure out scopes needed. - - # the autoactivate helper may present output and exit in the case of v4 endpoints - # which need activation (e.g. OA4MP) - autoactivate(transfer_client, source_endpoint, if_expires_in=86400) - autoactivate(transfer_client, dest_endpoint, if_expires_in=86400) - # check if either source or dest requires the data_access scope, and if so # prompt the user to go through the requisite login flow source_epish = Endpointish(source_endpoint, transfer_client=transfer_client) @@ -238,36 +230,33 @@ def transfer_command( # this list will only be populated *if* one of the two endpoints requires # data_access, so if it's empty, we can skip any handling if needs_data_access: - # if the user is using client credentials, we cannot support the incremental - # auth step in the current implementation - # - # TODO: think through how we can use the client creds to request the - # requisite token in this case; it should be possible - if is_client_login(): - raise click.UsageError( - "Unsupported operation. When using client credentials, " - "'globus timer create transfer' does not currently support " - "collections which use the data_access scope: " - f"{','.join(needs_data_access)}" - ) - - request_data_access = _derive_needed_scopes(auth_client, needs_data_access) - - if request_data_access: - scope_request_opts = " ".join( - f"--timer-data-access '{target}'" for target in request_data_access - ) - click.echo( - f"""\ -A collection you are trying to use in this timer requires you to grant consent -for the Globus CLI to access it. -Please run - - globus session consent {scope_request_opts} - -to login with the required scopes.""" - ) - click.get_current_context().exit(4) + scopes_needed = _derive_needed_scopes(needs_data_access) + # If it's not a client login, we need to check + # that the user has the required scopes + if not is_client_login(): + request_data_access = _derive_missing_scopes(auth_client, scopes_needed) + + if request_data_access: + scope_request_opts = " ".join( + f"--timer-data-access '{target}'" for target in request_data_access + ) + click.echo( + f"""\ + A collection you are trying to use in this timer requires you to grant consent + for the Globus CLI to access it. + Please run + + globus session consent {scope_request_opts} + + to login with the required scopes.""" + ) + click.get_current_context().exit(4) + + # Otherwise, add requirements to the LoginManager + login_manager.add_requirement( + globus_sdk.TimerClient.scopes.resource_server, + scopes=list(scopes_needed.values()), + ) transfer_data = globus_sdk.TransferData( source_endpoint=source_endpoint, @@ -296,23 +285,16 @@ def transfer_command( else: # unreachable raise NotImplementedError() + timer_client = login_manager.get_timer_client() body = globus_sdk.TransferTimer(name=name, schedule=schedule, body=transfer_data) response = timer_client.create_timer(body) display(response["timer"], text_mode=TextMode.text_record, fields=FORMAT_FIELDS) def _derive_needed_scopes( - auth_client: CustomAuthClient, needs_data_access: list[str], -) -> list[str]: - # read the identity ID stored from the login flow - user_identity_id = get_current_identity_id() - - # get the user's Globus CLI consents - consents = auth_client.get_consents(user_identity_id) - - # check the 'needs_data_access' scope names against the 3rd-order dependencies - # of the Timer scope and record the names of the ones which we need to request +) -> dict[str, MutableScope]: + # Render the fully nested scope strings for each target scopes_needed = {} for target in needs_data_access: target_scope = GCSCollectionScopeBuilder(target).data_access @@ -322,7 +304,21 @@ def _derive_needed_scopes( globus_sdk.TransferClient.scopes.all, target_scope, ) + return scopes_needed + +def _derive_missing_scopes( + auth_client: CustomAuthClient, + scopes_needed: dict[str, MutableScope], +) -> list[str]: + # read the identity ID stored from the login flow + user_identity_id = get_current_identity_id() + + # get the user's Globus CLI consents + consents = auth_client.get_consents(user_identity_id) + + # check the 'needs_data_access' scope names against the 3rd-order dependencies + # of the Timer scope and record the names of the ones which we need to request will_request_data_access: list[str] = [] for name, scope_object in scopes_needed.items(): if not consents.contains_scopes([scope_object]): diff --git a/tests/functional/timer/test_transfer_create.py b/tests/functional/timer/test_transfer_create.py index 8863ca94e..3d4352c39 100644 --- a/tests/functional/timer/test_transfer_create.py +++ b/tests/functional/timer/test_transfer_create.py @@ -443,7 +443,9 @@ def test_timer_creation_errors_on_data_access_with_client_creds( src = make_non_ha_mapped_collection() dst = ep_for_timer - result = run_line( + setup_timer_consent_tree_response("fake_client_id", src) + + run_line( [ "globus", "timer", @@ -455,7 +457,7 @@ def test_timer_creation_errors_on_data_access_with_client_creds( "--interval", "60m", ], - assert_exit_code=2, ) - assert "Unsupported operation." in result.stderr + req = get_last_request() + assert req.url.startswith("https://timer")