Skip to content

Terraform / Azure infra hardening (PostgreSQL public access, firewall, state backend) #2

@morph-eos

Description

@morph-eos

Repo target: docker2azure4student
Severity: High
Owner: infra
Type: security / hardening

Context

The Locus Azure infrastructure provisioned by docker2azure4student/main.tf exposes the
PostgreSQL Flexible Server to the public internet and broadens the firewall to all Azure
services. The Terraform state backend is also implicit/local, which means a working copy of
sensitive values (admin password, connection strings, storage keys) lives on whoever last
ran terraform apply. This issue tracks the changes needed to bring the database, the
firewall surface and the state management to a baseline that is acceptable for a
small-but-real production deployment.

Findings

1. CRITICAL — PostgreSQL Flexible Server is publicly reachable

File: main.tf (resource azurerm_postgresql_flexible_server.db)

public_network_access_enabled = true

Combined with the firewall rule below, the database accepts connections from the entire
public internet on port 5432. Any leaked credential becomes immediately exploitable, and
brute-force / credential-stuffing attempts hit the DB directly.

Proposal

  • Set public_network_access_enabled = false.
  • Provision a delegated subnet inside the existing VNet and switch the server to
    VNet-integrated (delegated_subnet_id + private_dns_zone_id).
  • Have the VM reach PostgreSQL through the private endpoint over the VNet only.
  • If a private endpoint is not feasible for the student tier, at minimum keep public
    network access disabled and add a single firewall rule scoped to the VM's static
    public IP only (see Terraform / Azure infra hardening (PostgreSQL public access, firewall, state backend) #2).

2. MEDIUM — Firewall rule allow-azure-services is effectively 0.0.0.0/0 for Azure tenants

File: main.tf (resource azurerm_postgresql_flexible_server_firewall_rule.azure_services)

start_ip_address = "0.0.0.0"
end_ip_address   = "0.0.0.0"

In Azure semantics, a 0.0.0.00.0.0.0 rule on a Flexible Server allows any Azure
resource in any tenant
to connect. Anyone with an Azure subscription can target the
server.

Proposal

  • Remove azure_services entirely.
  • Keep only vm_public_ip (already conditional on vm_public_ip_static) so that exactly
    one IP can reach the database.
  • Document the requirement of vm_public_ip_static = true in variables.tf /
    terraform.tfvars.example.

3. MEDIUM — Terraform state has no remote backend

File: versions.tf / providers.tf

There is no backend "azurerm" { … } block, so state defaults to local. State contains
the DB admin password (administrator_password), storage account keys and any other
sensitive output. Local state means:

  • whoever ran apply has the secrets on disk in cleartext;
  • there is no locking, two concurrent applies corrupt the infra;
  • there is no audit trail.

Proposal

  • Create a dedicated storage account + container (e.g. tfstate-locus) outside of the
    managed resource group, with:
    • min_tls_version = "TLS1_2",
    • allow_nested_items_to_be_public = false,
    • infrastructure_encryption_enabled = true,
    • RBAC restricted to the apply identity only (no shared keys),
    • soft delete + versioning on the container.
  • Configure
    terraform {
      backend "azurerm" {
        resource_group_name  = "..."
        storage_account_name = "..."
        container_name       = "tfstate"
        key                  = "locus.tfstate"
        use_azuread_auth     = true
      }
    }
  • Migrate existing state with terraform init -migrate-state.
  • Stop committing / sharing terraform.tfstate* (already gitignored — verify).

4. LOW — Admin password lives in tfvars

The DB password is read from var.db_admin_password, typically supplied via
terraform.tfvars. With remote state + RBAC this is acceptable for the student tier, but
the medium-term path is Azure AD authentication on the Flexible Server
(azurerm_postgresql_flexible_server_active_directory_administrator) so that the API VM
authenticates with a managed identity instead of a static password.

Acceptance criteria

  • azurerm_postgresql_flexible_server.db.public_network_access_enabled = false in the
    committed code.
  • azurerm_postgresql_flexible_server_firewall_rule.azure_services is removed; only
    the VM-scoped firewall rule remains (or no firewall rule at all if private endpoint
    is in place).
  • Remote azurerm backend configured, state migrated, no local terraform.tfstate
    tracked or shared.
  • README.md updated with the new connectivity model and migration steps.
  • plan / apply produces the expected diff in a staging subscription before being
    applied to the live deployment.

Rollback plan

  • The PostgreSQL changes (public_network_access_enabled toggle and firewall rule
    removal) can be reverted by re-applying the previous Terraform revision; existing
    databases and data are unaffected.
  • Backend migration is one-way in practice: keep a copy of the local state before
    init -migrate-state so it can be re-imported into a different backend if Azure
    storage is unavailable.

Related

  • Round 1 hardening (api/client/.github) is a separate effort; this issue is the infra
    half.
  • See OIDC / GitHub App issue for the authentication side of the deploy pipeline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions