> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cognisafe.uk/llms.txt
> Use this file to discover all available pages before exploring further.

# Private Endpoint Architecture

> Zero-egress deployment with Azure Private Endpoints and VNet isolation

This blueprint describes a fully private Cognisafe deployment where no data-plane traffic traverses the public internet. Every Azure service Cognisafe depends on — Azure OpenAI, PostgreSQL Flexible Server, Key Vault, Container Registry, and Redis Cache — is accessed via Azure Private Endpoints with private DNS resolution. The Cognisafe proxy, API, and workers run inside AKS pods in a dedicated VNet. No public IPs are assigned to any data-plane component.

This architecture satisfies:

* **Data residency** requirements (all traffic stays within Azure in your chosen region)
* **PCI DSS / HIPAA / ISO 27001** network isolation controls
* **Zero-trust** posture for AI workloads (no implicit outbound internet access)

## Architecture

```
─────────────────────── Azure VNet: 10.0.0.0/16 ─────────────────────────

 proxy-subnet            api-subnet             data-subnet
 10.0.1.0/24             10.0.2.0/24            10.0.3.0/24
 ┌──────────────┐        ┌─────────────────┐    ┌──────────────────────┐
 │ AKS node pool │       │ AKS node pool   │    │ Private Endpoints    │
 │ (proxy pods) │        │ (api, web,      │    │                      │
 │              │        │  worker pods)   │    │ Azure OpenAI  .10    │
 │  ──────────  │  ───▶  │                 │    │ PostgreSQL    .20    │
 │  Cognisafe   │        │  Cognisafe API  │    │ Key Vault     .30    │
 │  Proxy :8080 │        │  :8000          │    │ ACR           .40    │
 └──────────────┘        │  Web :3000      │    │ Redis         .50    │
        │                │  Workers        │    └──────────────────────┘
        │ (LLM calls)    └─────────────────┘           │
        │                       │                       │
        └───────────────────────┴───────────────────────┘
                         Private DNS resolution
                    (privatelink.* zones linked to VNet)

 apim-subnet             nat-subnet
 10.0.4.0/24             10.0.5.0/24
 ┌──────────────┐        ┌─────────────────┐
 │ APIM (opt.)  │        │ NAT Gateway     │
 │              │        │ (for PyPI /     │
 └──────────────┘        │  Stripe egress) │
                         └─────────────────┘

No public IPs on proxy, api, web, workers, or any data service.
APIM is the sole public-facing entry point (optional).
```

## Terraform: VNet and subnets

```hcl theme={null}
# main.tf

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.90"
    }
  }
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
}

variable "subscription_id" {}
variable "resource_group"  { default = "cognisafe-prod" }
variable "location"        { default = "eastus" }

resource "azurerm_resource_group" "cognisafe" {
  name     = var.resource_group
  location = var.location
}

resource "azurerm_virtual_network" "main" {
  name                = "cognisafe-vnet"
  resource_group_name = azurerm_resource_group.cognisafe.name
  location            = azurerm_resource_group.cognisafe.location
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "proxy" {
  name                 = "proxy-subnet"
  resource_group_name  = azurerm_resource_group.cognisafe.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.1.0/24"]
}

resource "azurerm_subnet" "api" {
  name                 = "api-subnet"
  resource_group_name  = azurerm_resource_group.cognisafe.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.2.0/24"]
}

resource "azurerm_subnet" "data" {
  name                 = "data-subnet"
  resource_group_name  = azurerm_resource_group.cognisafe.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.3.0/24"]
  # Private endpoints require this policy to be disabled on the subnet
  private_endpoint_network_policies_enabled = false
}

resource "azurerm_subnet" "nat" {
  name                 = "nat-subnet"
  resource_group_name  = azurerm_resource_group.cognisafe.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.5.0/24"]
}
```

## Terraform: Private endpoint for Azure OpenAI

```hcl theme={null}
# private-endpoint-aoai.tf

variable "aoai_resource_id" {
  description = "Resource ID of the Azure OpenAI Cognitive Services account"
}

resource "azurerm_private_endpoint" "azure_openai" {
  name                = "pe-azure-openai"
  resource_group_name = azurerm_resource_group.cognisafe.name
  location            = azurerm_resource_group.cognisafe.location
  subnet_id           = azurerm_subnet.data.id

  private_service_connection {
    name                           = "psc-azure-openai"
    private_connection_resource_id = var.aoai_resource_id
    subresource_names              = ["account"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name                 = "pdnszg-azure-openai"
    private_dns_zone_ids = [azurerm_private_dns_zone.azure_openai.id]
  }

  tags = {
    environment = "production"
    component   = "cognisafe"
  }
}

resource "azurerm_private_dns_zone" "azure_openai" {
  name                = "privatelink.openai.azure.com"
  resource_group_name = azurerm_resource_group.cognisafe.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "azure_openai" {
  name                  = "vnetlink-azure-openai"
  resource_group_name   = azurerm_resource_group.cognisafe.name
  private_dns_zone_name = azurerm_private_dns_zone.azure_openai.name
  virtual_network_id    = azurerm_virtual_network.main.id
  registration_enabled  = false
}

# The private endpoint NIC gets an IP automatically; the DNS A record is created
# by the private_dns_zone_group above. Verify with:
#   az network private-endpoint dns-zone-group list --endpoint-name pe-azure-openai \
#     --resource-group cognisafe-prod
```

