diff --git a/data_safe_haven/commands/admin.py b/data_safe_haven/commands/admin.py deleted file mode 100644 index cb53828b21..0000000000 --- a/data_safe_haven/commands/admin.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Command-line application for performing administrative tasks""" - -import pathlib -from typing import Annotated - -import typer - -from .admin_add_users import admin_add_users -from .admin_list_users import admin_list_users -from .admin_register_users import admin_register_users -from .admin_remove_users import admin_remove_users -from .admin_unregister_users import admin_unregister_users - -admin_command_group = typer.Typer() - - -@admin_command_group.command(help="Add users to a deployed Data Safe Haven.") -def add_users( - csv: Annotated[ - pathlib.Path, - typer.Argument( - help="A CSV file containing details of users to add.", - ), - ], -) -> None: - admin_add_users(csv) - - -@admin_command_group.command(help="List users from a deployed Data Safe Haven.") -def list_users() -> None: - admin_list_users() - - -@admin_command_group.command( - help="Register existing users with a deployed Secure Research Environment." -) -def register_users( - usernames: Annotated[ - list[str], - typer.Option( - "--username", - "-u", - help="Username of a user to register with this SRE. [*may be specified several times*]", - ), - ], - sre: Annotated[ - str, - typer.Argument( - help="The name of the SRE to add the users to.", - ), - ], -) -> None: - admin_register_users(usernames, sre) - - -@admin_command_group.command( - help="Remove existing users from a deployed Data Safe Haven." -) -def remove_users( - usernames: Annotated[ - list[str], - typer.Option( - "--username", - "-u", - help="Username of a user to remove from this Data Safe Haven. [*may be specified several times*]", - ), - ], -) -> None: - admin_remove_users(usernames) - - -@admin_command_group.command(help="Unregister existing users from a deployed SRE.") -def unregister_users( - usernames: Annotated[ - list[str], - typer.Option( - "--username", - "-u", - help="Username of a user to unregister from this SRE. [*may be specified several times*]", - ), - ], - sre: Annotated[ - str, - typer.Argument( - help="The name of the SRE to unregister the users from.", - ), - ], -) -> None: - admin_unregister_users(usernames, sre) diff --git a/data_safe_haven/commands/admin_add_users.py b/data_safe_haven/commands/admin_add_users.py deleted file mode 100644 index cad97bf100..0000000000 --- a/data_safe_haven/commands/admin_add_users.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Add users to a deployed Data Safe Haven""" - -import pathlib - -from data_safe_haven.administration.users import UserHandler -from data_safe_haven.config import Config, DSHPulumiConfig -from data_safe_haven.context import ContextSettings -from data_safe_haven.exceptions import DataSafeHavenError -from data_safe_haven.external import GraphApi - - -def admin_add_users(csv_path: pathlib.Path) -> None: - """Add users to a deployed Data Safe Haven""" - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote(context) - - shm_name = context.shm_name - - try: - # Load GraphAPI - graph_api = GraphApi( - tenant_id=config.shm.entra_tenant_id, - default_scopes=[ - "Group.Read.All", - "User.ReadWrite.All", - "UserAuthenticationMethod.ReadWrite.All", - ], - ) - - # Add users to SHM - users = UserHandler(context, config, pulumi_config, graph_api) - users.add(csv_path) - except DataSafeHavenError as exc: - msg = f"Could not add users to Data Safe Haven '{shm_name}'.\n{exc}" - raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/admin_list_users.py b/data_safe_haven/commands/admin_list_users.py deleted file mode 100644 index 9b0fc7dea4..0000000000 --- a/data_safe_haven/commands/admin_list_users.py +++ /dev/null @@ -1,30 +0,0 @@ -"""List users from a deployed Data Safe Haven""" - -from data_safe_haven.administration.users import UserHandler -from data_safe_haven.config import Config, DSHPulumiConfig -from data_safe_haven.context import ContextSettings -from data_safe_haven.exceptions import DataSafeHavenError -from data_safe_haven.external import GraphApi - - -def admin_list_users() -> None: - """List users from a deployed Data Safe Haven""" - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote(context) - - shm_name = context.shm_name - - try: - # Load GraphAPI - graph_api = GraphApi( - tenant_id=config.shm.entra_tenant_id, - default_scopes=["Directory.Read.All", "Group.Read.All"], - ) - - # List users from all sources - users = UserHandler(context, config, pulumi_config, graph_api) - users.list() - except DataSafeHavenError as exc: - msg = f"Could not list users for Data Safe Haven '{shm_name}'.\n{exc}" - raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/admin_register_users.py b/data_safe_haven/commands/admin_register_users.py deleted file mode 100644 index 23d51d2069..0000000000 --- a/data_safe_haven/commands/admin_register_users.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Register existing users with a deployed SRE""" - -from data_safe_haven.administration.users import UserHandler -from data_safe_haven.config import Config, DSHPulumiConfig -from data_safe_haven.context import ContextSettings -from data_safe_haven.exceptions import DataSafeHavenError -from data_safe_haven.external import GraphApi -from data_safe_haven.functions import sanitise_sre_name -from data_safe_haven.utility import LoggingSingleton - - -def admin_register_users( - usernames: list[str], - sre: str, -) -> None: - """Register existing users with a deployed SRE""" - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote(context) - - shm_name = context.shm_name - # Use a JSON-safe SRE name - sre_name = sanitise_sre_name(sre) - - try: - # Check that SRE option has been provided - if not sre_name: - msg = "SRE name must be specified." - raise DataSafeHavenError(msg) - LoggingSingleton().info( - f"Preparing to register {len(usernames)} user(s) with SRE '{sre_name}'" - ) - - # Load GraphAPI - graph_api = GraphApi( - tenant_id=config.shm.entra_tenant_id, - default_scopes=["Group.ReadWrite.All", "GroupMember.ReadWrite.All"], - ) - - # List users - users = UserHandler(context, config, pulumi_config, graph_api) - available_usernames = users.get_usernames_entra_id() - usernames_to_register = [] - for username in usernames: - if username in available_usernames: - usernames_to_register.append(username) - else: - LoggingSingleton().error( - f"Username '{username}' does not belong to this Data Safe Haven deployment." - " Please use 'dsh users add' to create it." - ) - users.register(sre_name, usernames_to_register) - except DataSafeHavenError as exc: - msg = f"Could not register users from Data Safe Haven '{shm_name}' with SRE '{sre_name}'.\n{exc}" - raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/admin_remove_users.py b/data_safe_haven/commands/admin_remove_users.py deleted file mode 100644 index 387655e567..0000000000 --- a/data_safe_haven/commands/admin_remove_users.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Remove existing users from a deployed Data Safe Haven""" - -from data_safe_haven.administration.users import UserHandler -from data_safe_haven.config import Config, DSHPulumiConfig -from data_safe_haven.context import ContextSettings -from data_safe_haven.exceptions import DataSafeHavenError -from data_safe_haven.external import GraphApi - - -def admin_remove_users( - usernames: list[str], -) -> None: - """Remove existing users from a deployed Data Safe Haven""" - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote(context) - - shm_name = context.shm_name - - try: - # Load GraphAPI - graph_api = GraphApi( - tenant_id=config.shm.entra_tenant_id, - default_scopes=["User.ReadWrite.All"], - ) - - # Remove users from SHM - if usernames: - users = UserHandler(context, config, pulumi_config, graph_api) - users.remove(usernames) - except DataSafeHavenError as exc: - msg = f"Could not remove users from Data Safe Haven '{shm_name}'.\n{exc}" - raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/admin_unregister_users.py b/data_safe_haven/commands/admin_unregister_users.py deleted file mode 100644 index f5aa2aae82..0000000000 --- a/data_safe_haven/commands/admin_unregister_users.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Unregister existing users from a deployed SRE""" - -from data_safe_haven.administration.users import UserHandler -from data_safe_haven.config import Config, DSHPulumiConfig -from data_safe_haven.context import ContextSettings -from data_safe_haven.exceptions import DataSafeHavenError -from data_safe_haven.external import GraphApi -from data_safe_haven.functions import sanitise_sre_name -from data_safe_haven.utility import LoggingSingleton - - -def admin_unregister_users( - usernames: list[str], - sre: str, -) -> None: - """Unregister existing users from a deployed SRE""" - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote(context) - - shm_name = context.shm_name - sre_name = sanitise_sre_name(sre) - - try: - # Check that SRE option has been provided - if not sre_name: - msg = "SRE name must be specified." - raise DataSafeHavenError(msg) - LoggingSingleton().info( - f"Preparing to unregister {len(usernames)} users with SRE '{sre_name}'" - ) - - # Load GraphAPI - graph_api = GraphApi( - tenant_id=config.shm.entra_tenant_id, - default_scopes=["Group.ReadWrite.All", "GroupMember.ReadWrite.All"], - ) - - # List users - users = UserHandler(context, config, pulumi_config, graph_api) - available_usernames = users.get_usernames_entra_id() - usernames_to_unregister = [] - for username in usernames: - if username in available_usernames: - usernames_to_unregister.append(username) - else: - LoggingSingleton().error( - f"Username '{username}' does not belong to this Data Safe Haven deployment." - " Please use 'dsh users add' to create it." - ) - for group_name in ( - f"{sre_name} Users", - f"{sre_name} Privileged Users", - f"{sre_name} Administrators", - ): - users.unregister(group_name, usernames_to_unregister) - except DataSafeHavenError as exc: - msg = f"Could not unregister users from Data Safe Haven '{shm_name}' with SRE '{sre_name}'.\n{exc}" - raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/cli.py b/data_safe_haven/commands/cli.py index cbd49c4812..7e312c8b5c 100644 --- a/data_safe_haven/commands/cli.py +++ b/data_safe_haven/commands/cli.py @@ -9,11 +9,11 @@ from data_safe_haven.exceptions import DataSafeHavenError from data_safe_haven.utility import LoggingSingleton -from .admin import admin_command_group from .config import config_command_group from .context import context_command_group -from .deploy import deploy_command_group -from .teardown import teardown_command_group +from .shm import shm_command_group +from .sre import sre_command_group +from .users import users_command_group # Create the application application = typer.Typer( @@ -64,9 +64,9 @@ def callback( # Register command groups application.add_typer( - admin_command_group, - name="admin", - help="Perform administrative tasks for a Data Safe Haven deployment.", + users_command_group, + name="users", + help="Manage the users of a Data Safe Haven deployment.", ) application.add_typer( config_command_group, @@ -77,14 +77,14 @@ def callback( context_command_group, name="context", help="Manage Data Safe Haven contexts." ) application.add_typer( - deploy_command_group, - name="deploy", - help="Deploy a Data Safe Haven component.", + shm_command_group, + name="shm", + help="Manage Data Safe Haven SHM infrastructure.", ) application.add_typer( - teardown_command_group, - name="teardown", - help="Tear down a Data Safe Haven component.", + sre_command_group, + name="sre", + help="Manage Data Safe Haven SRE infrastructure.", ) diff --git a/data_safe_haven/commands/shm.py b/data_safe_haven/commands/shm.py new file mode 100644 index 0000000000..a4b228e85f --- /dev/null +++ b/data_safe_haven/commands/shm.py @@ -0,0 +1,117 @@ +"""Command-line application for managing SHM infrastructure.""" + +from typing import Annotated, Optional + +import typer + +from data_safe_haven.config import Config, DSHPulumiConfig +from data_safe_haven.context import ContextSettings +from data_safe_haven.exceptions import DataSafeHavenError, DataSafeHavenInputError +from data_safe_haven.external import GraphApi +from data_safe_haven.infrastructure import SHMProjectManager + +shm_command_group = typer.Typer() + + +@shm_command_group.command() +def deploy( + force: Annotated[ + Optional[bool], # noqa: UP007 + typer.Option( + "--force", + "-f", + help="Force this operation, cancelling any others that are in progress.", + ), + ] = None, +) -> None: + """Deploy a Safe Haven Management environment.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote_or_create( + context, encrypted_key=None, projects={} + ) + + try: + # Connect to GraphAPI interactively + graph_api = GraphApi( + tenant_id=config.shm.entra_tenant_id, + default_scopes=[ + "Application.ReadWrite.All", + "Domain.ReadWrite.All", + "Group.ReadWrite.All", + ], + ) + verification_record = graph_api.add_custom_domain(config.shm.fqdn) + + # Initialise Pulumi stack + stack = SHMProjectManager( + context=context, + config=config, + pulumi_config=pulumi_config, + create_project=True, + ) + # Set Azure options + stack.add_option("azure-native:location", context.location, replace=False) + stack.add_option( + "azure-native:subscriptionId", + config.azure.subscription_id, + replace=False, + ) + stack.add_option("azure-native:tenantId", config.azure.tenant_id, replace=False) + # Add necessary secrets + stack.add_secret( + "verification-azuread-custom-domain", verification_record, replace=False + ) + + # Deploy Azure infrastructure with Pulumi + if force is None: + stack.deploy() + else: + stack.deploy(force=force) + + # Add the SHM domain to Entra ID as a custom domain + graph_api.verify_custom_domain( + config.shm.fqdn, + stack.output("networking")["fqdn_nameservers"], + ) + except DataSafeHavenError as exc: + # Note, would like to exit with a non-zero code here. + # However, typer.Exit does not print the exception tree which is very unhelpful + # for figuring out what went wrong. + # print("Could not deploy Data Safe Haven Management environment.") + # raise typer.Exit(code=1) from exc + msg = f"Could not deploy Data Safe Haven Management environment.\n{exc}" + raise DataSafeHavenError(msg) from exc + finally: + # Upload Pulumi config to blob storage + pulumi_config.upload(context) + + +@shm_command_group.command() +def teardown() -> None: + """Tear down a deployed a Safe Haven Management environment.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote(context) + + try: + # Remove infrastructure deployed with Pulumi + try: + stack = SHMProjectManager( + context=context, + config=config, + pulumi_config=pulumi_config, + ) + stack.teardown() + except Exception as exc: + msg = f"Unable to teardown Pulumi infrastructure.\n{exc}" + raise DataSafeHavenInputError(msg) from exc + + # Remove information from config file + del pulumi_config[context.shm_name] + + # Upload Pulumi config to blob storage + pulumi_config.upload(context) + except DataSafeHavenError as exc: + msg = f"Could not teardown Safe Haven Management environment.\n{exc}" + raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/deploy.py b/data_safe_haven/commands/sre.py similarity index 68% rename from data_safe_haven/commands/deploy.py rename to data_safe_haven/commands/sre.py index 8e88408696..6e8aefed14 100644 --- a/data_safe_haven/commands/deploy.py +++ b/data_safe_haven/commands/sre.py @@ -1,4 +1,4 @@ -"""Command-line application for deploying a Data Safe Haven component, delegating the details to a subcommand""" +"""Command-line application for managing SRE infrastructure.""" from typing import Annotated, Optional @@ -6,92 +6,18 @@ from data_safe_haven.config import Config, DSHPulumiConfig from data_safe_haven.context import ContextSettings -from data_safe_haven.exceptions import DataSafeHavenError +from data_safe_haven.exceptions import DataSafeHavenError, DataSafeHavenInputError from data_safe_haven.external import GraphApi from data_safe_haven.functions import sanitise_sre_name from data_safe_haven.infrastructure import SHMProjectManager, SREProjectManager from data_safe_haven.provisioning import SREProvisioningManager from data_safe_haven.utility import LoggingSingleton -deploy_command_group = typer.Typer() +sre_command_group = typer.Typer() -@deploy_command_group.command() -def shm( - force: Annotated[ - Optional[bool], # noqa: UP007 - typer.Option( - "--force", - "-f", - help="Force this operation, cancelling any others that are in progress.", - ), - ] = None, -) -> None: - """Deploy a Safe Haven Management component""" - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote_or_create( - context, encrypted_key=None, projects={} - ) - - try: - # Connect to GraphAPI interactively - graph_api = GraphApi( - tenant_id=config.shm.entra_tenant_id, - default_scopes=[ - "Application.ReadWrite.All", - "Domain.ReadWrite.All", - "Group.ReadWrite.All", - ], - ) - verification_record = graph_api.add_custom_domain(config.shm.fqdn) - - # Initialise Pulumi stack - stack = SHMProjectManager( - context=context, - config=config, - pulumi_config=pulumi_config, - create_project=True, - ) - # Set Azure options - stack.add_option("azure-native:location", context.location, replace=False) - stack.add_option( - "azure-native:subscriptionId", - config.azure.subscription_id, - replace=False, - ) - stack.add_option("azure-native:tenantId", config.azure.tenant_id, replace=False) - # Add necessary secrets - stack.add_secret( - "verification-azuread-custom-domain", verification_record, replace=False - ) - - # Deploy Azure infrastructure with Pulumi - if force is None: - stack.deploy() - else: - stack.deploy(force=force) - - # Add the SHM domain to Entra ID as a custom domain - graph_api.verify_custom_domain( - config.shm.fqdn, - stack.output("networking")["fqdn_nameservers"], - ) - except DataSafeHavenError as exc: - # Note, would like to exit with a non-zero code here. - # However, typer.Exit does not print the exception tree which is very unhelpful - # for figuring out what went wrong. - # print("Could not deploy Data Safe Haven Management environment.") - # raise typer.Exit(code=1) from exc - msg = f"Could not deploy Data Safe Haven Management environment.\n{exc}" - raise DataSafeHavenError(msg) from exc - finally: - # Upload Pulumi config to blob storage - pulumi_config.upload(context) - - -@deploy_command_group.command() -def sre( +@sre_command_group.command() +def deploy( name: Annotated[str, typer.Argument(help="Name of SRE to deploy")], force: Annotated[ Optional[bool], # noqa: UP007 @@ -218,3 +144,45 @@ def sre( finally: # Upload Pulumi config to blob storage pulumi_config.upload(context) + + +@sre_command_group.command() +def teardown( + name: Annotated[str, typer.Argument(help="Name of SRE to teardown.")], +) -> None: + """Tear down a deployed a Secure Research Environment.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote(context) + + sre_name = sanitise_sre_name(name) + try: + # Load GraphAPI as this may require user-interaction that is not possible as + # part of a Pulumi declarative command + graph_api = GraphApi( + tenant_id=config.shm.entra_tenant_id, + default_scopes=["Application.ReadWrite.All", "Group.ReadWrite.All"], + ) + + # Remove infrastructure deployed with Pulumi + try: + stack = SREProjectManager( + context=context, + config=config, + pulumi_config=pulumi_config, + sre_name=sre_name, + graph_api_token=graph_api.token, + ) + stack.teardown() + except Exception as exc: + msg = f"Unable to teardown Pulumi infrastructure.\n{exc}" + raise DataSafeHavenInputError(msg) from exc + + # Remove Pulumi project from Pulumi config file + del pulumi_config[name] + + # Upload Pulumi config to blob storage + pulumi_config.upload(context) + except DataSafeHavenError as exc: + msg = f"Could not teardown Secure Research Environment '{sre_name}'.\n{exc}" + raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/teardown.py b/data_safe_haven/commands/teardown.py deleted file mode 100644 index 71714c77b6..0000000000 --- a/data_safe_haven/commands/teardown.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Command-line application for tearing down a Data Safe Haven component, delegating the details to a subcommand""" - -from typing import Annotated - -import typer - -from data_safe_haven.config import Config, DSHPulumiConfig -from data_safe_haven.context import ContextSettings -from data_safe_haven.exceptions import ( - DataSafeHavenError, - DataSafeHavenInputError, -) -from data_safe_haven.external import GraphApi -from data_safe_haven.functions import sanitise_sre_name -from data_safe_haven.infrastructure import SHMProjectManager, SREProjectManager - -teardown_command_group = typer.Typer() - - -@teardown_command_group.command( - help="Tear down a deployed a Safe Haven Management component." -) -def shm() -> None: - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote(context) - - try: - # Remove infrastructure deployed with Pulumi - try: - stack = SHMProjectManager( - context=context, - config=config, - pulumi_config=pulumi_config, - ) - stack.teardown() - except Exception as exc: - msg = f"Unable to teardown Pulumi infrastructure.\n{exc}" - raise DataSafeHavenInputError(msg) from exc - - # Remove information from config file - del pulumi_config[context.shm_name] - - # Upload Pulumi config to blob storage - pulumi_config.upload(context) - except DataSafeHavenError as exc: - msg = f"Could not teardown Safe Haven Management component.\n{exc}" - raise DataSafeHavenError(msg) from exc - - -@teardown_command_group.command( - help="Tear down a deployed a Secure Research Environment component." -) -def sre( - name: Annotated[str, typer.Argument(help="Name of SRE to teardown.")], -) -> None: - context = ContextSettings.from_file().assert_context() - config = Config.from_remote(context) - pulumi_config = DSHPulumiConfig.from_remote(context) - - sre_name = sanitise_sre_name(name) - try: - # Load GraphAPI as this may require user-interaction that is not possible as - # part of a Pulumi declarative command - graph_api = GraphApi( - tenant_id=config.shm.entra_tenant_id, - default_scopes=["Application.ReadWrite.All", "Group.ReadWrite.All"], - ) - - # Remove infrastructure deployed with Pulumi - try: - stack = SREProjectManager( - context=context, - config=config, - pulumi_config=pulumi_config, - sre_name=sre_name, - graph_api_token=graph_api.token, - ) - stack.teardown() - except Exception as exc: - msg = f"Unable to teardown Pulumi infrastructure.\n{exc}" - raise DataSafeHavenInputError(msg) from exc - - # Remove Pulumi project from Pulumi config file - del pulumi_config[name] - - # Upload Pulumi config to blob storage - pulumi_config.upload(context) - except DataSafeHavenError as exc: - msg = f"Could not teardown Secure Research Environment '{sre_name}'.\n{exc}" - raise DataSafeHavenError(msg) from exc diff --git a/data_safe_haven/commands/users.py b/data_safe_haven/commands/users.py new file mode 100644 index 0000000000..33fdf1c581 --- /dev/null +++ b/data_safe_haven/commands/users.py @@ -0,0 +1,231 @@ +"""Command-line application for performing user management tasks.""" + +import pathlib +from typing import Annotated + +import typer + +from data_safe_haven.administration.users import UserHandler +from data_safe_haven.config import Config, DSHPulumiConfig +from data_safe_haven.context import ContextSettings +from data_safe_haven.exceptions import DataSafeHavenError +from data_safe_haven.external import GraphApi +from data_safe_haven.functions import sanitise_sre_name +from data_safe_haven.utility import LoggingSingleton + +users_command_group = typer.Typer() + + +@users_command_group.command() +def add( + csv: Annotated[ + pathlib.Path, + typer.Argument( + help="A CSV file containing details of users to add.", + ), + ], +) -> None: + """Add users to a deployed Data Safe Haven.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote(context) + + shm_name = context.shm_name + + try: + # Load GraphAPI + graph_api = GraphApi( + tenant_id=config.shm.entra_tenant_id, + default_scopes=[ + "Group.Read.All", + "User.ReadWrite.All", + "UserAuthenticationMethod.ReadWrite.All", + ], + ) + + # Add users to SHM + users = UserHandler(context, config, pulumi_config, graph_api) + users.add(csv) + except DataSafeHavenError as exc: + msg = f"Could not add users to Data Safe Haven '{shm_name}'.\n{exc}" + raise DataSafeHavenError(msg) from exc + + +@users_command_group.command("list") +def list_users() -> None: + """List users from a deployed Data Safe Haven.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote(context) + + shm_name = context.shm_name + + try: + # Load GraphAPI + graph_api = GraphApi( + tenant_id=config.shm.entra_tenant_id, + default_scopes=["Directory.Read.All", "Group.Read.All"], + ) + + # List users from all sources + users = UserHandler(context, config, pulumi_config, graph_api) + users.list() + except DataSafeHavenError as exc: + msg = f"Could not list users for Data Safe Haven '{shm_name}'.\n{exc}" + raise DataSafeHavenError(msg) from exc + + +@users_command_group.command() +def register( + usernames: Annotated[ + list[str], + typer.Option( + "--username", + "-u", + help="Username of a user to register with this SRE. [*may be specified several times*]", + ), + ], + sre: Annotated[ + str, + typer.Argument( + help="The name of the SRE to add the users to.", + ), + ], +) -> None: + """Register existing users with a deployed SRE.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote(context) + + shm_name = context.shm_name + # Use a JSON-safe SRE name + sre_name = sanitise_sre_name(sre) + + try: + # Check that SRE option has been provided + if not sre_name: + msg = "SRE name must be specified." + raise DataSafeHavenError(msg) + LoggingSingleton().info( + f"Preparing to register {len(usernames)} user(s) with SRE '{sre_name}'" + ) + + # Load GraphAPI + graph_api = GraphApi( + tenant_id=config.shm.entra_tenant_id, + default_scopes=["Group.ReadWrite.All", "GroupMember.ReadWrite.All"], + ) + + # List users + users = UserHandler(context, config, pulumi_config, graph_api) + available_usernames = users.get_usernames_entra_id() + usernames_to_register = [] + for username in usernames: + if username in available_usernames: + usernames_to_register.append(username) + else: + LoggingSingleton().error( + f"Username '{username}' does not belong to this Data Safe Haven deployment." + " Please use 'dsh users add' to create it." + ) + users.register(sre_name, usernames_to_register) + except DataSafeHavenError as exc: + msg = f"Could not register users from Data Safe Haven '{shm_name}' with SRE '{sre_name}'.\n{exc}" + raise DataSafeHavenError(msg) from exc + + +@users_command_group.command() +def remove( + usernames: Annotated[ + list[str], + typer.Option( + "--username", + "-u", + help="Username of a user to remove from this Data Safe Haven. [*may be specified several times*]", + ), + ], +) -> None: + """Remove existing users from a deployed Data Safe Haven.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote(context) + + shm_name = context.shm_name + + try: + # Load GraphAPI + graph_api = GraphApi( + tenant_id=config.shm.entra_tenant_id, + default_scopes=["User.ReadWrite.All"], + ) + + # Remove users from SHM + if usernames: + users = UserHandler(context, config, pulumi_config, graph_api) + users.remove(usernames) + except DataSafeHavenError as exc: + msg = f"Could not remove users from Data Safe Haven '{shm_name}'.\n{exc}" + raise DataSafeHavenError(msg) from exc + + +@users_command_group.command() +def unregister( + usernames: Annotated[ + list[str], + typer.Option( + "--username", + "-u", + help="Username of a user to unregister from this SRE. [*may be specified several times*]", + ), + ], + sre: Annotated[ + str, + typer.Argument( + help="The name of the SRE to unregister the users from.", + ), + ], +) -> None: + """Unregister existing users from a deployed SRE.""" + context = ContextSettings.from_file().assert_context() + config = Config.from_remote(context) + pulumi_config = DSHPulumiConfig.from_remote(context) + + shm_name = context.shm_name + sre_name = sanitise_sre_name(sre) + + try: + # Check that SRE option has been provided + if not sre_name: + msg = "SRE name must be specified." + raise DataSafeHavenError(msg) + LoggingSingleton().info( + f"Preparing to unregister {len(usernames)} users with SRE '{sre_name}'" + ) + + # Load GraphAPI + graph_api = GraphApi( + tenant_id=config.shm.entra_tenant_id, + default_scopes=["Group.ReadWrite.All", "GroupMember.ReadWrite.All"], + ) + + # List users + users = UserHandler(context, config, pulumi_config, graph_api) + available_usernames = users.get_usernames_entra_id() + usernames_to_unregister = [] + for username in usernames: + if username in available_usernames: + usernames_to_unregister.append(username) + else: + LoggingSingleton().error( + f"Username '{username}' does not belong to this Data Safe Haven deployment." + " Please use 'dsh users add' to create it." + ) + for group_name in ( + f"{sre_name} Users", + f"{sre_name} Privileged Users", + f"{sre_name} Administrators", + ): + users.unregister(group_name, usernames_to_unregister) + except DataSafeHavenError as exc: + msg = f"Could not unregister users from Data Safe Haven '{shm_name}' with SRE '{sre_name}'.\n{exc}" + raise DataSafeHavenError(msg) from exc diff --git a/tests/commands/test_cli.py b/tests/commands/test_cli.py index 69d85c23fe..c27a1e3e76 100644 --- a/tests/commands/test_cli.py +++ b/tests/commands/test_cli.py @@ -12,11 +12,11 @@ def result_checker(self, result): assert "│ --install-completion" in result.stdout assert "│ --show-completion" in result.stdout assert "│ --help" in result.stdout - assert "│ admin" in result.stdout + assert "│ users" in result.stdout assert "│ config" in result.stdout assert "│ context" in result.stdout - assert "│ deploy" in result.stdout - assert "│ teardown" in result.stdout + assert "│ shm" in result.stdout + assert "│ sre" in result.stdout def test_help(self, runner): result = runner.invoke(application, ["--help"]) diff --git a/tests/commands/test_deploy.py b/tests/commands/test_shm.py similarity index 67% rename from tests/commands/test_deploy.py rename to tests/commands/test_shm.py index bb22b136ab..05b0ba2ac5 100644 --- a/tests/commands/test_deploy.py +++ b/tests/commands/test_shm.py @@ -1,5 +1,5 @@ -import data_safe_haven.commands.deploy -from data_safe_haven.commands.deploy import deploy_command_group +import data_safe_haven.commands.shm +from data_safe_haven.commands.shm import shm_command_group from data_safe_haven.external import AzureApi @@ -16,16 +16,16 @@ def exception(): # Make early step in shm deploy function raise an exception mocker.patch.object( - data_safe_haven.commands.deploy.GraphApi, "__init__", exception + data_safe_haven.commands.shm.GraphApi, "__init__", exception ) # Ensure a new DSHPulumiProject is created mocker.patch.object(AzureApi, "blob_exists", return_value=False) # Mock DSHPulumiConfig.upload mock_upload = mocker.patch.object( - data_safe_haven.commands.deploy.DSHPulumiConfig, "upload", return_value=None + data_safe_haven.commands.shm.DSHPulumiConfig, "upload", return_value=None ) - result = runner.invoke(deploy_command_group, ["shm"]) + result = runner.invoke(shm_command_group, ["deploy"]) assert result.exit_code == 1 mock_upload.assert_called_once_with(context)