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

Move security group creation to Pulumi #2160

Draft
wants to merge 10 commits into
base: develop
Choose a base branch
from
15 changes: 15 additions & 0 deletions data_safe_haven/commands/sre.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ def deploy(
stack.add_option(
"azure-native:tenantId", sre_config.azure.tenant_id, replace=False
)
# Set Entra options
application = graph_api.get_application_by_name(context.entra_application_name)
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)
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
)
# Load SHM outputs
stack.add_option(
"shm-admin-group-id",
Expand Down
80 changes: 57 additions & 23 deletions data_safe_haven/config/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,46 +9,56 @@

from data_safe_haven import __version__
from data_safe_haven.directories import config_dir
from data_safe_haven.exceptions import DataSafeHavenAzureError
from data_safe_haven.external import AzureSdk
from data_safe_haven.functions import alphanumeric
from data_safe_haven.serialisers import ContextBase
from data_safe_haven.types import AzureSubscriptionName, EntraGroupName, SafeString


class Context(ContextBase, BaseModel, validate_assignment=True):
"""Context for a Data Safe Haven deployment."""

entra_application_kvsecret_name: ClassVar[str] = "pulumi-deployment-secret"
entra_application_secret_name: ClassVar[str] = "Pulumi Deployment Secret"
pulumi_encryption_key_name: ClassVar[str] = "pulumi-encryption-key"
pulumi_storage_container_name: ClassVar[str] = "pulumi"
storage_container_name: ClassVar[str] = "config"

admin_group_name: EntraGroupName
description: str
name: SafeString
subscription_name: AzureSubscriptionName
storage_container_name: ClassVar[str] = "config"
pulumi_storage_container_name: ClassVar[str] = "pulumi"
pulumi_encryption_key_name: ClassVar[str] = "pulumi-encryption-key"

_pulumi_encryption_key = None
_entra_application_secret = None

@property
def tags(self) -> dict[str, str]:
return {
"description": self.description,
"project": "Data Safe Haven",
"shm_name": self.name,
"version": __version__,
}
def entra_application_name(self) -> str:
return f"Data Safe Haven ({self.description}) Pulumi Service Principal"

@property
def work_directory(self) -> Path:
return config_dir() / self.name

@property
def resource_group_name(self) -> str:
return f"shm-{self.name}-rg"

@property
def storage_account_name(self) -> str:
# https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview#storage-account-name
# Storage account names must be between 3 and 24 characters in length and may
# contain numbers and lowercase letters only.
return f"shm{alphanumeric(self.name)[:21]}"
def entra_application_secret(self) -> str:
if not self._entra_application_secret:
azure_sdk = AzureSdk(subscription_name=self.subscription_name)
try:
application_secret = azure_sdk.get_keyvault_secret(
secret_name=self.entra_application_kvsecret_name,
key_vault_name=self.key_vault_name,
)
self._entra_application_secret = application_secret
except DataSafeHavenAzureError:
return ""
return self._entra_application_secret

@entra_application_secret.setter
def entra_application_secret(self, application_secret: str) -> None:
azure_sdk = AzureSdk(subscription_name=self.subscription_name)
azure_sdk.set_keyvault_secret(
secret_name=self.entra_application_kvsecret_name,
secret_value=application_secret,
key_vault_name=self.key_vault_name,
)

@property
def key_vault_name(self) -> str:
Expand Down Expand Up @@ -83,5 +93,29 @@ def pulumi_encryption_key_version(self) -> str:
def pulumi_secrets_provider_url(self) -> str:
return f"azurekeyvault://{self.key_vault_name}.vault.azure.net/keys/{self.pulumi_encryption_key_name}/{self.pulumi_encryption_key_version}"

@property
def resource_group_name(self) -> str:
return f"shm-{self.name}-rg"

@property
def storage_account_name(self) -> str:
# https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview#storage-account-name
# Storage account names must be between 3 and 24 characters in length and may
# contain numbers and lowercase letters only.
return f"shm{alphanumeric(self.name)[:21]}"

@property
def tags(self) -> dict[str, str]:
return {
"description": self.description,
"project": "Data Safe Haven",
"shm_name": self.name,
"version": __version__,
}

@property
def work_directory(self) -> Path:
return config_dir() / self.name

def to_yaml(self) -> str:
return yaml.dump(self.model_dump(), indent=2)
1 change: 1 addition & 0 deletions data_safe_haven/config/dsh_pulumi_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class DSHPulumiConfig(AzureSerialisableModel):

config_type: ClassVar[str] = "Pulumi"
default_filename: ClassVar[str] = "pulumi.yaml"

encrypted_key: str | None
projects: dict[str, DSHPulumiProject]

Expand Down
3 changes: 3 additions & 0 deletions data_safe_haven/config/shm_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@


class SHMConfig(AzureSerialisableModel):
"""Serialisable config for a Data Safe Haven management component."""

config_type: ClassVar[str] = "SHMConfig"
default_filename: ClassVar[str] = "shm.yaml"

azure: ConfigSectionAzure
shm: ConfigSectionSHM

Expand Down
3 changes: 3 additions & 0 deletions data_safe_haven/config/sre_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ def sre_config_name(sre_name: str) -> str:


class SREConfig(AzureSerialisableModel):
"""Serialisable config for a secure research environment component."""

config_type: ClassVar[str] = "SREConfig"
default_filename: ClassVar[str] = "sre.yaml"

azure: ConfigSectionAzure
description: str
dockerhub: ConfigSectionDockerHub
Expand Down
40 changes: 36 additions & 4 deletions data_safe_haven/external/api/azure_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)
from azure.keyvault.certificates import CertificateClient, KeyVaultCertificate
from azure.keyvault.keys import KeyClient, KeyVaultKey
from azure.keyvault.secrets import SecretClient
from azure.keyvault.secrets import KeyVaultSecret, SecretClient
from azure.mgmt.compute.v2021_07_01 import ComputeManagementClient
from azure.mgmt.compute.v2021_07_01.models import (
ResourceSkuCapabilities,
Expand Down Expand Up @@ -447,7 +447,7 @@ def ensure_keyvault_key(
"""Ensure that a key exists in the KeyVault

Returns:
str: The key ID
KeyVaultKey: The key

Raises:
DataSafeHavenAzureError if the existence of the key could not be verified
Expand All @@ -472,7 +472,7 @@ def ensure_keyvault_key(
)
return key
except AzureError as exc:
msg = f"Failed to create key {key_name}."
msg = f"Failed to create key '{key_name}' in KeyVault '{key_vault_name}'."
raise DataSafeHavenAzureError(msg) from exc

def ensure_managed_identity(
Expand Down Expand Up @@ -689,7 +689,7 @@ def get_keyvault_secret(self, key_vault_name: str, secret_name: str) -> str:
credential=self.credential(AzureSdkCredentialScope.KEY_VAULT),
vault_url=f"https://{key_vault_name}.vault.azure.net",
)
# Ensure that secret exists
# Get secret if it exists
try:
secret = secret_client.get_secret(secret_name)
if secret.value:
Expand Down Expand Up @@ -1283,6 +1283,38 @@ def set_blob_container_acl(
msg = f"Failed to set ACL '{desired_acl}' on container '{container_name}'."
raise DataSafeHavenAzureError(msg) from exc

def set_keyvault_secret(
self,
secret_name: str,
secret_value: str,
key_vault_name: str,
) -> KeyVaultSecret:
"""Ensure that a secret exists in the KeyVault

Returns:
KeyVaultSecret: The secret

Raises:
DataSafeHavenAzureError if the secret could not be set
"""
try:
# Connect to Azure clients
secret_client = SecretClient(
credential=self.credential(AzureSdkCredentialScope.KEY_VAULT),
vault_url=f"https://{key_vault_name}.vault.azure.net",
)

# Set secret to given value
self.logger.debug(f"Setting secret [green]{secret_name}[/]...")
secret = secret_client.set_secret(secret_name, secret_value)
self.logger.info(f"Set secret [green]{secret_name}[/].")
return secret
except AzureError as exc:
msg = (
f"Failed to set secret '{secret_name}' in KeyVault '{key_vault_name}'."
)
raise DataSafeHavenAzureError(msg) from exc

def storage_exists(
self,
storage_account_name: str,
Expand Down
51 changes: 7 additions & 44 deletions data_safe_haven/external/api/graph_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
DataSafeHavenMicrosoftGraphError,
DataSafeHavenValueError,
)
from data_safe_haven.functions import alphanumeric
from data_safe_haven.logging import get_logger, get_null_logger

from .credentials import DeferredCredential, GraphApiCredential
Expand Down Expand Up @@ -314,40 +313,6 @@ def create_application_secret(
msg = f"Could not create application secret '{application_secret_name}'."
raise DataSafeHavenMicrosoftGraphError(msg) from exc

def create_group(self, group_name: str) -> None:
"""Create an Entra group if it does not already exist

Raises:
DataSafeHavenMicrosoftGraphError if the group could not be created
"""
try:
if self.get_id_from_groupname(group_name):
self.logger.info(
f"Found existing Entra group '[green]{group_name}[/]'.",
)
return
self.logger.debug(
f"Creating Entra group '[green]{group_name}[/]'...",
)
request_json = {
"description": group_name,
"displayName": group_name,
"groupTypes": [],
"mailEnabled": False,
"mailNickname": alphanumeric(group_name).lower(),
"securityEnabled": True,
}
self.http_post(
f"{self.base_endpoint}/groups",
json=request_json,
).json()
self.logger.info(
f"Created Entra group '[green]{group_name}[/]'.",
)
except Exception as exc:
msg = f"Could not create Entra group '{group_name}'."
raise DataSafeHavenMicrosoftGraphError(msg) from exc

def ensure_application_service_principal(
self, application_name: str
) -> dict[str, Any]:
Expand Down Expand Up @@ -1047,17 +1012,17 @@ def verify_custom_domain(
DataSafeHavenMicrosoftGraphError if domain could not be verified
"""
try:
# Create the Entra custom domain if it does not already exist
# Check whether the domain has been added to Entra ID
domains = self.read_domains()
if not any(d["id"] == domain_name for d in domains):
msg = f"Domain {domain_name} has not been added to Entra ID."
raise DataSafeHavenMicrosoftGraphError(msg)
# Wait until domain delegation is complete
# Loop until domain delegation is complete
while True:
# Check whether all expected nameservers are active
with suppress(resolver.NXDOMAIN):
self.logger.debug(
f"Checking [green]{domain_name}[/] domain verification status ..."
f"Checking [green]{domain_name}[/] domain registration status ..."
)
active_nameservers = [
str(ns) for ns in iter(resolver.resolve(domain_name, "NS"))
Expand All @@ -1067,11 +1032,11 @@ def verify_custom_domain(
for nameserver in expected_nameservers
):
self.logger.info(
f"Verified that domain [green]{domain_name}[/] is delegated to Azure."
f"Verified that [green]{domain_name}[/] is registered as a custom Entra ID domain."
)
break
self.logger.warning(
f"Domain [green]{domain_name}[/] is not currently delegated to Azure."
f"Domain [green]{domain_name}[/] is not currently registered as a custom Entra ID domain."
)
# Prompt user to set domain delegation manually
docs_link = "https://learn.microsoft.com/en-us/azure/dns/dns-delegate-domain-azure-dns#delegate-the-domain"
Expand All @@ -1080,15 +1045,13 @@ def verify_custom_domain(
)
ns_list = ", ".join([f"[green]{n}[/]" for n in expected_nameservers])
self.logger.info(
f"You will need to create an NS record pointing to: {ns_list}"
f"You will need to create NS records pointing to: {ns_list}"
)
if not console.confirm(
f"Are you ready to check whether [green]{domain_name}[/] has been delegated to Azure?",
default_to_yes=True,
):
self.logger.error(
"Please use `az login` to connect to the correct Azure CLI account"
)
self.logger.error("User terminated check for domain delegation.")
raise typer.Exit(1)
# Send verification request if needed
if not any((d["id"] == domain_name and d["isVerified"]) for d in domains):
Expand Down
9 changes: 9 additions & 0 deletions data_safe_haven/infrastructure/programs/declarative_sre.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .sre.data import SREDataComponent, SREDataProps
from .sre.desired_state import SREDesiredStateComponent, SREDesiredStateProps
from .sre.dns_server import SREDnsServerComponent, SREDnsServerProps
from .sre.entra import SREEntraComponent, SREEntraProps
from .sre.firewall import SREFirewallComponent, SREFirewallProps
from .sre.identity import SREIdentityComponent, SREIdentityProps
from .sre.monitoring import SREMonitoringComponent, SREMonitoringProps
Expand Down Expand Up @@ -108,6 +109,14 @@ 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",
Expand Down
Loading
Loading