## Terraform: Remaining private endpoints

Apply the same pattern for each dependent service. Key differences per service:

```hcl theme={null}
# private-endpoint-postgres.tf

variable "postgres_server_id" {}

resource "azurerm_private_endpoint" "postgres" {
  name                = "pe-postgres"
  resource_group_name = azurerm_resource_group.cognisafe.name
  location            = azurerm_resource_group.cognisafe.location
  subnet_id           = azurerm_subnet.data.id

  private_service_connection {
    name                           = "psc-postgres"
    private_connection_resource_id = var.postgres_server_id
    subresource_names              = ["postgresqlServer"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name                 = "pdnszg-postgres"
    private_dns_zone_ids = [azurerm_private_dns_zone.postgres.id]
  }
}

resource "azurerm_private_dns_zone" "postgres" {
  name                = "privatelink.postgres.database.azure.com"
  resource_group_name = azurerm_resource_group.cognisafe.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
  name                  = "vnetlink-postgres"
  resource_group_name   = azurerm_resource_group.cognisafe.name
  private_dns_zone_name = azurerm_private_dns_zone.postgres.name
  virtual_network_id    = azurerm_virtual_network.main.id
  registration_enabled  = false
}

# ─── Key Vault ───────────────────────────────────────────────────────────────
# subresource_names = ["vault"]
# private_dns_zone  = "privatelink.vaultcore.azure.net"

# ─── Azure Container Registry ─────────────────────────────────────────────────
# subresource_names = ["registry"]
# private_dns_zone  = "privatelink.azurecr.io"

# ─── Azure Cache for Redis ────────────────────────────────────────────────────
# subresource_names = ["redisCache"]
# private_dns_zone  = "privatelink.redis.cache.windows.net"
```

## Private DNS zones reference

| Service                    | Private DNS zone                          | Subresource name   |
| -------------------------- | ----------------------------------------- | ------------------ |
| Azure OpenAI               | `privatelink.openai.azure.com`            | `account`          |
| PostgreSQL Flexible Server | `privatelink.postgres.database.azure.com` | `postgresqlServer` |
| Azure Key Vault            | `privatelink.vaultcore.azure.net`         | `vault`            |
| Azure Container Registry   | `privatelink.azurecr.io`                  | `registry`         |
| Azure Cache for Redis      | `privatelink.redis.cache.windows.net`     | `redisCache`       |

Every DNS zone must be linked to the VNet (`registration_enabled = false`). The private endpoint's NIC IP is registered automatically by the `private_dns_zone_group` block — no manual A records needed.

## Network Security Group rules

Define NSGs for each subnet. The rules below use the principle of least-privilege: only explicitly required flows are permitted.

### proxy-subnet NSG

| Priority | Name                | Direction | Source                      | Destination  | Port | Protocol | Action |
| -------- | ------------------- | --------- | --------------------------- | ------------ | ---- | -------- | ------ |
| 100      | allow-inbound-apim  | Inbound   | `ApiManagement` service tag | proxy-subnet | 8080 | TCP      | Allow  |
| 110      | allow-inbound-https | Inbound   | VirtualNetwork              | proxy-subnet | 443  | TCP      | Allow  |
| 200      | allow-outbound-aoai | Outbound  | proxy-subnet                | data-subnet  | 443  | TCP      | Allow  |
| 210      | allow-outbound-api  | Outbound  | proxy-subnet                | api-subnet   | 8000 | TCP      | Allow  |
| 900      | deny-all-inbound    | Inbound   | Any                         | Any          | Any  | Any      | Deny   |
| 910      | deny-all-outbound   | Outbound  | Any                         | Any          | Any  | Any      | Deny   |

### api-subnet NSG

