Skip to content

Commit

Permalink
Merge pull request #2248 from jemrobinson/2215-create-entra-applicati…
Browse files Browse the repository at this point in the history
…ons-with-pulumi

Use Pulumi to create Entra applications
  • Loading branch information
jemrobinson authored Oct 29, 2024
2 parents 1bf9d0c + fd34042 commit 868d41c
Show file tree
Hide file tree
Showing 21 changed files with 342 additions and 316 deletions.
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

0 comments on commit 868d41c

Please sign in to comment.