Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Pulumi to create Entra applications #2248

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
41e3ac4
:wrench: Add GUID for Directory.ReadWrite.All permission
jemrobinson Oct 18, 2024
dc14741
:sparkles: Add pulumi-azuread EntraApplication component
jemrobinson Oct 18, 2024
f533b0e
:sparkles: Add support for web applications as well as desktop applic…
jemrobinson Oct 22, 2024
a6ddc98
:recycle: Use composite EntraApplication component for identity server
jemrobinson Oct 22, 2024
ba1f5b3
:truck: Consolidate remote desktop URL construction in Entra component
jemrobinson Oct 22, 2024
5df24d7
:recycle: Use composite EntraApplication component for remote desktop…
jemrobinson Oct 22, 2024
c43708b
:coffin: Drop dynamic EntraApplication component
jemrobinson Oct 22, 2024
c0d9857
:bug: Use client_id instead of id for Entra applications
jemrobinson Oct 23, 2024
4f9aa97
:recycle: Add an EntraAppPermissionType enum to ensure that Role/Scop…
jemrobinson Oct 23, 2024
89d9662
:truck: Simplify import path for Entra components
jemrobinson Oct 23, 2024
463f490
:coffin: Drop requirements for GraphAPI everywhere in the SRE where t…
jemrobinson Oct 24, 2024
8942257
:alien: Do not replace azuread:clientId or azuread:tenantId in stack …
jemrobinson Oct 25, 2024
23918b9
:wrench: Add an EntraSignInAudienceType enum
jemrobinson Oct 28, 2024
e740ba0
:loud_sound: Add comments explaining application scope requirements
jemrobinson Oct 28, 2024
7ef3d6c
:art: Add an enum for Entra application IDs
jemrobinson Oct 29, 2024
fd34042
:recycle: Refactor following suggestions from code review
jemrobinson Oct 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions data_safe_haven/commands/pulumi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down
18 changes: 5 additions & 13 deletions data_safe_haven/commands/sre.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,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(
Expand Down Expand Up @@ -99,15 +98,17 @@ 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)
stack.add_secret(
"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(
Expand Down Expand Up @@ -153,7 +154,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,
Expand Down Expand Up @@ -183,15 +183,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)
Expand All @@ -212,7 +205,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)
Expand Down
19 changes: 11 additions & 8 deletions data_safe_haven/external/api/graph_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,26 @@
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


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"
}
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",
"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",
Expand Down Expand Up @@ -192,33 +195,33 @@ def create_application(
if not request_json:
request_json = {
"displayName": application_name,
"signInAudience": "AzureADMyOrg",
"passwordCredentials": [],
"publicClient": {
"redirectUris": [
"https://login.microsoftonline.com/common/oauth2/nativeclient",
"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,
}
]
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions data_safe_haven/infrastructure/components/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from .composite import (
EntraApplicationComponent,
EntraDesktopApplicationProps,
EntraWebApplicationProps,
LinuxVMComponentProps,
LocalDnsRecordComponent,
LocalDnsRecordProps,
Expand All @@ -13,8 +16,6 @@
from .dynamic import (
BlobContainerAcl,
BlobContainerAclProps,
EntraApplication,
EntraApplicationProps,
FileShareFile,
FileShareFileProps,
SSLCertificate,
Expand All @@ -28,8 +29,9 @@
__all__ = [
"BlobContainerAcl",
"BlobContainerAclProps",
"EntraApplication",
"EntraApplicationProps",
"EntraApplicationComponent",
"EntraDesktopApplicationProps",
"EntraWebApplicationProps",
"FileShareFile",
"FileShareFileProps",
"LinuxVMComponentProps",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from .entra_application import (
EntraApplicationComponent,
EntraDesktopApplicationProps,
EntraWebApplicationProps,
)
from .local_dns_record import LocalDnsRecordComponent, LocalDnsRecordProps
from .microsoft_sql_database import (
MicrosoftSQLDatabaseComponent,
Expand All @@ -8,6 +13,9 @@
from .virtual_machine import LinuxVMComponentProps, VMComponent

__all__ = [
"EntraApplicationComponent",
"EntraDesktopApplicationProps",
"EntraWebApplicationProps",
"LinuxVMComponentProps",
"LocalDnsRecordComponent",
"LocalDnsRecordProps",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
]
Loading
Loading