| Priority | Name               | Direction | Source       | Destination | Port      | Protocol | Action |
| -------- | ------------------ | --------- | ------------ | ----------- | --------- | -------- | ------ |
| 100      | allow-from-proxy   | Inbound   | proxy-subnet | api-subnet  | 8000      | TCP      | Allow  |
| 110      | allow-web-internal | Inbound   | api-subnet   | api-subnet  | 8000,3000 | TCP      | Allow  |
| 200      | allow-postgres     | Outbound  | api-subnet   | data-subnet | 5432      | TCP      | Allow  |
| 210      | allow-redis        | Outbound  | api-subnet   | data-subnet | 6380      | TCP      | Allow  |
| 220      | allow-keyvault     | Outbound  | api-subnet   | data-subnet | 443       | TCP      | Allow  |
| 230      | allow-nat-egress   | Outbound  | api-subnet   | nat-subnet  | Any       | TCP      | Allow  |
| 900      | deny-all-inbound   | Inbound   | Any          | Any         | Any       | Any      | Deny   |
| 910      | deny-all-outbound  | Outbound  | Any          | Any         | Any       | Any      | Deny   |

```hcl theme={null}
# nsg.tf (proxy-subnet example)

resource "azurerm_network_security_group" "proxy" {
  name                = "nsg-proxy-subnet"
  resource_group_name = azurerm_resource_group.cognisafe.name
  location            = azurerm_resource_group.cognisafe.location

  security_rule {
    name                       = "allow-inbound-apim"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8080"
    source_address_prefix      = "ApiManagement"
    destination_address_prefix = "10.0.1.0/24"
  }

  security_rule {
    name                       = "allow-outbound-aoai"
    priority                   = 200
    direction                  = "Outbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = "10.0.1.0/24"
    destination_address_prefix = "10.0.3.0/24"
  }

  security_rule {
    name                       = "allow-outbound-api"
    priority                   = 210
    direction                  = "Outbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8000"
    source_address_prefix      = "10.0.1.0/24"
    destination_address_prefix = "10.0.2.0/24"
  }

  security_rule {
    name                       = "deny-all-outbound"
    priority                   = 910
    direction                  = "Outbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_subnet_network_security_group_association" "proxy" {
  subnet_id                 = azurerm_subnet.proxy.id
  network_security_group_id = azurerm_network_security_group.proxy.id
}
```

## NAT Gateway for controlled egress

The safety worker needs outbound internet access for PyPI (pip install during container build is fine; runtime pip calls are not). If your workers need to reach external services at runtime, route them through an Azure NAT Gateway with a static public IP — this gives you a known egress IP for allowlisting.

```hcl theme={null}
# nat.tf

resource "azurerm_public_ip" "nat" {
  name                = "pip-nat-gateway"
  resource_group_name = azurerm_resource_group.cognisafe.name
  location            = azurerm_resource_group.cognisafe.location
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_nat_gateway" "main" {
  name                    = "nat-gateway"
  resource_group_name     = azurerm_resource_group.cognisafe.name
  location                = azurerm_resource_group.cognisafe.location
  sku_name                = "Standard"
  idle_timeout_in_minutes = 10
}

resource "azurerm_nat_gateway_public_ip_association" "main" {
  nat_gateway_id       = azurerm_nat_gateway.main.id
  public_ip_address_id = azurerm_public_ip.nat.id
}

resource "azurerm_subnet_nat_gateway_association" "api" {
  subnet_id      = azurerm_subnet.api.id
  nat_gateway_id = azurerm_nat_gateway.main.id
}
```

## Required egress domains

If you deploy an egress firewall (Azure Firewall or third-party NVA) in front of the NAT Gateway, allowlist the following FQDNs. All traffic is HTTPS/443 unless noted.

| Domain                               | Used by                | Purpose                                                     |
| ------------------------------------ | ---------------------- | ----------------------------------------------------------- |
| `api.stripe.com`                     | api service            | Stripe billing API                                          |
| `hooks.stripe.com`                   | api service            | Stripe webhook delivery                                     |
| `api.clerk.com`                      | web service            | Clerk authentication                                        |
| `frontend-api.clerk.com`             | web service            | Clerk JS SDK                                                |
| `api.resend.com`                     | api service            | Transactional email                                         |
| `pypi.org`, `files.pythonhosted.org` | CI/CD image build only | pip packages (not runtime)                                  |
| `api.openai.com`                     | safety-worker          | safety scorer calls (if not using Azure OpenAI for scoring) |
| `login.microsoftonline.com`          | All services           | Azure AD / workload identity token endpoint                 |
| `management.azure.com`               | Terraform / az CLI     | ARM API                                                     |

<Note>
  `pypi.org` is needed only during container image builds in CI/CD, not at runtime. If your workers do not install packages at runtime, omit it from runtime egress rules. Pin all Python dependencies in `requirements.txt` and bake them into the image.
