From 663e85a366b7ec63f531b496cc4424eaf7fc2c92 Mon Sep 17 00:00:00 2001 From: derek-globus <113056046+derek-globus@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:56:23 -0500 Subject: [PATCH 1/2] Add support for attaching authentication policies to new flows (#1197) Co-authored-by: Ada <107940310+ada-globus@users.noreply.github.com> --- ..._sc_45516_flows_authentication_policies.md | 11 +++++ pyproject.toml | 2 +- src/globus_cli/commands/flows/_fields.py | 3 ++ src/globus_cli/commands/flows/create.py | 21 ++++++++- src/globus_cli/commands/flows/update.py | 20 +++++++++ src/globus_cli/commands/login.py | 2 + tests/functional/flows/test_create_flow.py | 2 + tests/functional/flows/test_start_flow.py | 44 ++++++++++++++++++- tests/functional/flows/test_update_flow.py | 2 + 9 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 changelog.d/20251021_160154_derek_sc_45516_flows_authentication_policies.md diff --git a/changelog.d/20251021_160154_derek_sc_45516_flows_authentication_policies.md b/changelog.d/20251021_160154_derek_sc_45516_flows_authentication_policies.md new file mode 100644 index 000000000..69524fca4 --- /dev/null +++ b/changelog.d/20251021_160154_derek_sc_45516_flows_authentication_policies.md @@ -0,0 +1,11 @@ + +### Enhancements + +* Added a new keyword `authentication-policy-id` to the `globus flows create ...` and + `globus flows update ...` commands to allow creation of high assurance flows. + + * Note that a policy must be set at flow creation time in order to create a high + assurance flow. + A policy cannot be added to an existing non-high assurance flow. + A policy can, however, be replaced with a different high assurance policy if one + was already associated with the flow. diff --git a/pyproject.toml b/pyproject.toml index 84dbb7312..f4ed6e22b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "globus-sdk==4.0.1", + "globus-sdk==4.1.0", "click>=8.1.4,<8.4", "jmespath==1.0.1", "packaging>=17.0", diff --git a/src/globus_cli/commands/flows/_fields.py b/src/globus_cli/commands/flows/_fields.py index 6bc26a3f6..06fdbc653 100644 --- a/src/globus_cli/commands/flows/_fields.py +++ b/src/globus_cli/commands/flows/_fields.py @@ -36,6 +36,7 @@ def flow_format_fields( delimiter=", ", ) csv_list = formatters.ArrayFormatter(delimiter=", ") + fuzzy_bool = formatters.FuzzyBool return [ Field("Flow ID", "id"), @@ -44,6 +45,8 @@ def flow_format_fields( Field("Description", "description"), Field("Keywords", "keywords", formatter=csv_list), Field("Owner", "flow_owner", formatter=principal), + Field("High Assurance", "authentication_policy_id", formatter=fuzzy_bool), + Field("Authentication Policy ID", "authentication_policy_id"), Field("Subscription ID", "subscription_id"), Field("Created At", "created_at", formatter=formatters.Date), Field("Updated At", "updated_at", formatter=formatters.Date), diff --git a/src/globus_cli/commands/flows/create.py b/src/globus_cli/commands/flows/create.py index a44e25d8b..7736e48db 100644 --- a/src/globus_cli/commands/flows/create.py +++ b/src/globus_cli/commands/flows/create.py @@ -16,7 +16,7 @@ ) from globus_cli.commands.flows._fields import flow_format_fields from globus_cli.login_manager import LoginManager -from globus_cli.parsing import JSONStringOrFile, ParsedJSONData, command +from globus_cli.parsing import OMITTABLE_UUID, JSONStringOrFile, ParsedJSONData, command from globus_cli.termio import display from globus_cli.types import JsonValue @@ -64,6 +64,23 @@ to create a list of run monitors. """, ) +@click.option( + "--authentication-policy-id", + help=""" + A Globus Auth authentication policy ID. + The provided policy must require high-assurance. + Assigning an authentication policy enforces additional + authentication requirements, e.g., requiring an MFA or recent login, + on most API interactions with a flow and its runs. + + Flow policies are only semi-mutable. + Attempting to either remove a policy or add one when previously unset + will fail. Replacing an existing authentication policy with a new one, + however, is allowed. + """, + type=OMITTABLE_UUID, + default=globus_sdk.MISSING, +) @click.option( "--subscription-id", help="Set a subscription_id for the flow, marking it as subscription tier.", @@ -84,6 +101,7 @@ def create_command( keywords: tuple[str, ...], run_managers: tuple[str, ...], run_monitors: tuple[str, ...], + authentication_policy_id: uuid.UUID | globus_sdk.MissingType, subscription_id: uuid.UUID | None, ) -> None: """ @@ -136,6 +154,7 @@ def create_command( keywords=list(keywords), run_managers=list(run_managers), run_monitors=list(run_monitors), + authentication_policy_id=authentication_policy_id, subscription_id=subscription_id, ) diff --git a/src/globus_cli/commands/flows/update.py b/src/globus_cli/commands/flows/update.py index b8544bbeb..cc041881c 100644 --- a/src/globus_cli/commands/flows/update.py +++ b/src/globus_cli/commands/flows/update.py @@ -16,6 +16,7 @@ from globus_cli.login_manager import LoginManager from globus_cli.parsing import ( OMITTABLE_STRING, + OMITTABLE_UUID, CommaDelimitedList, JSONStringOrFile, ParsedJSONData, @@ -143,6 +144,23 @@ """, default=globus_sdk.MISSING, ) +@click.option( + "--authentication-policy-id", + help=""" + A Globus Auth authentication policy ID. + The provided policy must require high-assurance. + Assigning an authentication policy enforces additional + authentication requirements, e.g., requiring an MFA or recent login, + on most API interactions with a flow and its runs. + + Flow policies are only semi-mutable. + Attempting to either remove a policy or add one when previously unset + will fail. Replacing an existing authentication policy with a new one, + however, is allowed. + """, + type=OMITTABLE_UUID, + default=globus_sdk.MISSING, +) @subscription_id_option @LoginManager.requires_login("flows") def update_command( @@ -162,6 +180,7 @@ def update_command( run_monitors: list[str] | globus_sdk.MissingType, keywords: list[str] | globus_sdk.MissingType, subscription_id: uuid.UUID | t.Literal["DEFAULT"] | globus_sdk.MissingType, + authentication_policy_id: uuid.UUID | globus_sdk.MissingType, ) -> None: """ Update a flow. @@ -200,6 +219,7 @@ def update_command( run_monitors=run_monitors, keywords=keywords, subscription_id=subscription_id or globus_sdk.MISSING, + authentication_policy_id=authentication_policy_id, ) fields = flow_format_fields(auth_client, res.data) diff --git a/src/globus_cli/commands/login.py b/src/globus_cli/commands/login.py index d16537525..a68fac318 100644 --- a/src/globus_cli/commands/login.py +++ b/src/globus_cli/commands/login.py @@ -214,6 +214,8 @@ def login_command( manager.add_requirement(rs_name, [scope]) for flow_id in flow_ids: + # TODO - evaluate flow authorization requirements dynamically once + # `validate_run` has been updated to properly expose session requirements. # Rely on the SpecificFlowClient's scope builder. flow_scopes = SpecificFlowClient(flow_id).scopes assert flow_scopes is not None diff --git a/tests/functional/flows/test_create_flow.py b/tests/functional/flows/test_create_flow.py index 837247d3d..d248a80ea 100644 --- a/tests/functional/flows/test_create_flow.py +++ b/tests/functional/flows/test_create_flow.py @@ -63,6 +63,8 @@ def test_create_flow_text_output(run_line, load_identities_for_flow): "Administrators", "Starters", "Viewers", + "High Assurance", + "Authentication Policy ID", "Run Managers", "Run Monitors", } diff --git a/tests/functional/flows/test_start_flow.py b/tests/functional/flows/test_start_flow.py index 6fe766ec5..1322e0e15 100644 --- a/tests/functional/flows/test_start_flow.py +++ b/tests/functional/flows/test_start_flow.py @@ -2,10 +2,11 @@ import json import re +import uuid import pytest import responses -from globus_sdk.testing import load_response +from globus_sdk.testing import RegisteredResponse, load_response def test_start_flow_text_output(run_line, add_flow_login, load_identities_for_flow_run): @@ -77,6 +78,47 @@ def _get_output_value(name, output): return match.group("value") +def test_start_flow_prompts_session_reconsent_on_gare(run_line, add_flow_login): + """ + When an HA flow is started but a session requirement is not met, ensure we properly + instruct the user to update their session based on the supplied GARE. + """ + flow_id = str(uuid.uuid4()) + required_policy_id = str(uuid.uuid4()) + + add_flow_login(flow_id) + + RegisteredResponse( + service="flows", + path=f"/flows/{flow_id}/run", + method="POST", + status=403, + json={ + "code": "AuthenticationPolicyRequired", + "authorization_parameters": { + "session_message": ( + "Globus Flows detected an unsatisfied session policy for this " + "resource." + ), + "session_required_policies": [required_policy_id], + }, + "error": { + "code": "AUTHENTICATION_POLICY_REQUIRED", + "detail": ( + "You do not have the necessary permissions to perform this action " + "on the flow with id value 50d4ecb4-206b-4669-8c99-c18b05f30e7d. " + "Missing permissions: RUN." + ), + }, + "debug_id": "34a8a8ad-580f-4c44-a411-7e0fa05df370", + }, + ).add() + + result = run_line(f"globus flows start {flow_id} --input {{}}", assert_exit_code=4) + + assert f"globus session update --policy '{required_policy_id}'" in result.stdout + + def test_start_flow_rejects_non_object_input(run_line, add_flow_login): # setup test requirements for success to ensure that the test won't be sensitive to # the order in which checks which happen diff --git a/tests/functional/flows/test_update_flow.py b/tests/functional/flows/test_update_flow.py index 39484401c..949874af5 100644 --- a/tests/functional/flows/test_update_flow.py +++ b/tests/functional/flows/test_update_flow.py @@ -63,6 +63,8 @@ def test_update_flow_text_output(run_line, load_identities_for_flow): "Administrators", "Starters", "Viewers", + "High Assurance", + "Authentication Policy ID", "Run Managers", "Run Monitors", } From 64249a7b80b7de9a39d0e679ef5c13c85d46e0ca Mon Sep 17 00:00:00 2001 From: derek-globus Date: Wed, 22 Oct 2025 16:30:01 -0500 Subject: [PATCH 2/2] Added globus session update --scope --- ..._162752_derek_session_update_more_stuff.md | 5 ++ src/globus_cli/commands/flows/start.py | 51 +++++++++++++++---- src/globus_cli/commands/session/update.py | 9 ++++ .../hooks/auth_requirements.py | 2 +- src/globus_cli/exception_handling/messages.py | 27 ++++++---- tests/functional/flows/test_start_flow.py | 9 ++-- tests/functional/flows/test_update_flow.py | 2 + tests/functional/test_session_update.py | 31 ++++++++--- 8 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 changelog.d/20251022_162752_derek_session_update_more_stuff.md diff --git a/changelog.d/20251022_162752_derek_session_update_more_stuff.md b/changelog.d/20251022_162752_derek_session_update_more_stuff.md new file mode 100644 index 000000000..87165c5e1 --- /dev/null +++ b/changelog.d/20251022_162752_derek_session_update_more_stuff.md @@ -0,0 +1,5 @@ + +### Enhancements + +* Added the parameter `globus session update --scope` to specify additional scopes + to request when updating session tokens. diff --git a/src/globus_cli/commands/flows/start.py b/src/globus_cli/commands/flows/start.py index f36cc38c3..0beae7b2c 100644 --- a/src/globus_cli/commands/flows/start.py +++ b/src/globus_cli/commands/flows/start.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import os import string import typing as t @@ -7,6 +8,7 @@ import click import globus_sdk +from globus_sdk.gare import to_gare from globus_cli._click_compat import shim_get_metavar from globus_cli.commands.flows._fields import flow_run_format_fields @@ -21,6 +23,7 @@ ) from globus_cli.termio import display from globus_cli.types import JsonValue +from globus_cli.utils import CLIAuthRequirementsError if t.TYPE_CHECKING: from click.shell_completion import CompletionItem @@ -250,16 +253,46 @@ def start_command( flow_client = login_manager.get_specific_flow_client(flow_id) auth_client = login_manager.get_auth_client() - - response = flow_client.run_flow( - body=input_document_json, - label=label, - tags=list(tags), - run_managers=list(managers), - run_monitors=list(monitors), - activity_notification_policy=notify_policy, - ) + flow_scope = flow_client.scopes.user.scope_string + + with scope_injected_into_raised_gares(flow_scope): + response = flow_client.run_flow( + body=input_document_json, + label=label, + tags=list(tags), + run_managers=list(managers), + run_monitors=list(monitors), + activity_notification_policy=notify_policy, + ) fields = flow_run_format_fields(auth_client, response.data) display(response, fields=fields, text_mode=display.RECORD) + + +@contextlib.contextmanager +def scope_injected_into_raised_gares(scope: str) -> t.Iterator[None]: + """ + A context manager which catches GARE-convertible GlobusAPIErrors and reraises + as an exact copy of the original GARE but with the supplied scope injected. + + Due to SDK Error mutability limitations, these GAREs are raised as + CLIAuthRequirementsErrors. All other categories of errors raise as normal. + + :param scope: The scope to inject into any caught GAREs. + :raises CLIAuthRequirementsError: with modified GARE if a GARE-compatible + GlobusAPIError is caught. + """ + + try: + yield + except globus_sdk.GlobusAPIError as e: + gare = to_gare(e) + if gare: + scopes = gare.authorization_parameters.required_scopes or [] + if scope not in scopes: + scopes.append(scope) + + gare.authorization_parameters.required_scopes = scopes + raise CLIAuthRequirementsError("", gare=gare) + raise diff --git a/src/globus_cli/commands/session/update.py b/src/globus_cli/commands/session/update.py index 523a7e0d7..049e7efef 100644 --- a/src/globus_cli/commands/session/update.py +++ b/src/globus_cli/commands/session/update.py @@ -85,6 +85,13 @@ def _update_session_params_identities_case( @click.argument( "identities", type=IdentityType(allow_domains=True), nargs=-1, required=False ) +@click.option( + "--scope", + "scopes", + multiple=True, + type=str, + help="One or more additional scope strings to request during authentication", +) @click.option( "--policy", "policies", @@ -101,6 +108,7 @@ def session_update( login_manager: LoginManager, *, identities: tuple[ParsedIdentity, ...], + scopes: tuple[str, ...], no_local_server: bool, policies: list[str] | None, all: bool, @@ -156,5 +164,6 @@ def session_update( "\nYou have successfully updated your CLI session.\n" "Use 'globus session show' to see the updated session." ), + scopes=list(scopes), session_params=session_params, ) diff --git a/src/globus_cli/exception_handling/hooks/auth_requirements.py b/src/globus_cli/exception_handling/hooks/auth_requirements.py index 7067ed8b9..4f8442cca 100644 --- a/src/globus_cli/exception_handling/hooks/auth_requirements.py +++ b/src/globus_cli/exception_handling/hooks/auth_requirements.py @@ -40,7 +40,7 @@ def handle_internal_auth_requirements( ) def session_hook(exception: globus_sdk.GlobusAPIError) -> None: """ - Expects an exception with a valid authorization_paramaters info field. + Expects an exception with a valid authorization_parameters info field. """ message = exception.info.authorization_parameters.session_message if message: diff --git a/src/globus_cli/exception_handling/messages.py b/src/globus_cli/exception_handling/messages.py index c2a4c83d7..6e124c0c1 100644 --- a/src/globus_cli/exception_handling/messages.py +++ b/src/globus_cli/exception_handling/messages.py @@ -8,7 +8,7 @@ import os import click -import globus_sdk +import globus_sdk.gare from globus_cli.login_manager import is_client_login from globus_cli.types import JsonValue @@ -107,9 +107,11 @@ def emit_session_update_message( policies: list[str] | None, identities: list[str] | None, domains: list[str] | None, + scopes: list[str] | None = None, message: str = DEFAULT_SESSION_REAUTH_MESSAGE, ) -> None: click.echo(message) + scope_args = "".join(f" --scope '{s}'" for s in scopes or ()) if identities or domains: # check both values in this assignment to convince mypy of correctness @@ -119,18 +121,18 @@ def emit_session_update_message( update_target = " ".join(target_list) click.echo( "\nPlease run:\n\n" - f" globus session update {update_target}\n\n" + f" globus session update {update_target}{scope_args}\n\n" "to re-authenticate with the required identities." ) elif policies: click.echo( "\nPlease run:\n\n" - f" globus session update --policy '{','.join(policies)}'\n\n" + f" globus session update --policy '{','.join(policies)}'{scope_args}\n\n" "to re-authenticate with the required identities." ) else: click.echo( - '\nPlease use "globus session update" to re-authenticate ' + f'\nPlease use "globus session update {scope_args}" to re-authenticate ' "with specific identities." ) @@ -155,22 +157,25 @@ def emit_message_for_gare( gare: globus_sdk.gare.GARE, message: str | None = None ) -> None: required_scopes = gare.authorization_parameters.required_scopes - if required_scopes: - emit_consent_required_message( - required_scopes=required_scopes, - message=message or DEFAULT_CONSENT_REAUTH_MESSAGE, - ) - session_policies = gare.authorization_parameters.session_required_policies session_identities = gare.authorization_parameters.session_required_identities session_domains = gare.authorization_parameters.session_required_single_domain - if session_policies or session_identities or session_domains: + + requires_update = bool(session_policies or session_identities or session_domains) + + if requires_update: emit_session_update_message( policies=session_policies, identities=session_identities, domains=session_domains, + scopes=required_scopes, message=message or DEFAULT_SESSION_REAUTH_MESSAGE, ) + elif required_scopes: + emit_consent_required_message( + required_scopes=required_scopes, + message=message or DEFAULT_CONSENT_REAUTH_MESSAGE, + ) def pretty_json(data: JsonValue, compact: bool = False) -> str: diff --git a/tests/functional/flows/test_start_flow.py b/tests/functional/flows/test_start_flow.py index 1322e0e15..125cd91da 100644 --- a/tests/functional/flows/test_start_flow.py +++ b/tests/functional/flows/test_start_flow.py @@ -6,6 +6,7 @@ import pytest import responses +from globus_sdk.scopes import SpecificFlowScopes from globus_sdk.testing import RegisteredResponse, load_response @@ -84,7 +85,8 @@ def test_start_flow_prompts_session_reconsent_on_gare(run_line, add_flow_login): instruct the user to update their session based on the supplied GARE. """ flow_id = str(uuid.uuid4()) - required_policy_id = str(uuid.uuid4()) + flow_scope = SpecificFlowScopes(flow_id).user + policy_id = str(uuid.uuid4()) add_flow_login(flow_id) @@ -100,7 +102,7 @@ def test_start_flow_prompts_session_reconsent_on_gare(run_line, add_flow_login): "Globus Flows detected an unsatisfied session policy for this " "resource." ), - "session_required_policies": [required_policy_id], + "session_required_policies": [policy_id], }, "error": { "code": "AUTHENTICATION_POLICY_REQUIRED", @@ -116,7 +118,8 @@ def test_start_flow_prompts_session_reconsent_on_gare(run_line, add_flow_login): result = run_line(f"globus flows start {flow_id} --input {{}}", assert_exit_code=4) - assert f"globus session update --policy '{required_policy_id}'" in result.stdout + login_cmd = f"globus session update --policy '{policy_id}' --scope '{flow_scope}'" + assert login_cmd in result.stdout def test_start_flow_rejects_non_object_input(run_line, add_flow_login): diff --git a/tests/functional/flows/test_update_flow.py b/tests/functional/flows/test_update_flow.py index 949874af5..b66e50a4f 100644 --- a/tests/functional/flows/test_update_flow.py +++ b/tests/functional/flows/test_update_flow.py @@ -58,6 +58,8 @@ def test_update_flow_text_output(run_line, load_identities_for_flow): "Keywords", "Owner", "Subscription ID", + "High Assurance", + "Authentication Policy ID", "Created At", "Updated At", "Administrators", diff --git a/tests/functional/test_session_update.py b/tests/functional/test_session_update.py index 50ab5a18a..9a67a5b0d 100644 --- a/tests/functional/test_session_update.py +++ b/tests/functional/test_session_update.py @@ -55,11 +55,18 @@ def test_username_flow(run_line, userinfo_mocker, mock_remote_session, mock_link assert call_kwargs["session_params"]["session_required_identities"] == user_id -def test_domain_flow(run_line, userinfo_mocker, mock_remote_session, mock_link_flow): +@pytest.mark.parametrize("with_scopes", [True, False]) +def test_domain_flow( + run_line, userinfo_mocker, mock_remote_session, mock_link_flow, with_scopes +): mock_remote_session.return_value = True userinfo_mocker.configure_unlinked() - result = run_line("globus session update uchicago.edu") + scopes_args = "" + if with_scopes: + scopes_args = " --scope urn:globus:auth:scope:example.api:all --scope foo" + + result = run_line(f"globus session update uchicago.edu{scopes_args}") assert "You have successfully updated your CLI session." in result.output @@ -73,8 +80,9 @@ def test_domain_flow(run_line, userinfo_mocker, mock_remote_session, mock_link_f ) +@pytest.mark.parametrize("with_scopes", [True, False]) def test_all_flow( - run_line, userinfo_mocker, mock_remote_session, mock_local_server_flow + run_line, userinfo_mocker, mock_remote_session, mock_local_server_flow, with_scopes ): mock_remote_session.return_value = False meta = userinfo_mocker.configure( @@ -84,7 +92,11 @@ def test_all_flow( ids = [x["sub"] for x in meta["identity_set"]] - result = run_line("globus session update --all") + scopes_args = "" + if with_scopes: + scopes_args = " --scope urn:globus:auth:scope:example.api:all --scope foo" + + result = run_line(f"globus session update --all{scopes_args}") assert "You have successfully updated your CLI session." in result.output @@ -97,11 +109,18 @@ def test_all_flow( ) == set(ids) -def test_policy_flow(run_line, userinfo_mocker, mock_remote_session, mock_link_flow): +@pytest.mark.parametrize("with_scopes", [True, False]) +def test_policy_flow( + run_line, userinfo_mocker, mock_remote_session, mock_link_flow, with_scopes +): mock_remote_session.return_value = True userinfo_mocker.configure_unlinked() - result = run_line("globus session update --policy foo,bar") + scopes_args = "" + if with_scopes: + scopes_args = " --scope urn:globus:auth:scope:example.api:all --scope foo" + + result = run_line(f"globus session update --policy foo,bar{scopes_args}") assert "You have successfully updated your CLI session." in result.output