diff --git a/.github/workflows/functionApp.yml b/.github/workflows/functionApp.yml index ab7c056..db7f0ca 100644 --- a/.github/workflows/functionApp.yml +++ b/.github/workflows/functionApp.yml @@ -5,12 +5,14 @@ on: - main paths: - "**.py" + - "code/function/**" pull_request: branches: - main paths: - "**.py" + - "code/function/**" jobs: function_test: @@ -24,7 +26,7 @@ jobs: uses: ./.github/workflows/_functionAppDeployTemplate.yml name: "Function App Deploy" needs: [function_test] - if: github.event_name == 'push' || github.event_name == 'release' + # if: github.event_name == 'push' || github.event_name == 'release' with: environment: "dev" python_version: "3.10" diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index f1a9d92..e1618dc 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -5,12 +5,14 @@ on: - main paths: - "**.tf" + - "code/infra/**" pull_request: branches: - main paths: - "**.tf" + - "code/infra/**" jobs: terraform_lint: diff --git a/README.md b/README.md index f9f0fe7..a86d42e 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,54 @@ This repository provides a scalable baseline for Azure Functions written in Pyth 1. A compliant infrastructure baseline written in Terraform, 2. A Python code baseline that follows best practices and 3. A safe rollout mechanism of code artifacts. + +## Infrastructure + +The infrastructure as code (IaC) is written in Terraform and uses all the latest and greatest Azure Function features to ensure high security standards and the lowest attack surface possible. The code can be found in the [`/code/infra` folder](/code/infra/) and creates the following resources: + +* App Service Plan, +* Azure Function, +* Azure Storage Account, +* Azure Key Vault, +* Azure Application Insights and +* Azure Log Analytics Workspace. + +The Azure Function is configured in a way to fulfill highest compliance standards. In addition, the end-to-end setup takes care of wiring up all services to ensure a productive experience on day one. For instance, the Azure Function is automatically being connected to Azure Application Insights and the Application Insights service is being connected to the Azure Log Analytics Workspace. + +### Network configuration + +The deployed services ensure a compliant network setup using the following features: + +* Public network access is denied for all services. +* All deployed services rely on Azure Private Endpoints for all network flows including deployments and usage of the services. + +### Authentication & Authorization + +The deployed services ensure a compliant authentication & authorization setup using the following features: + +* No key-based or local/basic authentication flows. +* Azure AD-only authentication. +* All authorization is controlled by Azure RBAC. +* This includes the interaction of the Azure Function with the Azure Storage Account and the Azure Key Vault. + +### Encryption + +The deployed services ensure a compliant encryption setup using the following features: + +* Encryption at rest using 256-bit AES (FIPS 140-2). +* HTTPS traffic only. +* All traffic is encrypted using TLS 1.2. +* Note: Customer-manaed keys are not used at this point in time but can be added easily. +* Note: Cypher suites are set to default and can further be limited. + +## Azure Function Code + +The Azure Function code is written in Python and leverages the new [Web Framework integration](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python?tabs=asgi%2Capplication-level&pivots=python-mode-decorators#web-frameworks) supported by the v2 Python programming model. This allows to rely on proven frameworks such as FastAPI and Flask. The Azure Function application code can be found in the [`/code/function` folder](/code/function/). + +## FastAPI + +This sample uses FastAPI as a baseline which is a scalable, modern, fast and proven web framework for APIs built in Python. More details about FastAPI can be found [here](https://fastapi.tiangolo.com/). + +## Testing + +Testing of the Azure Functon application code. The testing is done using `pytest`. Tests are stored in the [`/tests` folder](/tests/) and should be extended for new functionality that is being implemented over time. The `pytest.ini` is used to reference the Azure Functon project for imports. This file makes sure that the respective python objects from the Azrue Function application code can be imported into the tests and validated accordingly. diff --git a/code/function/api/__init__.py b/code/function/fastapp/__init__.py similarity index 100% rename from code/function/api/__init__.py rename to code/function/fastapp/__init__.py diff --git a/code/function/api/v1/__init__.py b/code/function/fastapp/api/__init__.py similarity index 100% rename from code/function/api/v1/__init__.py rename to code/function/fastapp/api/__init__.py diff --git a/code/function/core/__init__.py b/code/function/fastapp/api/v1/__init__.py similarity index 100% rename from code/function/core/__init__.py rename to code/function/fastapp/api/v1/__init__.py diff --git a/code/function/api/v1/api_v1.py b/code/function/fastapp/api/v1/api_v1.py similarity index 79% rename from code/function/api/v1/api_v1.py rename to code/function/fastapp/api/v1/api_v1.py index 4c9c462..b5e7276 100644 --- a/code/function/api/v1/api_v1.py +++ b/code/function/fastapp/api/v1/api_v1.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from function.api.v1.endpoints import heartbeat, sample +from fastapp.api.v1.endpoints import heartbeat, sample api_v1_router = APIRouter() api_v1_router.include_router(sample.router, prefix="/sample", tags=["sample"]) diff --git a/code/function/api/v1/endpoints/heartbeat.py b/code/function/fastapp/api/v1/endpoints/heartbeat.py similarity index 76% rename from code/function/api/v1/endpoints/heartbeat.py rename to code/function/fastapp/api/v1/endpoints/heartbeat.py index dd03420..1f70a61 100644 --- a/code/function/api/v1/endpoints/heartbeat.py +++ b/code/function/fastapp/api/v1/endpoints/heartbeat.py @@ -1,8 +1,8 @@ from typing import Any from fastapi import APIRouter -from function.models.heartbeat import HearbeatResult -from function.utils import setup_logging +from fastapp.models.heartbeat import HearbeatResult +from fastapp.utils import setup_logging logger = setup_logging(__name__) diff --git a/code/function/api/v1/endpoints/sample.py b/code/function/fastapp/api/v1/endpoints/sample.py similarity index 76% rename from code/function/api/v1/endpoints/sample.py rename to code/function/fastapp/api/v1/endpoints/sample.py index 5f4fc62..47439a8 100644 --- a/code/function/api/v1/endpoints/sample.py +++ b/code/function/fastapp/api/v1/endpoints/sample.py @@ -1,8 +1,8 @@ from typing import Any from fastapi import APIRouter -from function.models.sample import SampleRequest, SampleResponse -from function.utils import setup_logging +from fastapp.models.sample import SampleRequest, SampleResponse +from fastapp.utils import setup_logging logger = setup_logging(__name__) diff --git a/code/function/models/__init__.py b/code/function/fastapp/core/__init__.py similarity index 100% rename from code/function/models/__init__.py rename to code/function/fastapp/core/__init__.py diff --git a/code/function/core/config.py b/code/function/fastapp/core/config.py similarity index 62% rename from code/function/core/config.py rename to code/function/fastapp/core/config.py index f6f33ac..f727f9b 100644 --- a/code/function/core/config.py +++ b/code/function/fastapp/core/config.py @@ -1,6 +1,6 @@ import logging -from pydantic import BaseSettings +from pydantic import BaseSettings, Field class Settings(BaseSettings): @@ -10,6 +10,9 @@ class Settings(BaseSettings): API_V1_STR: str = "/v1" LOGGING_LEVEL: int = logging.INFO DEBUG: bool = False + APPLICATIONINSIGHTS_CONNECTION_STRING: str = Field( + default="", env="APPLICATIONINSIGHTS_CONNECTION_STRING" + ) settings = Settings() diff --git a/code/function/core/messages.py b/code/function/fastapp/core/messages.py similarity index 100% rename from code/function/core/messages.py rename to code/function/fastapp/core/messages.py diff --git a/code/function/fastapp/main.py b/code/function/fastapp/main.py new file mode 100644 index 0000000..488ef81 --- /dev/null +++ b/code/function/fastapp/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapp.api.v1.api_v1 import api_v1_router +from fastapp.core.config import settings + + +def get_app() -> FastAPI: + """Setup the Fast API server. + + RETURNS (FastAPI): The FastAPI object to start the server. + """ + app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.APP_VERSION, + openapi_url="/openapi.json", + debug=settings.DEBUG, + ) + app.include_router(api_v1_router, prefix=settings.API_V1_STR) + return app + + +app = get_app() + + +@app.on_event("startup") +async def startup_event(): + """Gracefully start the application before the server reports readiness.""" + pass + + +@app.on_event("shutdown") +async def shutdown_event(): + """Gracefully close connections before shutdown of the server.""" + pass diff --git a/code/function/fastapp/models/__init__.py b/code/function/fastapp/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/code/function/models/heartbeat.py b/code/function/fastapp/models/heartbeat.py similarity index 100% rename from code/function/models/heartbeat.py rename to code/function/fastapp/models/heartbeat.py diff --git a/code/function/models/sample.py b/code/function/fastapp/models/sample.py similarity index 100% rename from code/function/models/sample.py rename to code/function/fastapp/models/sample.py diff --git a/code/function/utils.py b/code/function/fastapp/utils.py similarity index 93% rename from code/function/utils.py rename to code/function/fastapp/utils.py index a27ac01..d140f1a 100644 --- a/code/function/utils.py +++ b/code/function/fastapp/utils.py @@ -1,11 +1,12 @@ import logging from logging import Logger -from function.core.config import settings +from fastapp.core.config import settings def setup_logging(module) -> Logger: """Setup logging and event handler. + RETURNS (Logger): The logger object to log activities. """ logger = logging.getLogger(module) @@ -17,6 +18,5 @@ def setup_logging(module) -> Logger: logger_stream_handler.setFormatter( logging.Formatter("[%(asctime)s] [%(levelname)s] [%(module)-8.8s] %(message)s") ) - logger.addHandler(logger_stream_handler) return logger diff --git a/code/function/function_app.py b/code/function/function_app.py deleted file mode 100644 index fe89a11..0000000 --- a/code/function/function_app.py +++ /dev/null @@ -1,34 +0,0 @@ -import azure.functions as func -from fastapi import FastAPI -from function.api.v1.api_v1 import api_v1_router -from function.core.config import settings - - -def get_app() -> FastAPI: - app = FastAPI( - title=settings.PROJECT_NAME, - version=settings.APP_VERSION, - openapi_url="/openapi.json", - debug=settings.DEBUG, - ) - app.include_router(api_v1_router, prefix=settings.API_V1_STR) - return app - - -fastapi_app = get_app() - - -@fastapi_app.on_event("startup") -async def startup_event(): - pass - - -@fastapi_app.on_event("shutdown") -async def shutdown_event(): - pass - - -app = func.AsgiFunctionApp( - app=fastapi_app, - http_auth_level=func.AuthLevel.ANONYMOUS, -) diff --git a/code/function/host.json b/code/function/host.json index 06d01bd..4a75541 100644 --- a/code/function/host.json +++ b/code/function/host.json @@ -1,15 +1,27 @@ { "version": "2.0", "logging": { + "fileLoggingMode": "debugOnly", + "logLevel": { + "default": "Information", + "Host": "Information", + "Function": "Information", + "Host.Aggregator": "Information" + }, "applicationInsights": { "samplingSettings": { "isEnabled": true, - "excludedTypes": "Request" + "excludedTypes": "Request;Exception" } } }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.*, 5.0.0)" + }, + "extensions": { + "http": { + "routePrefix": "" + } } } diff --git a/code/function/wrapper/__init__.py b/code/function/wrapper/__init__.py new file mode 100644 index 0000000..b30f269 --- /dev/null +++ b/code/function/wrapper/__init__.py @@ -0,0 +1,6 @@ +import azure.functions as func +from fastapp.main import app + + +async def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: + return await func.AsgiMiddleware(app).handle_async(req, context) diff --git a/code/function/wrapper/function.json b/code/function/wrapper/function.json new file mode 100644 index 0000000..60ae420 --- /dev/null +++ b/code/function/wrapper/function.json @@ -0,0 +1,21 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ], + "route": "{*route}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/code/infra/function.tf b/code/infra/function.tf index 68dc919..05e98d5 100644 --- a/code/infra/function.tf +++ b/code/infra/function.tf @@ -70,6 +70,18 @@ resource "azapi_resource" "function" { }, { name = "WEBSITE_RUN_FROM_PACKAGE" + value = "0" + }, + { + name = "PYTHON_ENABLE_WORKER_EXTENSIONS" + value = "1" + }, + { + name = "ENABLE_ORYX_BUILD" + value = "1" + }, + { + name = "SCM_DO_BUILD_DURING_DEPLOYMENT" value = "1" }, { @@ -82,9 +94,10 @@ resource "azapi_resource" "function" { functionAppScaleLimit = 0 functionsRuntimeScaleMonitoringEnabled = false ftpsState = "Disabled" + healthCheckPath = var.function_health_path http20Enabled = false ipSecurityRestrictionsDefaultAction = "Deny" - linuxFxVersion = "Python|${var.python_version}" + linuxFxVersion = "Python|${var.function_python_version}" localMySqlEnabled = false loadBalancing = "LeastRequests" minTlsVersion = "1.2" diff --git a/code/infra/logging.tf b/code/infra/logging.tf index e2576d6..7aaad10 100644 --- a/code/infra/logging.tf +++ b/code/infra/logging.tf @@ -101,3 +101,23 @@ resource "azurerm_monitor_diagnostic_setting" "diagnostic_setting_log_analytics_ } } } + +resource "azurerm_monitor_private_link_scope" "mpls" { + name = "${local.prefix}-ampls001" + resource_group_name = azurerm_resource_group.logging_rg.name + tags = var.tags +} + +resource "azurerm_monitor_private_link_scoped_service" "mpls_application_insights" { + name = "ampls-${azurerm_application_insights.application_insights.name}" + resource_group_name = azurerm_monitor_private_link_scope.mpls.resource_group_name + scope_name = azurerm_monitor_private_link_scope.mpls.name + linked_resource_id = azurerm_application_insights.application_insights.id +} + +resource "azurerm_monitor_private_link_scoped_service" "mpls_log_analytics_workspace" { + name = "ampls-${azurerm_log_analytics_workspace.log_analytics_workspace.name}" + resource_group_name = azurerm_monitor_private_link_scope.mpls.resource_group_name + scope_name = azurerm_monitor_private_link_scope.mpls.name + linked_resource_id = azurerm_log_analytics_workspace.log_analytics_workspace.id +} diff --git a/code/infra/variables.tf b/code/infra/variables.tf index cd90143..fc69e2a 100644 --- a/code/infra/variables.tf +++ b/code/infra/variables.tf @@ -62,17 +62,27 @@ variable "route_table_id" { } } -variable "python_version" { +variable "function_python_version" { description = "Specifies the python version of the Azure Function." type = string sensitive = false default = "3.10" validation { - condition = contains(["3.9", "3.10"], var.python_version) + condition = contains(["3.9", "3.10"], var.function_python_version) error_message = "Please specify a valid Python version." } } +variable "function_health_path" { + description = "Specifies the health endpoint of the Azure Function." + type = string + sensitive = false + validation { + condition = startswith(var.function_health_path, "/") + error_message = "Please specify a valid path." + } +} + variable "private_dns_zone_id_blob" { description = "Specifies the resource ID of the private DNS zone for Azure Storage blob endpoints. Not required if DNS A-records get created via Azue Policy." type = string diff --git a/code/infra/vars.dev.tfvars b/code/infra/vars.dev.tfvars index 7af9c13..d2e9762 100644 --- a/code/infra/vars.dev.tfvars +++ b/code/infra/vars.dev.tfvars @@ -2,6 +2,8 @@ location = "northeurope" environment = "dev" prefix = "myfunc" tags = {} +function_python_version = "3.10" +function_health_path = "/v1/health/heartbeat" vnet_id = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1d094/resourceGroups/mycrp-prd-function-network-rg/providers/Microsoft.Network/virtualNetworks/mycrp-prd-function-vnet001" nsg_id = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1d094/resourceGroups/mycrp-prd-function-network-rg/providers/Microsoft.Network/networkSecurityGroups/mycrp-prd-function-nsg001" route_table_id = "/subscriptions/8f171ff9-2b5b-4f0f-aed5-7fa360a1d094/resourceGroups/mycrp-prd-function-network-rg/providers/Microsoft.Network/routeTables/mycrp-prd-function-rt001" diff --git a/pytest.ini b/pytest.ini index 1d9b4af..1ff3a7c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ [pytest] -pythonpath = code +pythonpath = code/function diff --git a/tests/test_main.py b/tests/test_main.py index f1b1ffb..d9cac8c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,12 +1,11 @@ import pytest from fastapi.testclient import TestClient -from function.core.config import settings -from function.function_app import fastapi_app +from fastapp.main import app @pytest.fixture(scope="module") def client() -> TestClient: - return TestClient(fastapi_app) + return TestClient(app) @pytest.mark.parametrize("version", ("v1",))