</Note>

<Warning>
  If `OPENAI_API_KEY` is set and `SCORER_MODEL` uses a non-Azure OpenAI endpoint, the safety worker will call `api.openai.com` at runtime. To keep scoring fully private, deploy a separate Azure OpenAI resource for scoring and set `SCORER_MODEL` to a deployment on that resource, pointing the worker at the private endpoint.
</Warning>

## Azure Policy: enforce private endpoint usage

Assign built-in policies to prevent anyone from accidentally creating public-facing Azure OpenAI or PostgreSQL resources in this subscription.

```hcl theme={null}
# policy.tf

# Deny Azure OpenAI resources without private endpoints
resource "azurerm_subscription_policy_assignment" "aoai_private_endpoint" {
  name                 = "require-aoai-private-endpoint"
  policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/438c38d2-3772-465a-a9cc-7a6666a275ce"
  subscription_id      = "/subscriptions/${var.subscription_id}"
  display_name         = "Azure OpenAI should use private endpoints"

  parameters = jsonencode({
    effect = { value = "Deny" }
  })
}

# Deny PostgreSQL Flexible Server with public network access enabled
resource "azurerm_subscription_policy_assignment" "postgres_no_public" {
  name                 = "deny-postgres-public-access"
  policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/5e1de0e3-42cb-4ebc-a86d-61d0c619ca48"
  subscription_id      = "/subscriptions/${var.subscription_id}"
  display_name         = "PostgreSQL Flexible Server should disable public network access"

  parameters = jsonencode({
    effect = { value = "Deny" }
  })
}

# Audit Key Vault firewall — should only allow VNet access
resource "azurerm_subscription_policy_assignment" "kv_firewall" {
  name                 = "audit-kv-firewall"
  policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/55615ac9-af46-4a59-874e-391cc3dfb490"
  subscription_id      = "/subscriptions/${var.subscription_id}"
  display_name         = "Key Vault should have firewall enabled"

  parameters = jsonencode({
    effect = { value = "Audit" }
  })
}
```

## Defender for Cloud integration

Enable Microsoft Defender for Cloud on the subscription to receive:

* **Defender for Containers** — image vulnerability scanning, AKS runtime threat detection
* **Defender for Azure OpenAI** — anomalous prompt detection, jailbreak alerts (complements Cognisafe scoring)
* **Defender for Databases** — SQL injection detection on PostgreSQL, unusual access patterns

```bash theme={null}
# Enable Defender plans via Azure CLI
az security pricing create --name ContainerRegistry --tier Standard
az security pricing create --name Containers --tier Standard
az security pricing create --name CosmosDbs --tier Standard  # if using Cosmos
az security pricing create --name SqlserverVirtualMachines --tier Standard

# Enable Defender for Azure OpenAI (preview — requires registration)
az feature register \
  --namespace Microsoft.Security \
  --name AzureOpenAIDefender

az provider register --namespace Microsoft.Security
```

### Compliance dashboard

After enabling Defender, navigate to **Microsoft Defender for Cloud** → **Regulatory Compliance** in the Azure Portal. Add the relevant standards:

* **NIST SP 800-53 Rev. 5** — network isolation, access control, audit logging
* **ISO 27001:2013** — information security management
* **PCI DSS v4** — if processing payment-adjacent data

Defender for Cloud will surface non-compliant resources (e.g., a subnet without an NSG, a Key Vault without purge protection) with direct remediation links.

## Verify the private endpoint setup

```bash theme={null}
# From inside an AKS pod, confirm Azure OpenAI resolves to a private IP
kubectl run dns-test --image=busybox --restart=Never -n cognisafe-system -- \
  nslookup my-aoai-resource.openai.azure.com

# Expected output:
# Server:    168.63.129.16
# Address 1: 168.63.129.16
# Name:      my-aoai-resource.openai.azure.com
# Address 1: 10.0.3.10   <-- private IP in data-subnet

# Confirm no public internet reachability from a worker pod
kubectl exec -n cognisafe-system \
  $(kubectl get pod -n cognisafe-system -l app=safety-worker -o name | head -1) \
  -- curl -s --max-time 5 https://example.com || echo "BLOCKED — expected in zero-egress config"
```

<Tip>
  Run these DNS and connectivity checks as part of your deployment pipeline smoke tests. A private endpoint misconfiguration (missing DNS zone link, wrong subresource name) will cause silent failures — the service resolves to the public IP and either times out (if outbound is blocked) or succeeds insecurely (if outbound is open). Catching this in CI prevents production incidents.
</Tip>
