diff --git a/data_safe_haven/administration/users/azure_ad_users.py b/data_safe_haven/administration/users/azure_ad_users.py index 29a7586e95..06f648987c 100644 --- a/data_safe_haven/administration/users/azure_ad_users.py +++ b/data_safe_haven/administration/users/azure_ad_users.py @@ -3,6 +3,7 @@ from collections.abc import Sequence from typing import Any +from data_safe_haven.exceptions import DataSafeHavenMicrosoftGraphError from data_safe_haven.external import GraphApi from data_safe_haven.functions import password from data_safe_haven.utility import LoggingSingleton @@ -46,16 +47,8 @@ def add(self, new_users: Sequence[ResearchUser]) -> None: request_json, user.email_address, user.phone_number ) self.logger.info( - f"Ensured user '{user.preferred_username}' exists in AzureAD" + f"Ensured user '[green]{user.preferred_username}[/]' exists in AzureAD" ) - # Decorate all users with the Linux schema - self.set_user_attributes() - # # Ensure that all users belong to an associated group the same name and UID - # for user in self.list(): - # self.graph_api.create_group(user.username, user.uid_number) - # self.graph_api.add_user_to_group(user.username, user.username) - # # Also add the user to the research users group - # self.graph_api.add_user_to_group(user.username, self.researchers_group_name) def list(self) -> Sequence[ResearchUser]: user_list = self.graph_api.read_users() @@ -84,84 +77,33 @@ def list(self) -> Sequence[ResearchUser]: ) ] + def register(self, sre_name: str, usernames: Sequence[str]) -> None: + """Add usernames to SRE security group""" + group_name = f"Data Safe Haven SRE {sre_name} Users" + for username in usernames: + self.graph_api.add_user_to_group(username, group_name) + def remove(self, users: Sequence[ResearchUser]) -> None: - """Disable a list of users in AzureAD""" - # for user_to_remove in users: - # matched_users = [user for user in self.users if user == user_to_remove] - # if not matched_users: - # continue - # user = matched_users[0] - # try: - # if self.graph_api.remove_user_from_group( - # user.username, self.researchers_group_name - # ): - # self.logger.info( - # f"Removed '{user.preferred_username}' from group '{self.researchers_group_name}'" - # ) - # else: - # raise DataSafeHavenMicrosoftGraphError - # except DataSafeHavenMicrosoftGraphError: - # self.logger.error( - # f"Unable to remove '{user.preferred_username}' from group '{self.researchers_group_name}'" - # ) - pass + """Remove list of users from AzureAD""" + for user in filter( + lambda existing_user: any(existing_user == user for user in users), + self.list(), + ): + try: + self.graph_api.remove_user(user.username) + self.logger.info(f"Removed '{user.preferred_username}'.") + except DataSafeHavenMicrosoftGraphError: + self.logger.error(f"Unable to remove '{user.preferred_username}'.") def set(self, users: Sequence[ResearchUser]) -> None: - """Set Guacamole users to specified list""" + """Set AzureAD users to specified list""" users_to_remove = [user for user in self.list() if user not in users] self.remove(users_to_remove) users_to_add = [user for user in users if user not in self.list()] self.add(users_to_add) - def set_user_attributes(self) -> None: - """Ensure that all users have Linux attributes""" - # next_uid = max( - # [int(user.uid_number) + 1 if user.uid_number else 0 for user in self.users] - # + [10000] - # ) - # for user in self.users: - # try: - # # Get username from userPrincipalName - # username = user.user_principal_name.split("@")[0] - # if not user.homedir: - # user.homedir = f"/home/{username}" - # self.logger.debug( - # f"Added homedir {user.homedir} to user {user.preferred_username}" - # ) - # if not user.shell: - # user.shell = "/bin/bash" - # self.logger.debug( - # f"Added shell {user.shell} to user {user.preferred_username}" - # ) - # if not user.uid_number: - # # Set UID to the next unused one - # user.uid_number = next_uid - # next_uid += 1 - # self.logger.debug( - # f"Added uid {user.uid_number} to user {user.preferred_username}" - # ) - # if not user.username: - # user.username = username - # self.logger.debug( - # f"Added username {user.username} to user {user.preferred_username}" - # ) - # # Ensure that the remote user matches the local model - # patch_json = { - # GraphApi.linux_schema: { - # "gidnumber": user.uid_number, - # "homedir": user.homedir, - # "shell": user.shell, - # "uid": user.uid_number, - # "user": user.username, - # } - # } - # self.graph_api.http_patch( - # f"{self.graph_api.base_endpoint}/users/{user.azure_oid}", - # json=patch_json, - # ) - # self.logger.debug(f"Set Linux attributes for user {user.preferred_username}.") - # except Exception as exc: - # self.logger.error( - # f"Failed to set Linux attributes for user {user.preferred_username}.\n{str(exc)}" - # ) - pass + def unregister(self, sre_name: str, usernames: Sequence[str]) -> None: + """Remove usernames from SRE security group""" + group_name = f"Data Safe Haven SRE {sre_name}" + for username in usernames: + self.graph_api.remove_user_from_group(username, group_name) diff --git a/data_safe_haven/commands/deploy.py b/data_safe_haven/commands/deploy.py index e1ddbdbfb0..355effc4da 100644 --- a/data_safe_haven/commands/deploy.py +++ b/data_safe_haven/commands/deploy.py @@ -115,7 +115,12 @@ def sre( # part of a Pulumi declarative command graph_api = GraphApi( tenant_id=config.shm.aad_tenant_id, - default_scopes=["Application.ReadWrite.All", "Group.ReadWrite.All"], + default_scopes=[ + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "Directory.ReadWrite.All", + "Group.ReadWrite.All", + ], ) # Initialise Pulumi stack diff --git a/data_safe_haven/external/api/azure_api.py b/data_safe_haven/external/api/azure_api.py index 68a47975fc..58180393ac 100644 --- a/data_safe_haven/external/api/azure_api.py +++ b/data_safe_haven/external/api/azure_api.py @@ -227,7 +227,7 @@ def ensure_dns_txt_record( parameters=RecordSet( ttl=30, txt_records=[TxtRecord(value=[record_value])] ), - record_type="TXT", + record_type=RecordType.TXT, relative_record_set_name=record_name, resource_group_name=resource_group_name, zone_name=zone_name, diff --git a/data_safe_haven/external/api/graph_api.py b/data_safe_haven/external/api/graph_api.py index 8edfa063a1..adf3705c3b 100644 --- a/data_safe_haven/external/api/graph_api.py +++ b/data_safe_haven/external/api/graph_api.py @@ -43,15 +43,20 @@ def __del__(self) -> None: class GraphApi: """Interface to the Microsoft Graph REST API""" - linux_schema = "extj8xolrvw_linux" # this is the "Extension with Properties for Linux User and Groups" extension + 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" } uuid_application: ClassVar[dict[str, str]] = { + "Application.ReadWrite.All": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", + "AppRoleAssignment.ReadWrite.All": "06b708a9-e830-4db3-a914-8e69da51d44f", "Directory.Read.All": "7ab1d382-f21e-4acd-a863-ba3e13f7da61", "Domain.Read.All": "dbb9058a-0e50-45d7-ae91-66909b5d4664", "Group.Read.All": "5b567255-7703-4780-807c-7be8301ae99b", "Group.ReadWrite.All": "62a82d76-70ea-41e2-9197-370581804d09", + "GroupMember.Read.All": "98830695-27a2-44f7-8c18-0c3ebc9698f6", "User.Read.All": "df021288-bdef-4463-88db-98f22de89214", "User.ReadWrite.All": "741f803b-c850-494e-b5df-cde7c675a1ca", "UserAuthenticationMethod.ReadWrite.All": "50483e42-d915-4231-9639-7fdb7fd190e5", @@ -213,7 +218,7 @@ def create_application( if scopes: request_json["requiredResourceAccess"] = [ { - "resourceAppId": "00000003-0000-0000-c000-000000000000", # Microsoft Graph: https://graph.microsoft.com + "resourceAppId": self.application_ids["Microsoft Graph"], "resourceAccess": scopes, } ] @@ -225,35 +230,31 @@ def create_application( self.logger.info( f"Created new application '[green]{json_response['displayName']}[/]'.", ) + + # Ensure that the application service principal exists + self.ensure_application_service_principal(application_name) + # Grant admin consent for the requested scopes if application_scopes or delegated_scopes: - application_id = self.get_id_from_application_name(application_name) - application_sp = self.get_service_principal_by_name(application_name) - if not ( - application_sp - and self.read_application_permissions(application_sp["id"]) - ): - self.logger.info( - f"Application [green]{application_name}[/] has requested permissions" - " that need administrator approval." - ) - self.logger.info( - "Please sign-in with [bold]global administrator[/] credentials for the" - " Azure Active Directory where your users are stored." - ) - self.logger.info( - "To sign in, use a web browser to open the page" - f" [green]https://login.microsoftonline.com/{self.tenant_id}/adminconsent?client_id=" - f"{application_id}&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient[/]" - " and follow the instructions." - ) - while True: - if application_sp := self.get_service_principal_by_name( - application_name - ): - if self.read_application_permissions(application_sp["id"]): - break - time.sleep(10) + for scope in application_scopes: + self.grant_application_role_permissions(application_name, scope) + for scope in delegated_scopes: + self.grant_delegated_role_permissions(application_name, scope) + attempts = 0 + max_attempts = 5 + while attempts < max_attempts: + if application_sp := self.get_service_principal_by_name( + application_name + ): + if self.read_application_permissions(application_sp["id"]): + break + time.sleep(10) + attempts += 1 + + if attempts == max_attempts: + msg = "Maximum attempts to validate service principle permissions exceeded" + raise DataSafeHavenMicrosoftGraphError(msg) + # Return JSON representation of the AzureAD application return json_response except Exception as exc: @@ -261,7 +262,7 @@ def create_application( raise DataSafeHavenMicrosoftGraphError(msg) from exc def create_application_secret( - self, application_secret_name: str, application_name: str + self, application_name: str, application_secret_name: str ) -> str: """Add a secret to an existing AzureAD application @@ -308,7 +309,7 @@ def create_application_secret( msg = f"Could not create application secret '{application_secret_name}'.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc - def create_group(self, group_name: str, group_id: str) -> None: + def create_group(self, group_name: str) -> None: """Create an AzureAD group if it does not already exist Raises: @@ -323,7 +324,6 @@ def create_group(self, group_name: str, group_id: str) -> None: self.logger.debug( f"Creating AzureAD group '[green]{group_name}[/]'...", ) - endpoint = f"{self.base_endpoint}/groups" request_json = { "displayName": group_name, "groupTypes": [], @@ -331,21 +331,10 @@ def create_group(self, group_name: str, group_id: str) -> None: "mailNickname": group_name, "securityEnabled": True, } - json_response = self.http_post( - endpoint, + self.http_post( + f"{self.base_endpoint}/groups", json=request_json, ).json() - # Add Linux group name and ID - patch_json = { - self.linux_schema: { - "group": group_name, - "gid": group_id, - } - } - self.http_patch( - f"{self.base_endpoint}/groups/{json_response['id']}", - json=patch_json, - ) self.logger.info( f"Created AzureAD group '[green]{group_name}[/]'.", ) @@ -353,6 +342,42 @@ def create_group(self, group_name: str, group_id: str) -> None: msg = f"Could not create AzureAD group '{group_name}'.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc + def ensure_application_service_principal( + self, application_name: str + ) -> dict[str, Any]: + """Create a service principal for an AzureAD application if it does not already exist + + Raises: + DataSafeHavenMicrosoftGraphError if the service principal could not be created + """ + try: + # Return existing service principal if there is one + application_sp = self.get_service_principal_by_name(application_name) + if not application_sp: + # Otherwise we need to try + self.logger.debug( + f"Creating service principal for application '[green]{application_name}[/]'...", + ) + application_json = self.get_application_by_name(application_name) + if not application_json: + msg = f"Could not retrieve application '{application_name}'" + raise DataSafeHavenMicrosoftGraphError(msg) + self.http_post( + f"{self.base_endpoint}/servicePrincipals", + json={"appId": application_json["appId"]}, + ).json() + self.logger.info( + f"Created service principal for application '[green]{application_name}[/]'.", + ) + application_sp = self.get_service_principal_by_name(application_name) + if not application_sp: + msg = f"service principal for application '[green]{application_name}[/]' not found." + raise DataSafeHavenMicrosoftGraphError(msg) + return application_sp + except Exception as exc: + msg = f"Could not create service principal for application '[green]{application_name}[/]'.\n{exc}" + raise DataSafeHavenMicrosoftGraphError(msg) from exc + def create_token_administrator(self) -> str: """Create an access token for a global administrator @@ -448,20 +473,20 @@ def create_user( DataSafeHavenMicrosoftGraphError if the user could not be created """ username = request_json["mailNickname"] + final_verb = "create/update" try: # Check whether user already exists user_id = self.get_id_from_username(username) - final_verb = "" if user_id: self.logger.debug( f"Updating AzureAD user '[green]{username}[/]'...", ) - final_verb = "Updated" + final_verb = "Update" else: self.logger.debug( f"Creating AzureAD user '[green]{username}[/]'...", ) - final_verb = "Created" + final_verb = "Create" # If they do not then create them endpoint = f"{self.base_endpoint}/users" json_response = self.http_post( @@ -476,8 +501,9 @@ def create_user( json={"emailAddress": email_address}, ) except DataSafeHavenMicrosoftGraphError as exc: - if "already exists" not in str(exc): - raise + if "already registered" not in str(exc): + msg = f"Invalid email address '{email_address}'.\n{exc}" + raise DataSafeHavenMicrosoftGraphError(msg) from exc # Set the authentication phone number try: self.http_post( @@ -485,18 +511,19 @@ def create_user( json={"phoneNumber": phone_number, "phoneType": "mobile"}, ) except DataSafeHavenMicrosoftGraphError as exc: - if "already exists" not in str(exc): - raise + if "already registered" not in str(exc): + msg = f"Invalid phone number '{phone_number}'.\n{exc}" + raise DataSafeHavenMicrosoftGraphError(msg) from exc # Ensure user is enabled self.http_patch( f"{self.base_endpoint}/users/{user_id}", json={"accountEnabled": True}, ) self.logger.info( - f"{final_verb} AzureAD user '[green]{username}[/]'.", + f"{final_verb}d AzureAD user '[green]{username}[/]'.", ) except DataSafeHavenMicrosoftGraphError as exc: - msg = f"Could not create/update user {username}.\n{exc}" + msg = f"Could not {final_verb.lower()} user {username}.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc def delete_application( @@ -573,12 +600,163 @@ def get_id_from_username(self, username: str) -> str | None: next( user for user in self.read_users() - if user["mailNickname"] == username + if user["userPrincipalName"].split("@")[0] == username )["id"] ) except (DataSafeHavenMicrosoftGraphError, StopIteration): return None + def grant_role_permissions( + self, + application_name: str, + *, + application_role_assignments: Sequence[str], + delegated_role_assignments: Sequence[str], + ) -> None: + """ + Grant roles to the service principal associated with an application and give admin approval to these roles + + These can be either application or delegated roles. + + - Application roles allow the application to perform an action itself. + - Delegated roles allow the application to ask a user for permission to perform an action. + + See https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph for more details. + + Raises: + DataSafeHavenMicrosoftGraphError if one or more roles could not be assigned. + """ + # Ensure that the application has a service principal + self.ensure_application_service_principal(application_name) + + # Grant any requested application role permissions + for role_name in application_role_assignments: + self.grant_application_role_permissions(application_name, role_name) + + # Grant any requested delegated role permissions + for role_name in delegated_role_assignments: + self.grant_delegated_role_permissions(application_name, role_name) + + def grant_application_role_permissions( + self, application_name: str, application_role_name: str + ) -> None: + """ + Assign a named application role to the service principal associated with an application. + Additionally provide Global Admin approval for the application to hold this role. + Application roles allow the application to perform an action itself. + + See https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph for more details. + + Raises: + DataSafeHavenMicrosoftGraphError if one or more roles could not be assigned. + """ + try: + # Get service principals for Microsoft Graph and this application + microsoft_graph_sp = self.get_service_principal_by_name("Microsoft Graph") + if not microsoft_graph_sp: + msg = "Could not find Microsoft Graph service principal." + raise DataSafeHavenMicrosoftGraphError(msg) + application_sp = self.get_service_principal_by_name(application_name) + if not application_sp: + msg = f"Could not find application service principal for application {application_name}." + raise DataSafeHavenMicrosoftGraphError(msg) + # Check whether permission is already granted + app_role_id = self.uuid_application[application_role_name] + response = self.http_get( + f"{self.base_endpoint}/servicePrincipals/{microsoft_graph_sp['id']}/appRoleAssignedTo", + ) + for application in response.json().get("value", []): + if (application["appRoleId"] == app_role_id) and ( + application["principalDisplayName"] == application_name + ): + self.logger.debug( + f"Application role '[green]{application_role_name}[/]' already assigned to '{application_name}'.", + ) + return + # Otherwise grant permissions for this role to the application + self.logger.debug( + f"Assigning application role '[green]{application_role_name}[/]' to '{application_name}'...", + ) + request_json = { + "principalId": application_sp["id"], + "resourceId": microsoft_graph_sp["id"], + "appRoleId": app_role_id, + } + response = self.http_post( + f"{self.base_endpoint}/servicePrincipals/{microsoft_graph_sp['id']}/appRoleAssignments", + json=request_json, + ) + self.logger.info( + f"Assigned application role '[green]{application_role_name}[/]' to '{application_name}'.", + ) + except Exception as exc: + msg = f"Could not assign application role '{application_role_name}' to application '{application_name}'.\n{exc}" + raise DataSafeHavenMicrosoftGraphError(msg) from exc + + def grant_delegated_role_permissions( + self, application_name: str, application_role_name: str + ) -> None: + """ + Assign a named delegated role to the service principal associated with an application. + Additionally provide Global Admin approval for the application to hold this role. + Delegated roles allow the application to ask a user for permission to perform an action. + + See https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph for more details. + + Raises: + DataSafeHavenMicrosoftGraphError if one or more roles could not be assigned. + """ + try: + # Get service principals for Microsoft Graph and this application + microsoft_graph_sp = self.get_service_principal_by_name("Microsoft Graph") + if not microsoft_graph_sp: + msg = "Could not find Microsoft Graph service principal." + raise DataSafeHavenMicrosoftGraphError(msg) + application_sp = self.get_service_principal_by_name(application_name) + if not application_sp: + msg = "Could not find application service principal." + raise DataSafeHavenMicrosoftGraphError(msg) + # Check existing permissions + response = self.http_get(f"{self.base_endpoint}/oauth2PermissionGrants") + self.logger.debug( + f"Assigning delegated role '[green]{application_role_name}[/]' to '{application_name}'...", + ) + # If there are existing permissions then we need to patch + application = next( + ( + app + for app in response.json().get("value", []) + if app["clientId"] == application_sp["id"] + ), + None, + ) + if application: + request_json = { + "scope": f"{application['scope']} {application_role_name}" + } + response = self.http_patch( + f"{self.base_endpoint}/oauth2PermissionGrants/{application['id']}", + json=request_json, + ) + # Otherwise we need to make a new delegation request + else: + request_json = { + "clientId": application_sp["id"], + "consentType": "AllPrincipals", + "resourceId": microsoft_graph_sp["id"], + "scope": application_role_name, + } + response = self.http_post( + f"{self.base_endpoint}/oauth2PermissionGrants", + json=request_json, + ) + self.logger.info( + f"Assigned delegated role '[green]{application_role_name}[/]' to '{application_name}'.", + ) + except Exception as exc: + msg = f"Could not assign delegated role '{application_role_name}' to application '{application_name}'.\n{exc}" + raise DataSafeHavenMicrosoftGraphError(msg) from exc + def http_delete(self, url: str, **kwargs: Any) -> requests.Response: """Make an HTTP DELETE request @@ -604,7 +782,7 @@ def http_delete(self, url: str, **kwargs: Any) -> requests.Response: return response raise DataSafeHavenInternalError(response.content) except Exception as exc: - msg = f"Could not execute DELETE request.\n{exc}" + msg = f"Could not execute DELETE request to '{url}'.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc def http_get(self, url: str, **kwargs: Any) -> requests.Response: @@ -632,7 +810,7 @@ def http_get(self, url: str, **kwargs: Any) -> requests.Response: return response raise DataSafeHavenInternalError(response.content) except Exception as exc: - msg = f"Could not execute GET request.\n{exc}" + msg = f"Could not execute GET request from '{url}'.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc def http_patch(self, url: str, **kwargs: Any) -> requests.Response: @@ -660,7 +838,7 @@ def http_patch(self, url: str, **kwargs: Any) -> requests.Response: return response raise DataSafeHavenInternalError(response.content) except Exception as exc: - msg = f"Could not execute PATCH request.\n{exc}" + msg = f"Could not execute PATCH request to '{url}'.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc def http_post(self, url: str, **kwargs: Any) -> requests.Response: @@ -689,7 +867,7 @@ def http_post(self, url: str, **kwargs: Any) -> requests.Response: return response raise DataSafeHavenInternalError(response.content) except Exception as exc: - msg = f"Could not execute POST request.\n{exc}" + msg = f"Could not execute POST request to '{url}'.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc def read_applications(self) -> Sequence[dict[str, Any]]: @@ -814,7 +992,6 @@ def read_users( "surname", "telephoneNumber", "userPrincipalName", - self.linux_schema, ] ) users: Sequence[dict[str, Any]] @@ -831,14 +1008,31 @@ def read_users( user["isGlobalAdmin"] = any( user["id"] == admin["id"] for admin in administrators ) - for key, value in user.get(self.linux_schema, {}).items(): - user[key] = value - user[self.linux_schema] = {} return users except Exception as exc: msg = f"Could not load list of users.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc + def remove_user( + self, + username: str, + ) -> None: + """Remove a user from AzureAD + + Raises: + DataSafeHavenMicrosoftGraphError if the user could not be removed + """ + try: + user_id = self.get_id_from_username(username) + # Attempt to remove user from group + self.http_delete( + f"{self.base_endpoint}/users/{user_id}", + ) + return + except Exception as exc: + msg = f"Could not remove user '{username}'.\n{exc}" + raise DataSafeHavenMicrosoftGraphError(msg) from exc + def remove_user_from_group( self, username: str, @@ -852,10 +1046,24 @@ def remove_user_from_group( try: user_id = self.get_id_from_username(username) group_id = self.get_id_from_groupname(group_name) - # Attempt to remove user from group - self.http_delete( - f"{self.base_endpoint}/groups/{group_id}/members/{user_id}/$ref", - ) + # Check whether user is in group + json_response = self.http_get( + f"{self.base_endpoint}/groups/{group_id}/members", + ).json() + # Remove user from group if it is a member + if user_id in ( + group_member["id"] for group_member in json_response["value"] + ): + self.http_delete( + f"{self.base_endpoint}/groups/{group_id}/members/{user_id}/$ref", + ) + self.logger.info( + f"Removed [green]'{username}'[/] from group [green]'{group_name}'[/]." + ) + else: + self.logger.info( + f"User [green]'{username}'[/] does not belong to group [green]'{group_name}'[/]." + ) except Exception as exc: msg = ( f"Could not remove user '{username}' from group '{group_name}'.\n{exc}" diff --git a/data_safe_haven/infrastructure/components/dynamic/azuread_application.py b/data_safe_haven/infrastructure/components/dynamic/azuread_application.py index 253231804b..e24c3f25d7 100644 --- a/data_safe_haven/infrastructure/components/dynamic/azuread_application.py +++ b/data_safe_haven/infrastructure/components/dynamic/azuread_application.py @@ -18,12 +18,20 @@ class AzureADApplicationProps: def __init__( self, application_name: Input[str], - application_url: Input[str], auth_token: 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_url = application_url - self.auth_token = auth_token + self.application_role_assignments = application_role_assignments + self.application_secret_name = application_secret_name + self.auth_token = Output.secret(auth_token) + self.delegated_role_assignments = delegated_role_assignments + self.public_client_redirect_uri = public_client_redirect_uri + self.web_redirect_url = web_redirect_url class AzureADApplicationProvider(DshResourceProvider): @@ -40,6 +48,17 @@ def refresh(props: dict[str, Any]) -> dict[str, Any]: ): 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 [green]{props['application_name']}[/] in AzureAD.\n{exc}" @@ -50,19 +69,48 @@ def create(self, props: dict[str, Any]) -> CreateResult: outs = dict(**props) try: graph_api = GraphApi(auth_token=props["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"], - request_json={ - "displayName": props["application_name"], - "web": { - "redirectUris": [props["application_url"]], - "implicitGrantSettings": {"enableIdTokenIssuance": True}, - }, - "signInAudience": "AzureADMyOrg", - }, + 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 [green]{props['application_name']}[/] in AzureAD.\n{exc}" raise DataSafeHavenMicrosoftGraphError(msg) from exc @@ -116,6 +164,7 @@ def update( class AzureADApplication(Resource): application_id: Output[str] + application_secret: Output[str] object_id: Output[str] _resource_type_name = "dsh:common:AzureADApplication" # set resource type @@ -128,6 +177,11 @@ def __init__( super().__init__( AzureADApplicationProvider(), name, - {"application_id": None, "object_id": None, **vars(props)}, + { + "application_id": None, + "application_secret": None, + "object_id": None, + **vars(props), + }, opts, ) diff --git a/data_safe_haven/infrastructure/stacks/sre/remote_desktop.py b/data_safe_haven/infrastructure/stacks/sre/remote_desktop.py index 0ea737e7d5..27263ab814 100644 --- a/data_safe_haven/infrastructure/stacks/sre/remote_desktop.py +++ b/data_safe_haven/infrastructure/stacks/sre/remote_desktop.py @@ -136,8 +136,8 @@ def __init__( f"{self._name}_aad_application", AzureADApplicationProps( application_name=props.aad_application_name, - application_url=props.aad_application_url, auth_token=props.aad_auth_token, + web_redirect_url=props.aad_application_url, ), opts=child_opts, )