diff --git a/data_safe_haven/commands/pulumi.py b/data_safe_haven/commands/pulumi.py index 7ad9506f0b..13f0795de0 100644 --- a/data_safe_haven/commands/pulumi.py +++ b/data_safe_haven/commands/pulumi.py @@ -6,8 +6,7 @@ import typer from data_safe_haven import console -from data_safe_haven.config import ContextManager, DSHPulumiConfig, SHMConfig, SREConfig -from data_safe_haven.external import GraphApi +from data_safe_haven.config import ContextManager, DSHPulumiConfig, SREConfig from data_safe_haven.infrastructure import SREProjectManager pulumi_command_group = typer.Typer() @@ -33,24 +32,12 @@ def run( """Run arbitrary Pulumi commands in a DSH project""" context = ContextManager.from_file().assert_context() pulumi_config = DSHPulumiConfig.from_remote(context) - shm_config = SHMConfig.from_remote(context) sre_config = SREConfig.from_remote_by_name(context, sre_name) - graph_api = GraphApi.from_scopes( - scopes=[ - "Application.ReadWrite.All", - "AppRoleAssignment.ReadWrite.All", - "Directory.ReadWrite.All", - "Group.ReadWrite.All", - ], - tenant_id=shm_config.shm.entra_tenant_id, - ) - project = SREProjectManager( context=context, config=sre_config, pulumi_config=pulumi_config, - graph_api_token=graph_api.token, ) stdout = project.run_pulumi_command(command) diff --git a/data_safe_haven/commands/sre.py b/data_safe_haven/commands/sre.py index 453794d525..8e6b3036fd 100644 --- a/data_safe_haven/commands/sre.py +++ b/data_safe_haven/commands/sre.py @@ -67,7 +67,6 @@ def deploy( config=sre_config, pulumi_config=pulumi_config, create_project=True, - graph_api_token=graph_api.token, ) # Set Azure options stack.add_option( @@ -100,7 +99,9 @@ def deploy( if not application: msg = f"No Entra application '{context.entra_application_name}' was found. Please redeploy your SHM." raise DataSafeHavenConfigError(msg) - stack.add_option("azuread:clientId", application.get("appId", ""), replace=True) + stack.add_option( + "azuread:clientId", application.get("appId", ""), replace=False + ) if not context.entra_application_secret: msg = f"No Entra application secret '{context.entra_application_secret_name}' was found. Please redeploy your SHM." raise DataSafeHavenConfigError(msg) @@ -108,7 +109,7 @@ def deploy( "azuread:clientSecret", context.entra_application_secret, replace=True ) stack.add_option( - "azuread:tenantId", shm_config.shm.entra_tenant_id, replace=True + "azuread:tenantId", shm_config.shm.entra_tenant_id, replace=False ) # Load SHM outputs stack.add_option( @@ -154,7 +155,6 @@ def deploy( # Provision SRE with anything that could not be done in Pulumi manager = SREProvisioningManager( - graph_api_token=graph_api.token, location=sre_config.azure.location, sre_name=sre_config.name, sre_stack=stack, @@ -184,16 +184,8 @@ def teardown( """Tear down a deployed a Secure Research Environment.""" logger = get_logger() try: - - # Load context and SHM config + # Load context context = ContextManager.from_file().assert_context() - shm_config = SHMConfig.from_remote(context) - - # Load GraphAPI as this may require user-interaction - graph_api = GraphApi.from_scopes( - scopes=["Application.ReadWrite.All", "Group.ReadWrite.All"], - tenant_id=shm_config.shm.entra_tenant_id, - ) # Load Pulumi and SRE configs pulumi_config = DSHPulumiConfig.from_remote(context) @@ -225,7 +217,6 @@ def teardown( context=context, config=sre_config, pulumi_config=pulumi_config, - graph_api_token=graph_api.token, create_project=True, ) stack.teardown(force=force) diff --git a/data_safe_haven/external/api/graph_api.py b/data_safe_haven/external/api/graph_api.py index 66cf139a65..7d3b088672 100644 --- a/data_safe_haven/external/api/graph_api.py +++ b/data_safe_haven/external/api/graph_api.py @@ -17,6 +17,11 @@ DataSafeHavenValueError, ) from data_safe_haven.logging import get_logger, get_null_logger +from data_safe_haven.types import ( + EntraApplicationId, + EntraAppPermissionType, + EntraSignInAudienceType, +) from .credentials import DeferredCredential, GraphApiCredential @@ -24,9 +29,6 @@ class GraphApi: """Interface to the Microsoft Graph REST API""" - application_ids: ClassVar[dict[str, str]] = { - "Microsoft Graph": "00000003-0000-0000-c000-000000000000", - } role_template_ids: ClassVar[dict[str, str]] = { "Global Administrator": "62e90394-69f5-4237-9190-012177145e10" } @@ -34,6 +36,7 @@ class GraphApi: "Application.ReadWrite.All": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", "AppRoleAssignment.ReadWrite.All": "06b708a9-e830-4db3-a914-8e69da51d44f", "Directory.Read.All": "7ab1d382-f21e-4acd-a863-ba3e13f7da61", + "Directory.ReadWrite.All": "19dbc75e-c2e2-444c-a770-ec69d8559fc7", "Domain.Read.All": "dbb9058a-0e50-45d7-ae91-66909b5d4664", "Group.Read.All": "5b567255-7703-4780-807c-7be8301ae99b", "Group.ReadWrite.All": "62a82d76-70ea-41e2-9197-370581804d09", @@ -192,7 +195,6 @@ def create_application( if not request_json: request_json = { "displayName": application_name, - "signInAudience": "AzureADMyOrg", "passwordCredentials": [], "publicClient": { "redirectUris": [ @@ -200,25 +202,26 @@ def create_application( "urn:ietf:wg:oauth:2.0:oob", ] }, + "signInAudience": EntraSignInAudienceType.THIS_TENANT.value, } # Add scopes if there are any scopes = [ { "id": self.uuid_application[application_scope], - "type": "Role", # 'Role' is the type for application permissions + "type": EntraAppPermissionType.APPLICATION.value, } for application_scope in application_scopes ] + [ { "id": self.uuid_delegated[delegated_scope], - "type": "Scope", # 'Scope' is the type for delegated permissions + "type": EntraAppPermissionType.DELEGATED.value, } for delegated_scope in delegated_scopes ] if scopes: request_json["requiredResourceAccess"] = [ { - "resourceAppId": self.application_ids["Microsoft Graph"], + "resourceAppId": EntraApplicationId.MICROSOFT_GRAPH.value, "resourceAccess": scopes, } ] @@ -589,9 +592,9 @@ def grant_application_role_permissions( f"Assigning application role '[green]{application_role_name}[/]' to '{application_name}'...", ) request_json = { + "appRoleId": app_role_id, "principalId": application_sp["id"], "resourceId": microsoft_graph_sp["id"], - "appRoleId": app_role_id, } self.http_post( f"{self.base_endpoint}/servicePrincipals/{microsoft_graph_sp['id']}/appRoleAssignments", diff --git a/data_safe_haven/infrastructure/components/__init__.py b/data_safe_haven/infrastructure/components/__init__.py index 7531491bf8..2b3dd67e7a 100644 --- a/data_safe_haven/infrastructure/components/__init__.py +++ b/data_safe_haven/infrastructure/components/__init__.py @@ -1,4 +1,7 @@ from .composite import ( + EntraApplicationComponent, + EntraDesktopApplicationProps, + EntraWebApplicationProps, LinuxVMComponentProps, LocalDnsRecordComponent, LocalDnsRecordProps, @@ -13,8 +16,6 @@ from .dynamic import ( BlobContainerAcl, BlobContainerAclProps, - EntraApplication, - EntraApplicationProps, FileShareFile, FileShareFileProps, SSLCertificate, @@ -28,8 +29,9 @@ __all__ = [ "BlobContainerAcl", "BlobContainerAclProps", - "EntraApplication", - "EntraApplicationProps", + "EntraApplicationComponent", + "EntraDesktopApplicationProps", + "EntraWebApplicationProps", "FileShareFile", "FileShareFileProps", "LinuxVMComponentProps", diff --git a/data_safe_haven/infrastructure/components/composite/__init__.py b/data_safe_haven/infrastructure/components/composite/__init__.py index e4254a50ed..bc09bc18a8 100644 --- a/data_safe_haven/infrastructure/components/composite/__init__.py +++ b/data_safe_haven/infrastructure/components/composite/__init__.py @@ -1,3 +1,8 @@ +from .entra_application import ( + EntraApplicationComponent, + EntraDesktopApplicationProps, + EntraWebApplicationProps, +) from .local_dns_record import LocalDnsRecordComponent, LocalDnsRecordProps from .microsoft_sql_database import ( MicrosoftSQLDatabaseComponent, @@ -8,6 +13,9 @@ from .virtual_machine import LinuxVMComponentProps, VMComponent __all__ = [ + "EntraApplicationComponent", + "EntraDesktopApplicationProps", + "EntraWebApplicationProps", "LinuxVMComponentProps", "LocalDnsRecordComponent", "LocalDnsRecordProps", diff --git a/data_safe_haven/infrastructure/components/composite/entra_application.py b/data_safe_haven/infrastructure/components/composite/entra_application.py new file mode 100644 index 0000000000..e5bcff949b --- /dev/null +++ b/data_safe_haven/infrastructure/components/composite/entra_application.py @@ -0,0 +1,163 @@ +"""Pulumi component for an Entra Application resource""" + +from collections.abc import Mapping +from typing import Any + +import pulumi_azuread as entra +from pulumi import ComponentResource, Input, Output, ResourceOptions + +from data_safe_haven.functions import replace_separators +from data_safe_haven.types import EntraAppPermissionType, EntraSignInAudienceType + + +class EntraApplicationProps: + """Properties for EntraApplicationComponent""" + + def __init__( + self, + application_name: Input[str], + application_permissions: list[tuple[EntraAppPermissionType, str]], + msgraph_service_principal: Input[entra.ServicePrincipal], + application_kwargs: Mapping[str, Any], + ) -> None: + self.application_name = application_name + self.application_permissions = application_permissions + self.msgraph_client_id = msgraph_service_principal.client_id + self.msgraph_object_id = msgraph_service_principal.object_id + self.application_kwargs = application_kwargs + + # Construct a mapping of all the available application permissions + self.msgraph_permissions: Output[dict[str, Mapping[str, str]]] = Output.all( + application=msgraph_service_principal.app_role_ids, + delegated=msgraph_service_principal.oauth2_permission_scope_ids, + ).apply( + lambda kwargs: { + EntraAppPermissionType.APPLICATION: kwargs["application"], + EntraAppPermissionType.DELEGATED: kwargs["delegated"], + } + ) + + +class EntraDesktopApplicationProps(EntraApplicationProps): + """ + Properties for a desktop EntraApplicationComponent. + See https://learn.microsoft.com/en-us/entra/identity-platform/msal-client-applications) + """ + + def __init__( + self, + application_name: Input[str], + application_permissions: list[tuple[EntraAppPermissionType, str]], + msgraph_service_principal: Input[entra.ServicePrincipal], + ): + super().__init__( + application_name=application_name, + application_kwargs={ + "public_client": entra.ApplicationPublicClientArgs( + redirect_uris=["urn:ietf:wg:oauth:2.0:oob"] + ) + }, + application_permissions=application_permissions, + msgraph_service_principal=msgraph_service_principal, + ) + + +class EntraWebApplicationProps(EntraApplicationProps): + """ + Properties for a web EntraApplicationComponent. + See https://learn.microsoft.com/en-us/entra/identity-platform/msal-client-applications) + """ + + def __init__( + self, + application_name: Input[str], + application_permissions: list[tuple[EntraAppPermissionType, str]], + msgraph_service_principal: Input[entra.ServicePrincipal], + redirect_url: Input[str], + ): + super().__init__( + application_name=application_name, + application_kwargs={ + "web": entra.ApplicationWebArgs( + redirect_uris=[redirect_url], + implicit_grant=entra.ApplicationWebImplicitGrantArgs( + id_token_issuance_enabled=True, + ), + ) + }, + application_permissions=application_permissions, + msgraph_service_principal=msgraph_service_principal, + ) + + +class EntraApplicationComponent(ComponentResource): + """Deploy an Entra application with Pulumi""" + + def __init__( + self, + name: str, + props: EntraApplicationProps, + opts: ResourceOptions | None = None, + ) -> None: + super().__init__("dsh:common:EntraApplicationComponent", name, {}, opts) + + # Create the application + self.application = entra.Application( + f"{self._name}_application", + display_name=props.application_name, + prevent_duplicate_names=True, + required_resource_accesses=( + [ + entra.ApplicationRequiredResourceAccessArgs( + resource_accesses=[ + entra.ApplicationRequiredResourceAccessResourceAccessArgs( + id=props.msgraph_permissions[permission_type][ + permission + ], + type=permission_type.value, + ) + for permission_type, permission in props.application_permissions + ], + resource_app_id=props.msgraph_client_id, + ) + ] + if props.application_permissions + else [] + ), + sign_in_audience=EntraSignInAudienceType.THIS_TENANT.value, + **props.application_kwargs, + ) + + # Get the service principal for this application + self.application_service_principal = entra.ServicePrincipal( + f"{self._name}_application_service_principal", + client_id=self.application.client_id, + ) + + # Grant admin approval for requested application permissions + [ + entra.AppRoleAssignment( + replace_separators( + f"{self._name}_application_role_grant_{permission_type.value}_{permission}", + "_", + ).lower(), + app_role_id=props.msgraph_permissions[permission_type][permission], + principal_object_id=self.application_service_principal.object_id, + resource_object_id=props.msgraph_object_id, + ) + for permission_type, permission in props.application_permissions + if permission_type == EntraAppPermissionType.APPLICATION + ] + [ + entra.ServicePrincipalDelegatedPermissionGrant( + replace_separators( + f"{self._name}_application_delegated_grant_{permission_type.value}_{permission}", + "_", + ).lower(), + claim_values=[permission], + resource_service_principal_object_id=props.msgraph_object_id, + service_principal_object_id=self.application_service_principal.object_id, + ) + for permission_type, permission in props.application_permissions + if permission_type == EntraAppPermissionType.DELEGATED + ] diff --git a/data_safe_haven/infrastructure/components/dynamic/__init__.py b/data_safe_haven/infrastructure/components/dynamic/__init__.py index 429fc8470d..78ecfbcef1 100644 --- a/data_safe_haven/infrastructure/components/dynamic/__init__.py +++ b/data_safe_haven/infrastructure/components/dynamic/__init__.py @@ -1,13 +1,10 @@ from .blob_container_acl import BlobContainerAcl, BlobContainerAclProps -from .entra_application import EntraApplication, EntraApplicationProps from .file_share_file import FileShareFile, FileShareFileProps from .ssl_certificate import SSLCertificate, SSLCertificateProps __all__ = [ "BlobContainerAcl", "BlobContainerAclProps", - "EntraApplication", - "EntraApplicationProps", "FileShareFile", "FileShareFileProps", "SSLCertificate", diff --git a/data_safe_haven/infrastructure/components/dynamic/entra_application.py b/data_safe_haven/infrastructure/components/dynamic/entra_application.py deleted file mode 100644 index fd2d233137..0000000000 --- a/data_safe_haven/infrastructure/components/dynamic/entra_application.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Pulumi dynamic component for Entra applications.""" - -from contextlib import suppress -from typing import Any - -from pulumi import Input, Output, ResourceOptions -from pulumi.dynamic import CreateResult, DiffResult, Resource, UpdateResult - -from data_safe_haven.exceptions import DataSafeHavenMicrosoftGraphError -from data_safe_haven.external import GraphApi - -from .dsh_resource_provider import DshResourceProvider - - -class EntraApplicationProps: - """Props for the EntraApplication class""" - - def __init__( - self, - application_name: Input[str], - application_role_assignments: Input[list[str]] | None = None, - application_secret_name: Input[str] | None = None, - delegated_role_assignments: Input[list[str]] | None = None, - public_client_redirect_uri: Input[str] | None = None, - web_redirect_url: Input[str] | None = None, - ) -> None: - self.application_name = application_name - self.application_role_assignments = application_role_assignments - self.application_secret_name = application_secret_name - self.delegated_role_assignments = delegated_role_assignments - self.public_client_redirect_uri = public_client_redirect_uri - self.web_redirect_url = web_redirect_url - - -class EntraApplicationProvider(DshResourceProvider): - def __init__(self, auth_token: str): - self.auth_token = auth_token - super().__init__() - - def create(self, props: dict[str, Any]) -> CreateResult: - """Create new Entra application.""" - outs = dict(**props) - try: - graph_api = GraphApi.from_token(self.auth_token, disable_logging=True) - request_json = { - "displayName": props["application_name"], - "signInAudience": "AzureADMyOrg", - } - # Add a web redirection URL if requested - if props.get("web_redirect_url", None): - request_json["web"] = { - "redirectUris": [props["web_redirect_url"]], - "implicitGrantSettings": {"enableIdTokenIssuance": True}, - } - # Add a public client redirection URL if requested - if props.get("public_client_redirect_uri", None): - request_json["publicClient"] = { - "redirectUris": [props["public_client_redirect_uri"]], - } - json_response = graph_api.create_application( - props["application_name"], - application_scopes=props.get("application_role_assignments", []), - delegated_scopes=props.get("delegated_role_assignments", []), - request_json=request_json, - ) - outs["object_id"] = json_response["id"] - outs["application_id"] = json_response["appId"] - - # Grant requested role permissions - graph_api.grant_role_permissions( - outs["application_name"], - application_role_assignments=props.get( - "application_role_assignments", [] - ), - delegated_role_assignments=props.get("delegated_role_assignments", []), - ) - - # Attach an application secret if requested - outs["application_secret"] = ( - graph_api.create_application_secret( - props["application_name"], - props["application_secret_name"], - ) - if props.get("application_secret_name", None) - else "" - ) - except Exception as exc: - msg = f"Failed to create application '{props['application_name']}' in Entra ID." - raise DataSafeHavenMicrosoftGraphError(msg) from exc - return CreateResult( - f"EntraApplication-{props['application_name']}", - outs=outs, - ) - - def delete(self, id_: str, props: dict[str, Any]) -> None: - """Delete an Entra application.""" - # Use `id` as a no-op to avoid ARG002 while maintaining function signature - id(id_) - try: - graph_api = GraphApi.from_token(self.auth_token, disable_logging=True) - graph_api.delete_application(props["application_name"]) - except Exception as exc: - msg = f"Failed to delete application '{props['application_name']}' from Entra ID." - raise DataSafeHavenMicrosoftGraphError(msg) from exc - - def diff( - self, - id_: str, - old_props: dict[str, Any], - new_props: dict[str, Any], - ) -> DiffResult: - """Calculate diff between old and new state""" - # Use `id` as a no-op to avoid ARG002 while maintaining function signature - id(id_) - # We exclude '__provider' from the diff. This is a Base64-encoded pickle of this - # EntraApplicationProvider instance. This means that it contains self.auth_token - # and would otherwise trigger a diff each time the auth_token changes. Note that - # ignoring '__provider' could cause issues if the structure of this class - # changes in any other way, but this could be fixed by manually deleting the - # application in the Entra directory. - return self.partial_diff(old_props, new_props, excluded_props=["__provider"]) - - def refresh(self, props: dict[str, Any]) -> dict[str, Any]: - try: - outs = dict(**props) - with suppress(DataSafeHavenMicrosoftGraphError, KeyError): - graph_api = GraphApi.from_token(self.auth_token, disable_logging=True) - if json_response := graph_api.get_application_by_name( - outs["application_name"] - ): - outs["object_id"] = json_response["id"] - outs["application_id"] = json_response["appId"] - - # Ensure that requested role permissions have been granted - graph_api.grant_role_permissions( - outs["application_name"], - application_role_assignments=props.get( - "application_role_assignments", [] - ), - delegated_role_assignments=props.get( - "delegated_role_assignments", [] - ), - ) - return outs - except Exception as exc: - msg = f"Failed to refresh application '{props['application_name']}' in Entra ID." - raise DataSafeHavenMicrosoftGraphError(msg) from exc - - def update( - self, - id_: str, - old_props: dict[str, Any], - new_props: dict[str, Any], - ) -> UpdateResult: - """Updating is deleting followed by creating.""" - try: - # Delete the old application, using the auth token from new_props - old_props_ = {**old_props} - self.delete(id_, old_props_) - # Create a new application - updated = self.create(new_props) - return UpdateResult(outs=updated.outs) - except Exception as exc: - msg = f"Failed to update application '{new_props['application_name']}' in Entra ID." - raise DataSafeHavenMicrosoftGraphError(msg) from exc - - -class EntraApplication(Resource): - application_id: Output[str] - application_secret: Output[str] - object_id: Output[str] - _resource_type_name = "dsh:common:EntraApplication" # set resource type - - def __init__( - self, - name: str, - props: EntraApplicationProps, - auth_token: str, - opts: ResourceOptions | None = None, - ): - super().__init__( - EntraApplicationProvider(auth_token), - name, - { - "application_id": None, - "application_secret": None, - "object_id": None, - **vars(props), - }, - opts, - ) diff --git a/data_safe_haven/infrastructure/programs/declarative_sre.py b/data_safe_haven/infrastructure/programs/declarative_sre.py index ce678dbb4a..15989bbe7b 100644 --- a/data_safe_haven/infrastructure/programs/declarative_sre.py +++ b/data_safe_haven/infrastructure/programs/declarative_sre.py @@ -35,11 +35,9 @@ def __init__( self, context: Context, config: SREConfig, - graph_api_token: str, ) -> None: self.context = context self.config = config - self.graph_api_token = graph_api_token self.stack_name = replace_separators( f"shm-{context.name}-sre-{config.name}", "-" ) @@ -112,14 +110,6 @@ def __call__(self) -> None: ] ) - # Deploy Entra resources - SREEntraComponent( - "sre_entra", - SREEntraProps( - group_names=ldap_group_names, - ), - ) - # Deploy resource group resource_group = resources.ResourceGroup( "sre_resource_group", @@ -162,6 +152,17 @@ def __call__(self) -> None: tags=self.tags, ) + # Deploy Entra resources + entra = SREEntraComponent( + "sre_entra", + SREEntraProps( + group_names=ldap_group_names, + shm_name=self.context.name, + sre_fqdn=networking.sre_fqdn, + sre_name=self.config.name, + ), + ) + # Deploy SRE firewall SREFirewallComponent( "sre_firewall", @@ -248,8 +249,8 @@ def __call__(self) -> None: SREIdentityProps( dns_server_ip=dns.ip_address, dockerhub_credentials=dockerhub_credentials, - entra_application_name=f"sre-{self.config.name}-apricot", - entra_auth_token=self.graph_api_token, + entra_application_id=entra.identity_application_id, + entra_application_secret=entra.identity_application_secret, entra_tenant_id=shm_entra_tenant_id, location=self.config.azure.location, resource_group_name=resource_group.name, @@ -288,9 +289,8 @@ def __call__(self) -> None: database_password=data.password_user_database_admin, dns_server_ip=dns.ip_address, dockerhub_credentials=dockerhub_credentials, - entra_application_fqdn=networking.sre_fqdn, - entra_application_name=f"sre-{self.config.name}-guacamole", - entra_auth_token=self.graph_api_token, + entra_application_id=entra.remote_desktop_application_id, + entra_application_url=entra.remote_desktop_url, entra_tenant_id=shm_entra_tenant_id, ldap_group_filter=ldap_group_filter, ldap_group_search_base=ldap_group_search_base, diff --git a/data_safe_haven/infrastructure/programs/imperative_shm.py b/data_safe_haven/infrastructure/programs/imperative_shm.py index 5cbb679b73..c4c0f5e761 100644 --- a/data_safe_haven/infrastructure/programs/imperative_shm.py +++ b/data_safe_haven/infrastructure/programs/imperative_shm.py @@ -5,6 +5,7 @@ ) from data_safe_haven.external import AzureSdk, GraphApi from data_safe_haven.logging import get_logger +from data_safe_haven.types import EntraSignInAudienceType class ImperativeSHM: @@ -147,11 +148,16 @@ def deploy(self) -> None: try: graph_api.create_application( self.context.entra_application_name, - application_scopes=["Group.ReadWrite.All"], + application_scopes=[ + "Application.ReadWrite.All", # For creating applications + "AppRoleAssignment.ReadWrite.All", # For application permissions + "Directory.ReadWrite.All", # For creating/deleting groups + "Group.ReadWrite.All", # For creating/deleting groups + ], delegated_scopes=[], request_json={ "displayName": self.context.entra_application_name, - "signInAudience": "AzureADMyOrg", + "signInAudience": EntraSignInAudienceType.THIS_TENANT.value, }, ) # Always recreate the application secret. diff --git a/data_safe_haven/infrastructure/programs/sre/entra.py b/data_safe_haven/infrastructure/programs/sre/entra.py index 1f44995f9f..abc0241070 100644 --- a/data_safe_haven/infrastructure/programs/sre/entra.py +++ b/data_safe_haven/infrastructure/programs/sre/entra.py @@ -2,10 +2,16 @@ from collections.abc import Mapping -from pulumi import ComponentResource, ResourceOptions -from pulumi_azuread import Group +import pulumi_azuread as entra +from pulumi import ComponentResource, Input, Output, ResourceOptions from data_safe_haven.functions import replace_separators +from data_safe_haven.infrastructure.components import ( + EntraApplicationComponent, + EntraDesktopApplicationProps, + EntraWebApplicationProps, +) +from data_safe_haven.types import EntraApplicationId, EntraAppPermissionType class SREEntraProps: @@ -14,8 +20,14 @@ class SREEntraProps: def __init__( self, group_names: Mapping[str, str], + sre_fqdn: Input[str], + shm_name: Input[str], + sre_name: Input[str], ) -> None: self.group_names = group_names + self.shm_name = shm_name + self.sre_fqdn = sre_fqdn + self.sre_name = sre_name class SREEntraComponent(ComponentResource): @@ -28,13 +40,84 @@ def __init__( opts: ResourceOptions | None = None, ) -> None: super().__init__("dsh:sre:EntraComponent", name, {}, opts) + child_opts = ResourceOptions.merge(opts, ResourceOptions(parent=self)) - for group_id, group_description in props.group_names.items(): - Group( - replace_separators(f"{self._name}_group_{group_id}", "_"), + # Create Entra groups + for group_name, group_description in props.group_names.items(): + entra.Group( + replace_separators(f"{self._name}_group_{group_name}", "_"), description=group_description, display_name=group_description, mail_enabled=False, prevent_duplicate_names=True, security_enabled=True, ) + + # Get the Microsoft Graph service principal + msgraph_service_principal = entra.ServicePrincipal( + f"{self._name}_microsoft_graph_service_principal", + client_id=EntraApplicationId.MICROSOFT_GRAPH.value, + use_existing=True, + ) + + # Identity application + # - needs read-only permissions for users/groups + # - needs delegated permission to read users (for validating log-in attempts) + # - needs an application secret for authentication + self.identity_application = EntraApplicationComponent( + f"{self._name}_identity", + EntraDesktopApplicationProps( + application_name=Output.concat( + "Data Safe Haven (", + props.shm_name, + " - ", + props.sre_name, + ") Identity Service Principal", + ), + application_permissions=[ + (EntraAppPermissionType.APPLICATION, "User.Read.All"), + (EntraAppPermissionType.APPLICATION, "GroupMember.Read.All"), + (EntraAppPermissionType.DELEGATED, "User.Read.All"), + ], + msgraph_service_principal=msgraph_service_principal, + ), + opts=child_opts, + ) + + # Add an application password + self.identity_application_secret = entra.ApplicationPassword( + f"{self._name}_identity_application_secret", + application_id=self.identity_application.application.id, + display_name="Apricot Authentication Secret", + ) + + # Remote desktop application + # - only used as part of the OAuth 2.0 authorization flow + # - does not need any application permissions + # - does not need an application secret + self.remote_desktop_url = Output.from_input(props.sre_fqdn).apply( + lambda fqdn: f"https://{str(fqdn).strip('/')}/" + ) + self.remote_desktop_application = EntraApplicationComponent( + f"{self._name}_remote_desktop", + EntraWebApplicationProps( + application_name=Output.concat( + "Data Safe Haven (", + props.shm_name, + " - ", + props.sre_name, + ") Remote Desktop Service Principal", + ), + application_permissions=[], + msgraph_service_principal=msgraph_service_principal, + redirect_url=self.remote_desktop_url, + ), + opts=child_opts, + ) + + # Register outputs + self.identity_application_id = self.identity_application.application.client_id + self.identity_application_secret = self.identity_application_secret.value + self.remote_desktop_application_id = ( + self.remote_desktop_application.application.client_id + ) diff --git a/data_safe_haven/infrastructure/programs/sre/identity.py b/data_safe_haven/infrastructure/programs/sre/identity.py index 0196fe7e39..7839853384 100644 --- a/data_safe_haven/infrastructure/programs/sre/identity.py +++ b/data_safe_haven/infrastructure/programs/sre/identity.py @@ -11,8 +11,6 @@ get_ip_address_from_container_group, ) from data_safe_haven.infrastructure.components import ( - EntraApplication, - EntraApplicationProps, LocalDnsRecordComponent, LocalDnsRecordProps, ) @@ -25,8 +23,8 @@ def __init__( self, dns_server_ip: Input[str], dockerhub_credentials: DockerHubCredentials, - entra_application_name: Input[str], - entra_auth_token: str, + entra_application_id: Input[str], + entra_application_secret: Input[str], entra_tenant_id: Input[str], location: Input[str], resource_group_name: Input[str], @@ -38,8 +36,8 @@ def __init__( ) -> None: self.dns_server_ip = dns_server_ip self.dockerhub_credentials = dockerhub_credentials - self.entra_application_name = entra_application_name - self.entra_auth_token = entra_auth_token + self.entra_application_id = entra_application_id + self.entra_application_secret = entra_application_secret self.entra_tenant_id = entra_tenant_id self.location = location self.resource_group_name = resource_group_name @@ -82,20 +80,6 @@ def __init__( opts=child_opts, ) - # Define Entra ID application - entra_application = EntraApplication( - f"{self._name}_entra_application", - EntraApplicationProps( - application_name=props.entra_application_name, - application_role_assignments=["User.Read.All", "GroupMember.Read.All"], - application_secret_name="Apricot Authentication Secret", - delegated_role_assignments=["User.Read.All"], - public_client_redirect_uri="urn:ietf:wg:oauth:2.0:oob", - ), - auth_token=props.entra_auth_token, - opts=child_opts, - ) - # Define the LDAP server container group with Apricot container_group = containerinstance.ContainerGroup( f"{self._name}_container_group", @@ -111,11 +95,11 @@ def __init__( ), containerinstance.EnvironmentVariableArgs( name="CLIENT_ID", - value=entra_application.application_id, + value=props.entra_application_id, ), containerinstance.EnvironmentVariableArgs( name="CLIENT_SECRET", - secure_value=entra_application.application_secret, + secure_value=props.entra_application_secret, ), containerinstance.EnvironmentVariableArgs( name="DEBUG", diff --git a/data_safe_haven/infrastructure/programs/sre/remote_desktop.py b/data_safe_haven/infrastructure/programs/sre/remote_desktop.py index 3be1207c77..e2df83ede5 100644 --- a/data_safe_haven/infrastructure/programs/sre/remote_desktop.py +++ b/data_safe_haven/infrastructure/programs/sre/remote_desktop.py @@ -11,8 +11,6 @@ get_id_from_subnet, ) from data_safe_haven.infrastructure.components import ( - EntraApplication, - EntraApplicationProps, FileShareFile, FileShareFileProps, PostgresqlDatabaseComponent, @@ -32,9 +30,8 @@ def __init__( database_password: Input[str], dns_server_ip: Input[str], dockerhub_credentials: DockerHubCredentials, - entra_application_fqdn: Input[str], - entra_application_name: Input[str], - entra_auth_token: str, + entra_application_id: Input[str], + entra_application_url: Input[str], entra_tenant_id: Input[str], ldap_group_filter: Input[str], ldap_group_search_base: Input[str], @@ -58,9 +55,8 @@ def __init__( self.disable_paste = not allow_paste self.dns_server_ip = dns_server_ip self.dockerhub_credentials = dockerhub_credentials - self.entra_application_name = entra_application_name - self.entra_application_url = Output.concat("https://", entra_application_fqdn) - self.entra_auth_token = entra_auth_token + self.entra_application_id = entra_application_id + self.entra_application_url = entra_application_url self.entra_tenant_id = entra_tenant_id self.ldap_group_filter = ldap_group_filter self.ldap_group_search_base = ldap_group_search_base @@ -119,17 +115,6 @@ def __init__( child_opts = ResourceOptions.merge(opts, ResourceOptions(parent=self)) child_tags = {"component": "remote desktop"} | (tags if tags else {}) - # Define Entra ID application - entra_application = EntraApplication( - f"{self._name}_entra_application", - EntraApplicationProps( - application_name=props.entra_application_name, - web_redirect_url=props.entra_application_url, - ), - auth_token=props.entra_auth_token, - opts=child_opts, - ) - # Define configuration file shares file_share = storage.FileShare( f"{self._name}_file_share", @@ -224,7 +209,7 @@ def __init__( ), containerinstance.EnvironmentVariableArgs( name="OPENID_CLIENT_ID", - value=entra_application.application_id, + value=props.entra_application_id, ), containerinstance.EnvironmentVariableArgs( name="OPENID_ISSUER", diff --git a/data_safe_haven/infrastructure/project_manager.py b/data_safe_haven/infrastructure/project_manager.py index a6d5af805b..eca352b28b 100644 --- a/data_safe_haven/infrastructure/project_manager.py +++ b/data_safe_haven/infrastructure/project_manager.py @@ -446,14 +446,12 @@ def __init__( pulumi_config: DSHPulumiConfig, *, create_project: bool = False, - graph_api_token: str | None = None, ) -> None: """Constructor""" - token = graph_api_token or "" super().__init__( context, pulumi_config, config.name, - DeclarativeSRE(context, config, token), + DeclarativeSRE(context, config), create_project=create_project, ) diff --git a/data_safe_haven/provisioning/sre_provisioning_manager.py b/data_safe_haven/provisioning/sre_provisioning_manager.py index 7c39046b86..b269779d8f 100644 --- a/data_safe_haven/provisioning/sre_provisioning_manager.py +++ b/data_safe_haven/provisioning/sre_provisioning_manager.py @@ -7,7 +7,6 @@ AzureContainerInstance, AzurePostgreSQLDatabase, AzureSdk, - GraphApi, ) from data_safe_haven.infrastructure import SREProjectManager from data_safe_haven.logging import get_logger @@ -19,7 +18,6 @@ class SREProvisioningManager: def __init__( self, - graph_api_token: str, location: AzureLocation, sre_name: str, sre_stack: SREProjectManager, @@ -28,7 +26,6 @@ def __init__( ): self._available_vm_skus: dict[str, dict[str, Any]] | None = None self.location = location - self.graph_api = GraphApi.from_token(graph_api_token) self.logger = get_logger() self.sre_name = sre_name self.subscription_name = subscription_name diff --git a/data_safe_haven/types/__init__.py b/data_safe_haven/types/__init__.py index 4f2f89b3be..e12c76bdc6 100644 --- a/data_safe_haven/types/__init__.py +++ b/data_safe_haven/types/__init__.py @@ -17,6 +17,9 @@ AzureSdkCredentialScope, AzureServiceTag, DatabaseSystem, + EntraApplicationId, + EntraAppPermissionType, + EntraSignInAudienceType, FirewallPriorities, ForbiddenDomains, NetworkingPriorities, @@ -36,7 +39,10 @@ "AzureVmSku", "DatabaseSystem", "EmailAddress", + "EntraApplicationId", + "EntraAppPermissionType", "EntraGroupName", + "EntraSignInAudienceType", "FirewallPriorities", "ForbiddenDomains", "Fqdn", diff --git a/data_safe_haven/types/enums.py b/data_safe_haven/types/enums.py index 35465f260e..17d5dda8e3 100644 --- a/data_safe_haven/types/enums.py +++ b/data_safe_haven/types/enums.py @@ -37,6 +37,25 @@ class DatabaseSystem(str, Enum): POSTGRESQL = "postgresql" +@verify(UNIQUE) +class EntraApplicationId(str, Enum): + MICROSOFT_GRAPH = "00000003-0000-0000-c000-000000000000" + + +@verify(UNIQUE) +class EntraAppPermissionType(str, Enum): + APPLICATION = "Role" + DELEGATED = "Scope" + + +@verify(UNIQUE) +class EntraSignInAudienceType(str, Enum): + ANY_TENANT = "AzureADMultipleOrgs" + ANY_TENANT_OR_PERSONAL = "AzureADandPersonalMicrosoftAccount" + PERSONAL = "PersonalMicrosoftAccount" + THIS_TENANT = "AzureADMyOrg" + + @verify(UNIQUE) class FirewallPriorities(int, Enum): """Priorities for firewall rules.""" diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index dab10adb7b..dc6811ff9f 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -48,11 +48,6 @@ def mock_graph_api_get_application_by_name(mocker, request): ) -@fixture -def mock_graph_api_token(mocker): - mocker.patch.object(GraphApi, "token", return_value="dummy-token") - - @fixture def mock_imperative_shm_deploy(mocker): mocker.patch.object( diff --git a/tests/commands/test_pulumi.py b/tests/commands/test_pulumi.py index fefb4615fc..b55d2974ec 100644 --- a/tests/commands/test_pulumi.py +++ b/tests/commands/test_pulumi.py @@ -6,7 +6,6 @@ def test_run_sre( self, runner, local_project_settings, # noqa: ARG002 - mock_graph_api_token, # noqa: ARG002 mock_install_plugins, # noqa: ARG002 mock_key_vault_key, # noqa: ARG002 mock_pulumi_config_no_key_from_remote, # noqa: ARG002 @@ -30,7 +29,6 @@ def test_run_sre_invalid_command( self, runner, local_project_settings, # noqa: ARG002 - mock_graph_api_token, # noqa: ARG002 mock_install_plugins, # noqa: ARG002 mock_key_vault_key, # noqa: ARG002 mock_pulumi_config_no_key_from_remote, # noqa: ARG002 @@ -48,7 +46,6 @@ def test_run_sre_invalid_name( self, runner, local_project_settings, # noqa: ARG002 - mock_graph_api_token, # noqa: ARG002 mock_install_plugins, # noqa: ARG002 mock_key_vault_key, # noqa: ARG002 mock_pulumi_config_no_key_from_remote, # noqa: ARG002 diff --git a/tests/commands/test_shm.py b/tests/commands/test_shm.py index 17ac1f178c..36b62c7524 100644 --- a/tests/commands/test_shm.py +++ b/tests/commands/test_shm.py @@ -7,7 +7,6 @@ def test_infrastructure_deploy( runner, mock_imperative_shm_deploy_then_exit, # noqa: ARG002 mock_graph_api_add_custom_domain, # noqa: ARG002 - mock_graph_api_token, # noqa: ARG002 mock_shm_config_from_remote, # noqa: ARG002 mock_shm_config_remote_exists, # noqa: ARG002 mock_shm_config_upload, # noqa: ARG002 diff --git a/tests/commands/test_sre.py b/tests/commands/test_sre.py index 6f373da351..18d6c6daa1 100644 --- a/tests/commands/test_sre.py +++ b/tests/commands/test_sre.py @@ -13,7 +13,6 @@ def test_deploy( self, runner: CliRunner, mock_azuresdk_get_subscription_name, # noqa: ARG002 - mock_graph_api_token, # noqa: ARG002 mock_contextmanager_assert_context, # noqa: ARG002 mock_ip_1_2_3_4, # noqa: ARG002 mock_pulumi_config_from_remote_or_create, # noqa: ARG002 @@ -34,7 +33,6 @@ def test_no_application( runner: CliRunner, mock_azuresdk_get_subscription_name, # noqa: ARG002 mock_contextmanager_assert_context, # noqa: ARG002 - mock_graph_api_token, # noqa: ARG002 mock_ip_1_2_3_4, # noqa: ARG002 mock_pulumi_config_from_remote_or_create, # noqa: ARG002 mock_shm_config_from_remote, # noqa: ARG002 @@ -56,7 +54,6 @@ def test_no_application_secret( mocker: MockerFixture, mock_azuresdk_get_subscription_name, # noqa: ARG002 mock_graph_api_get_application_by_name, # noqa: ARG002 - mock_graph_api_token, # noqa: ARG002 mock_ip_1_2_3_4, # noqa: ARG002 mock_pulumi_config_from_remote_or_create, # noqa: ARG002 mock_shm_config_from_remote, # noqa: ARG002 @@ -105,7 +102,6 @@ def test_teardown( self, mocker: MockerFixture, runner: CliRunner, - mock_graph_api_token, # noqa: ARG002 mock_ip_1_2_3_4, # noqa: ARG002 mock_pulumi_config_from_remote, # noqa: ARG002 mock_shm_config_from_remote, # noqa: ARG002