Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/release-v5.0.0' into security_ch…
Browse files Browse the repository at this point in the history
…ecklist
  • Loading branch information
JimMadge committed Aug 20, 2024
2 parents a9f0823 + 058ef41 commit 7cc3d90
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 41 deletions.
66 changes: 63 additions & 3 deletions data_safe_haven/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
import typer

from data_safe_haven import console
from data_safe_haven.config import ContextManager, SHMConfig, SREConfig
from data_safe_haven.config import ContextManager, DSHPulumiConfig, SHMConfig, SREConfig
from data_safe_haven.exceptions import (
DataSafeHavenAzureStorageError,
DataSafeHavenConfigError,
DataSafeHavenError,
DataSafeHavenPulumiError,
)
from data_safe_haven.external.api.azure_sdk import AzureSdk
from data_safe_haven.infrastructure import SREProjectManager
from data_safe_haven.logging import get_logger

config_command_group = typer.Typer()
Expand Down Expand Up @@ -51,6 +54,59 @@ def show_shm(


# Commands related to an SRE
@config_command_group.command()
def available() -> None:
"""List the available SRE configurations for the selected Data Safe Haven context"""
logger = get_logger()
try:
context = ContextManager.from_file().assert_context()
except DataSafeHavenConfigError as exc:
logger.critical(
"No context is selected. Use `dsh context add` to create a context "
"or `dsh context switch` to select one."
)
raise typer.Exit(1) from exc
azure_sdk = AzureSdk(context.subscription_name)
try:
blobs = azure_sdk.list_blobs(
container_name=context.storage_container_name,
prefix="sre",
resource_group_name=context.resource_group_name,
storage_account_name=context.storage_account_name,
)
except DataSafeHavenAzureStorageError as exc:
logger.critical("Ensure SHM is deployed before attempting to use SRE configs.")
raise typer.Exit(1) from exc
if not blobs:
logger.info(f"No configurations found for context '{context.name}'.")
raise typer.Exit(0)
pulumi_config = DSHPulumiConfig.from_remote(context)
sre_status = {}
for blob in blobs:
sre_config = SREConfig.from_remote_by_name(
context, blob.removeprefix("sre-").removesuffix(".yaml")
)
stack = SREProjectManager(
context=context,
config=sre_config,
pulumi_config=pulumi_config,
create_project=True,
)
try:
sre_status[sre_config.name] = (
"No output values" not in stack.run_pulumi_command("stack output")
)
except DataSafeHavenPulumiError as exc:
logger.error(
f"Failed to run Pulumi command querying stack outputs for SRE '{sre_config.name}'."
)
raise typer.Exit(1) from exc
headers = ["SRE Name", "Deployed"]
rows = [[name, "x" if deployed else ""] for name, deployed in sre_status.items()]
console.print(f"Available SRE configurations for context '{context.name}':")
console.tabulate(headers, rows)


@config_command_group.command()
def show(
name: Annotated[str, typer.Argument(help="Name of SRE to show")],
Expand Down Expand Up @@ -92,10 +148,14 @@ def template(
file: Annotated[
Optional[Path], # noqa: UP007
typer.Option(help="File path to write configuration template to."),
] = None
] = None,
tier: Annotated[
Optional[int], # noqa: UP007
typer.Option(help="Which security tier to base this template on."),
] = None,
) -> None:
"""Write a template Data Safe Haven SRE configuration."""
sre_config = SREConfig.template()
sre_config = SREConfig.template(tier)
# The template uses explanatory strings in place of the expected types.
# Serialisation warnings are therefore suppressed to avoid misleading the users into
# thinking there is a problem and contaminating the output.
Expand Down
40 changes: 33 additions & 7 deletions data_safe_haven/config/sre_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from data_safe_haven.functions import json_safe
from data_safe_haven.serialisers import AzureSerialisableModel, ContextBase
from data_safe_haven.types import SafeString
from data_safe_haven.types import SafeString, SoftwarePackageCategory

from .config_sections import (
ConfigSectionAzure,
Expand Down Expand Up @@ -44,8 +44,34 @@ def from_remote_by_name(
return cls.from_remote(context, filename=sre_config_name(sre_name))

@classmethod
def template(cls: type[Self]) -> SREConfig:
def template(cls: type[Self], tier: int | None = None) -> SREConfig:
"""Create SREConfig without validation to allow "replace me" prompts."""
# Set tier-dependent defaults
if tier == 0:
remote_desktop_allow_copy = True
remote_desktop_allow_paste = True
software_packages = SoftwarePackageCategory.ANY
elif tier == 1:
remote_desktop_allow_copy = True
remote_desktop_allow_paste = True
software_packages = SoftwarePackageCategory.ANY
elif tier == 2: # noqa: PLR2004
remote_desktop_allow_copy = False
remote_desktop_allow_paste = False
software_packages = SoftwarePackageCategory.ANY
elif tier == 3: # noqa: PLR2004
remote_desktop_allow_copy = False
remote_desktop_allow_paste = False
software_packages = SoftwarePackageCategory.PRE_APPROVED
elif tier == 4: # noqa: PLR2004
remote_desktop_allow_copy = False
remote_desktop_allow_paste = False
software_packages = SoftwarePackageCategory.NONE
else:
remote_desktop_allow_copy = "True/False: whether to allow copying text out of the environment." # type: ignore
remote_desktop_allow_paste = "True/False: whether to allow pasting text into the environment." # type: ignore
software_packages = "Which Python/R packages to allow users to install: [any/pre-approved/none]" # type: ignore

return SREConfig.model_construct(
azure=ConfigSectionAzure.model_construct(
location="Azure location where SRE resources will be deployed.",
Expand All @@ -66,14 +92,14 @@ def template(cls: type[Self]) -> SREConfig:
"List of IP addresses belonging to data providers"
],
remote_desktop=ConfigSubsectionRemoteDesktopOpts.model_construct(
allow_copy="True/False: whether to allow copying text out of the environment.", # type:ignore
allow_paste="True/False: whether to allow pasting text into the environment.", # type:ignore
allow_copy=remote_desktop_allow_copy,
allow_paste=remote_desktop_allow_paste,
),
research_user_ip_addresses=["List of IP addresses belonging to users"],
software_packages="Which Python/R packages to allow users to install: [any/pre-approved/none]", # type:ignore
software_packages=software_packages,
storage_quota_gb=ConfigSubsectionStorageQuotaGB.model_construct(
home="Total size in GiB across all home directories [minimum: 100].", # type:ignore
shared="Total size in GiB for the shared directories [minimum: 100].", # type:ignore
home="Total size in GiB across all home directories [minimum: 100].", # type: ignore
shared="Total size in GiB for the shared directories [minimum: 100].", # type: ignore
),
timezone="Timezone in pytz format (eg. Europe/London)",
workspace_skus=[
Expand Down
21 changes: 21 additions & 0 deletions data_safe_haven/external/api/azure_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,27 @@ def list_available_vm_skus(self, location: str) -> dict[str, dict[str, Any]]:
msg = f"Failed to load available VM sizes for Azure location {location}."
raise DataSafeHavenAzureError(msg) from exc

def list_blobs(
self,
container_name: str,
prefix: str,
resource_group_name: str,
storage_account_name: str,
) -> list[str]:
"""List all blobs with a given prefix in a container
Returns:
List[str]: The list of blob names
"""

blob_client = self.blob_service_client(
resource_group_name=resource_group_name,
storage_account_name=storage_account_name,
)
container_client = blob_client.get_container_client(container=container_name)
blob_list = container_client.list_blob_names(name_starts_with=prefix)
return list(blob_list)

def purge_keyvault(
self,
key_vault_name: str,
Expand Down
14 changes: 10 additions & 4 deletions data_safe_haven/external/api/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import jwt
from azure.core.credentials import AccessToken, TokenCredential
from azure.core.exceptions import ClientAuthenticationError
from azure.identity import (
AuthenticationRecord,
AzureCliCredential,
Expand Down Expand Up @@ -202,10 +203,15 @@ def callback(verification_uri: str, user_code: str, _: datetime) -> None:
**kwargs,
)

# Write out an authentication record for this credential
new_auth_record = credential.authenticate(scopes=self.scopes)
with open(authentication_record_path, "w") as f_auth:
f_auth.write(new_auth_record.serialize())
# Attempt to authenticate, writing out the record if successful
try:
new_auth_record = credential.authenticate(scopes=self.scopes)
with open(authentication_record_path, "w") as f_auth:
f_auth.write(new_auth_record.serialize())
except ClientAuthenticationError as exc:
self.logger.error(exc.message)
msg = "Error getting account information from Microsoft Graph API."
raise DataSafeHavenAzureError(msg) from exc

# Confirm that these are the desired credentials
self.confirm_credentials_interactive(
Expand Down
17 changes: 15 additions & 2 deletions docs/source/deployment/deploy_sre.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ $ hatch shell
This ensures that you are using the intended version of Data Safe Haven with the correct set of dependencies.
::::

::::{important}
::::{note}
As the Basic Application Gateway is still in preview, you will need to run the following commands once per subscription:

:::{code} shell
Expand All @@ -33,11 +33,24 @@ $ az provider register --name Microsoft.Network

Each project will have its own dedicated SRE.

- Create a configuration file
- Create a configuration file (optionally starting from one of our standard {ref}`policy_classification_sensitivity_tiers`)

::::{admonition} EITHER start from a blank template
:class: dropdown note

:::{code} shell
$ dsh config template --file PATH_YOU_WANT_TO_SAVE_YOUR_YAML_FILE_TO
:::
::::

::::{admonition} OR start from a predefined tier
:class: dropdown note

:::{code} shell
$ dsh config template --file PATH_YOU_WANT_TO_SAVE_YOUR_YAML_FILE_TO \
--tier TIER_YOU_WANT_TO_USE
:::
::::

- Edit this file in your favourite text editor, replacing the placeholder text with appropriate values for your setup.

Expand Down
10 changes: 2 additions & 8 deletions docs/source/deployment/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,8 @@ See [the instructions here](https://docs.docker.com/security/for-developers/acce

## Install the project

Download or checkout this code from GitHub.

:::{important}
**{sub-ref}`today`**: you should use the `develop` branch as no stable v5 release has been tagged.
Please contact the development team in case of any problems.
:::

Enter the base directory and install Python dependencies with `hatch` by doing the following:
- Download or checkout the [latest supported version](https://github.com/alan-turing-institute/data-safe-haven/blob/develop/SECURITY.md) of this code from [GitHub](https://github.com/alan-turing-institute/data-safe-haven).
- Enter the base directory and install Python dependencies with `hatch` by doing the following:

:::{code} shell
$ hatch run true
Expand Down
6 changes: 3 additions & 3 deletions docs/source/management/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ $ dsh users add PATH_TO_MY_CSV_FILE
- You can do this from the [Microsoft Entra admin centre](https://entra.microsoft.com/)

1. Browse to **{menuselection}`Groups --> All Groups`**
2. Click on the group named **Data Safe Haven SRE _SRE-NAME_ Users**
2. Click on the group named **Data Safe Haven SRE _YOUR\_SRE\_NAME_ Users**
3. Browse to **{menuselection}`Manage --> Members`** from the secondary menu on the left side

- You can do this at the command line by running the following command:
Expand Down Expand Up @@ -79,9 +79,9 @@ Users created via the `dsh users` command line tool will be automatically regist
If you have manually created a user and want to enable SSPR, do the following
- Go to the [Microsoft Entra admin centre](https://entra.microsoft.com/)
- Browse to **Users > All Users** from the menu on the left side
- Browse to **{menuselection}`Users --> All Users`**
- Select the user you want to enable SSPR for
- On the **Manage > Authentication Methods** page fill out their contact info as follows:
- On the **{menuselection}`Manage --> Authentication Methods`** page fill out their contact info as follows:
- Ensure that you register **both** a phone number and an email address
- **Phone:** add the user's phone number with a space between the country code and the rest of the number (_e.g._ +44 7700900000)
- **Email:** enter the user's email address here
Expand Down
31 changes: 17 additions & 14 deletions docs/source/roles/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,28 @@ researcher/index.md
system_manager/index.md
:::

Several aspects of the Data Safe Haven rely on role-based access controls.
Both organisational and user roles are important to the operation of a Data Safe Haven.
You will encounter references to these roles at several points in the rest of this documentation.

## Organisation roles
## Organisational roles

There may be different organisations involved in the operation of a Data Safe Haven.
Most common are those listed below:
The different organisational roles are detailed alphabetically below.
In some cases, one organisation fulfills multiple roles, in other cases multiple organisations share a single role.

(role_organisation_data_provider)=

### Data Provider

The organisation providing the dataset(s) that are being analysed.
Data Provider
: The organisation providing the dataset(s) that are being analysed.

(role_organisation_dsh_host)=

### Hosting Organisation
Data Safe Haven Host
: The organisation responsible for deploying, hosting and running the Data Safe Haven.

(role_organisation_research_institution)=

The organisation responsible for deploying, hosting and running the Data Safe Haven.
Research Institution
: The organisation responsible for deploying, hosting and running the Data Safe Haven.

## User roles

Expand All @@ -43,16 +46,16 @@ The different user roles are detailed alphabetically below.
: a representative of the {ref}`data provider <role_organisation_data_provider>`.

{ref}`role_investigator`
: the lead researcher on a project with overall responsibility for it.
: the lead researcher at a {ref}`research institution <role_organisation_research_institution>` with overall responsibility for the research project.

{ref}`role_programme_manager`
: a designated staff member at the {ref}`hosting institution <role_organisation_dsh_host>` with overall responsibility for creating and monitoring projects.
: a designated staff member at the {ref}`Data Safe Haven host <role_organisation_dsh_host>` with overall responsibility for creating and monitoring projects.

{ref}`role_project_manager`
: a designated staff member at the {ref}`hosting institution <role_organisation_dsh_host>` who is responsibile for running a particular project.
: a designated staff member at the {ref}`Data Safe Haven host <role_organisation_dsh_host>` who is responsibile for running a particular project.

{ref}`role_researcher`
: a member of a particular project, who analyses data to produce results.
: a member of a {ref}`research institution <role_organisation_research_institution>` who works on a particular project, analysing data to produce results.

{ref}`role_system_manager`
: a designated staff member at the {ref}`hosting institution <role_organisation_dsh_host>` who is responsible for administering the Data Safe Haven.
: a designated staff member at the {ref}`Data Safe Haven host <role_organisation_dsh_host>` who is responsible for administering the Data Safe Haven.
38 changes: 38 additions & 0 deletions tests/commands/test_config_sre.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,44 @@ def test_incorrect_sre_name(self, mocker, runner):
assert "No configuration exists for an SRE" in result.stdout
assert result.exit_code == 1

def test_available(
self,
context_manager,
mocker,
runner,
mock_pulumi_config_no_key_from_remote, # noqa: ARG002
mock_sre_config_from_remote, # noqa: ARG002
sre_project_manager, # noqa: ARG002
):
mocker.patch.object(ContextManager, "from_file", return_value=context_manager)
mocker.patch.object(AzureSdk, "list_blobs", return_value=["sandbox", "other"])
result = runner.invoke(config_command_group, ["available"])
assert result.exit_code == 0
assert "Available SRE configurations" in result.stdout
assert "sandbox" in result.stdout

def test_available_no_sres(self, mocker, runner):
mocker.patch.object(AzureSdk, "list_blobs", return_value=[])
result = runner.invoke(config_command_group, ["available"])
assert result.exit_code == 0
assert "No configurations found" in result.stdout

def test_available_no_context(self, mocker, runner):
mocker.patch.object(
ContextManager, "from_file", side_effect=DataSafeHavenConfigError(" ")
)
result = runner.invoke(config_command_group, ["available"])
assert result.exit_code == 1
assert "No context is selected" in result.stdout

def test_available_no_storage(self, mocker, runner):
mocker.patch.object(
AzureSdk, "list_blobs", side_effect=DataSafeHavenAzureStorageError(" ")
)
result = runner.invoke(config_command_group, ["available"])
assert result.exit_code == 1
assert "Ensure SHM is deployed" in result.stdout


class TestTemplateSRE:
def test_template(self, runner):
Expand Down

0 comments on commit 7cc3d90

Please sign in to comment.