Código da Aula: Github

Crie a branch aula-15-security a partir da 02-ingress e entre nela

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

git checkout 02-ingress
git checkout -b aula-15-security

⚙️ Parte 0 — Verificação do Ambiente

📁 Verificar Docker

🐧 Linux / Mac:

sudo systemctl start docker
docker info

⊞ Windows (PowerShell):

docker-machine start default
docker-machine regenerate-certs default -f
docker-machine env default | Invoke-Expression
docker info

No Windows com Docker Toolbox, o Docker roda dentro de uma VM VirtualBox. Esses 4 comandos garantem que a VM está rodando, com certificados válidos e o terminal apontando para ela.

📁 Verificar Terraform

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

terraform version

Se precisar atualizar ou instalar: https://developer.hashicorp.com/terraform/install

Para adicionar o Terraform ao PATH no Windows (PowerShell):

[System.Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";C:\terraform", "User")

📁 Verificar e instalar doctl

O doctl é a CLI oficial da DigitalOcean. Usamos ele para autenticar e salvar o kubeconfig do cluster automaticamente durante o terraform apply.

🐧 macOS:

brew install doctl
doctl version

🐧 Linux:

wget https://github.com/digitalocean/doctl/releases/download/v1.159.0/doctl-1.159.0-linux-amd64.tar.gz
tar xf doctl-1.159.0-linux-amd64.tar.gz
sudo mv doctl /usr/local/bin
doctl version

⊞ Windows (PowerShell):

Invoke-WebRequest https://github.com/digitalocean/doctl/releases/download/v1.159.0/doctl-1.159.0-windows-amd64.zip -OutFile doctl.zip
Expand-Archive -Path doctl.zip -DestinationPath C:\doctl
[System.Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";C:\doctl", "User")
doctl version

📁 Autenticar o doctl com a DigitalOcean

  1. Acesse o Painel DigitalOcean → API → Tokens
  2. Clique em Generate New Token
  3. Dê um nome (ex: aula-15-k8s-security), marque read e write
  4. Copie o token gerado — ele só aparece uma vez

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

doctl auth init --access-token SEU_TOKEN_AQUI
doctl account get

📁 Verificar versões disponíveis do Kubernetes

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

doctl kubernetes options versions

Copie o Slug da versão mais recente (ex: 1.36.0-do.0) — você vai usar esse valor no terraform.tfvars.

🐳 Parte 1 — Imagens Docker

Nesta aula vamos utilizar as imagens que já estão publicadas no Docker Hub a partir das aulas anteriores:

📁 Verificar e baixar as imagens do Docker Hub

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

docker pull toticavalcanti/fiber-auth-api:v1.0
docker pull toticavalcanti/auth-ui:v1.1

🐧 Linux / Mac:

docker images | grep toticavalcanti

⊞ Windows (PowerShell):

docker images | Select-String "toticavalcanti"

⊞ Windows (CMD):

docker images | findstr toticavalcanti

📁 Recap — Dockerfiles das aulas anteriores

📁 Ativar BuildKit

🐧 Linux / Mac:

export DOCKER_BUILDKIT=1

⊞ Windows (PowerShell):

$env:DOCKER_BUILDKIT=1

📁 Dockerfile do Backend

codigo-fluente-fiber-tutorial/devops/Dockerfile-backend

# Etapa 1: Build
FROM golang:1.18 as builder
WORKDIR /app
COPY backend/fiber-project/go.mod backend/fiber-project/go.sum ./
RUN go mod download
COPY backend/fiber-project/ ./
RUN go build -o main .

# Etapa 2: Imagem final leve (distroless)
FROM gcr.io/distroless/base-debian10
WORKDIR /app
COPY --from=builder /app/main .
CMD ["./main"]

📁 Dockerfile do Frontend

codigo-fluente-fiber-tutorial/devops/Dockerfile-frontend

# Etapa 1: Build da aplicação React
FROM node:16-alpine as build
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY frontend/react-auth/package*.json ./
RUN npm ci --legacy-peer-deps --verbose
COPY frontend/react-auth/ ./
ENV REACT_APP_API_URL=/api
RUN npm run build

# Etapa 2: Servir com Nginx
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY frontend/react-auth/nginx.conf /etc/nginx/conf.d/default.conf
COPY frontend/react-auth/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]

📁 Build e Push das imagens

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

docker build -f devops/Dockerfile-backend -t toticavalcanti/fiber-auth-api:v1.0 .
docker build -f devops/Dockerfile-frontend -t toticavalcanti/auth-ui:v1.1 .

docker login docker push toticavalcanti/fiber-auth-api:v1.0 docker push toticavalcanti/auth-ui:v1.1

☁️ Parte 2 — Estrutura do Projeto Terraform

O Terraform faz tudo de uma vez: cria o cluster na DigitalOcean e deploya a aplicação com todas as configurações de segurança. Um único terraform apply — o mesmo padrão das aulas anteriores.

Nesta aula a estrutura fica assim:

user-auth-system/
└── devops/
    ├── main.tf                  ← MODIFICADO: versão, nome e região do cluster viram variáveis
    ├── variables.tf             ← MODIFICADO: adiciona cluster_name, cluster_version e demais variáveis
    ├── locals.tf                ← NOVO: padrões de segurança centralizados
    ├── rbac.tf                  ← NOVO: ServiceAccounts e Roles com menor privilégio
    ├── deployment.tf            ← MODIFICADO: SecurityContext no backend e frontend; MySQL sem SecurityContext
    ├── network_policy.tf        ← NOVO: Zero Trust networking entre pods
    ├── helm_ingress.tf          ← MODIFICADO: namespace dedicado + desabilita admission webhook
    ├── ingress.tf               ← sem mudanças
    ├── outputs.tf               ← sem mudanças
    ├── terraform.tfvars.example ← MODIFICADO: reflete todas as variáveis
    └── .gitignore               ← sem mudanças

📁 Arquivos modificados e criados nesta aula

devops/main.tf ← MODIFICADO

O que mudou em relação à aula 14 e por quê:

  • required_version — adicionamos >= 1.0 para garantir compatibilidade mínima do Terraform.
  • name = var.cluster_name — o nome do cluster deixa de ser hardcoded e passa a vir do terraform.tfvars.
  • region = var.do_region — a região deixa de ser hardcoded e passa a vir do terraform.tfvars.
  • version = var.cluster_version — a versão do Kubernetes deixa de ser hardcoded. Use o slug obtido via doctl kubernetes options versions.
  • sleep 120 — aumentado de 45 para 120 segundos para garantir que o DNS do cluster esteja propagado antes de criar os recursos Kubernetes.

user-auth-system/devops/main.tf

terraform {
  required_version = ">= 1.0"
  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "~> 2.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "2.33.0"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "2.17.0"
    }
  }
}

provider "digitalocean" {
  token = var.do_token
}

# Criação do Cluster Kubernetes
resource "digitalocean_kubernetes_cluster" "meu_cluster" {
  name    = var.cluster_name
  region  = var.do_region
  version = var.cluster_version

  node_pool {
    name       = "default-pool"
    size       = "s-2vcpu-4gb"
    node_count = 2
  }

  provisioner "local-exec" {
    command = <<-EOT
      echo "Iniciando kubeconfig..."
      doctl kubernetes cluster kubeconfig save ${var.cluster_name}
      echo "Kubeconfig salvo. Aguardando cluster ficar pronto..."
      sleep 120
      kubectl wait --for=condition=Ready nodes --all --timeout=300s
      echo "Cluster está pronto!"
    EOT
  }
}

provider "kubernetes" {
  host  = digitalocean_kubernetes_cluster.meu_cluster.endpoint
  token = digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].token
  cluster_ca_certificate = base64decode(
    digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].cluster_ca_certificate
  )
}

provider "helm" {
  kubernetes {
    host  = digitalocean_kubernetes_cluster.meu_cluster.endpoint
    token = digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].token
    cluster_ca_certificate = base64decode(
      digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].cluster_ca_certificate
    )
  }
}

devops/variables.tf ← MODIFICADO

O que mudou e por quê:

  • do_region, cluster_name, cluster_version — região e versão do cluster deixam de ser hardcoded.
  • namespace, environment — permitem identificar o ambiente nos labels dos recursos.
  • backend_image, frontend_image, mysql_image — imagens Docker via variáveis em vez de hardcoded.
  • backend_replica_count — número de réplicas do backend via variável.
  • mysql_database — nome do banco via variável.

user-auth-system/devops/variables.tf

variable "do_token" {
  type        = string
  sensitive   = true
  description = "Token de acesso para a API da DigitalOcean"
}

variable "do_region" {
  description = "Região onde o cluster será criado"
  type        = string
  default     = "nyc1"
}

variable "cluster_name" {
  description = "Nome do cluster Kubernetes"
  type        = string
  default     = "meu-cluster"
}

variable "cluster_version" {
  description = "Versão do Kubernetes (use: doctl kubernetes options versions)"
  type        = string
  default     = "1.36.0-do.0"
}

variable "namespace" {
  description = "Namespace Kubernetes para os recursos"
  type        = string
  default     = "default"
}

variable "environment" {
  description = "Ambiente de execução"
  type        = string
  default     = "production"
}

variable "backend_image" {
  description = "Imagem Docker do backend Go/Fiber"
  type        = string
  default     = "toticavalcanti/fiber-auth-api:v1.0"
}

variable "frontend_image" {
  description = "Imagem Docker do frontend React/Nginx"
  type        = string
  default     = "toticavalcanti/auth-ui:v1.1"
}

variable "mysql_image" {
  description = "Imagem Docker do MySQL"
  type        = string
  default     = "mysql:8.0"
}

variable "backend_replica_count" {
  description = "Número de réplicas do backend"
  type        = number
  default     = 1
}

variable "mysql_database" {
  description = "Nome do banco de dados MySQL"
  type        = string
  default     = "auth_db"
}

variable "mysql_root_password" {
  description = "Senha do root para o MySQL"
  type        = string
  sensitive   = true
}

variable "gmail_username" {
  type        = string
  description = "Endereço do Gmail para enviar emails"
}

variable "gmail_password" {
  type        = string
  sensitive   = true
  description = "Senha do Gmail ou Senha de App"
}

variable "react_app_api_url" {
  description = "URL para a API do auth-api"
  type        = string
  default     = "/api/"
}

devops/locals.tf ← NOVO

Centraliza os padrões de segurança reutilizados em todos os recursos. Em vez de repetir as mesmas configurações de SecurityContext em cada container, definimos uma vez aqui e referenciamos em todo lugar.

  • common_labels — labels aplicados em todos os recursos para rastreabilidade.
  • pod_security_context e container_security_context — definem que nenhum container roda como root, o filesystem é somente leitura e todas as Linux capabilities são removidas. Aplicados no backend e frontend. O MySQL não recebe SecurityContext pois a imagem oficial requer privilégios de root para inicializar — o isolamento do MySQL é garantido pela NetworkPolicy.
  • resource limits — CPU e memória definidos para backend, frontend e MySQL.

user-auth-system/devops/locals.tf

locals {
  common_labels = {
    project    = "fiber-auth"
    managed-by = "terraform"
  }

  pod_security_context = {
    run_as_non_root = true
    run_as_user     = 65534
    run_as_group    = 65534
    fs_group        = 65534
  }

  container_security_context = {
    allow_privilege_escalation = false
    read_only_root_filesystem  = true
    run_as_non_root            = true
    run_as_user                = 65534
    drop_capabilities          = ["ALL"]
  }

  backend_resources = {
    requests = { cpu = "100m", memory = "128Mi" }
    limits   = { cpu = "500m", memory = "512Mi" }
  }

  frontend_resources = {
    requests = { cpu = "50m",  memory = "64Mi"  }
    limits   = { cpu = "200m", memory = "256Mi" }
  }

  mysql_resources = {
    requests = { cpu = "250m", memory = "512Mi" }
    limits   = { cpu = "500m", memory = "1Gi"   }
  }
}

devops/rbac.tf ← NOVO

Implementa o princípio do menor privilégio: cada serviço tem sua própria identidade no cluster e só pode fazer o que é estritamente necessário.

  • ServiceAccounts separadas — uma para backend, uma para frontend, uma para MySQL.
  • automount_service_account_token = false — impede que o token seja montado automaticamente nos pods.
  • Role readonly_debugger — o backend pode ver logs para debug, mas não pode deletar pods ou criar secrets.
  • Frontend e MySQL sem nenhuma Role — acesso zero à API do Kubernetes.

user-auth-system/devops/rbac.tf

resource "kubernetes_service_account" "backend" {
  metadata {
    name   = "backend-service-account"
    labels = local.common_labels
  }
  automount_service_account_token = false
}

resource "kubernetes_service_account" "frontend" {
  metadata {
    name   = "frontend-service-account"
    labels = local.common_labels
  }
  automount_service_account_token = false
}

resource "kubernetes_service_account" "mysql" {
  metadata {
    name   = "mysql-service-account"
    labels = local.common_labels
  }
  automount_service_account_token = false
}

resource "kubernetes_role" "readonly_debugger" {
  metadata {
    name   = "readonly-debugger"
    labels = local.common_labels
  }

  rule {
    api_groups = [""]
    resources  = ["pods", "pods/log"]
    verbs      = ["get", "list"]
  }
}

resource "kubernetes_role_binding" "backend_readonly" {
  metadata {
    name   = "backend-readonly-binding"
    labels = local.common_labels
  }

  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "Role"
    name      = kubernetes_role.readonly_debugger.metadata[0].name
  }

  subject {
    kind = "ServiceAccount"
    name = kubernetes_service_account.backend.metadata[0].name
  }
}

devops/deployment.tf ← MODIFICADO

O que mudou em relação à aula 14 e por quê:

  • service_account_name — cada deployment usa a ServiceAccount específica criada no rbac.tf.
  • automount_service_account_token = false — o token não é montado automaticamente.
  • SecurityContext no backend e frontendrun_as_non_root, read_only_root_filesystem, capabilities drop ALL e seccomp_profile RuntimeDefault.
  • MySQL sem SecurityContext — a imagem oficial do MySQL requer privilégios de root para inicializar o banco. O isolamento é garantido via NetworkPolicy.
  • Volumes tmpfs/tmp, /var/cache/nginx e /var/run montados em RAM porque o filesystem é somente leitura.
  • Resource limits via locals — CPU e memória via locals.tf.
  • Imagens, banco e réplicas via variáveisvar.mysql_image, var.backend_image, var.frontend_image, var.mysql_database, var.backend_replica_count.
  • namespace = "ingress-nginx" no data source — o Ingress Controller agora roda em namespace dedicado.

user-auth-system/devops/deployment.tf

# Data Source para o IP do Ingress Controller
data "kubernetes_service" "ingress_nginx" {
  metadata {
    name      = "ingress-nginx-controller"
    namespace = "ingress-nginx"
  }
  depends_on = [helm_release.ingress_nginx]
}

##################################
# SECRETS
##################################
resource "kubernetes_secret" "gmail_credentials" {
  metadata {
    name = "gmail-credentials"
  }
  data = {
    username = var.gmail_username
    password = var.gmail_password
  }
}

resource "kubernetes_secret" "mysql_secret" {
  metadata {
    name = "mysql-secret"
  }
  data = {
    mysql-root-password = base64encode(var.mysql_root_password)
  }
}

##################################
# VOLUMES
##################################
resource "kubernetes_persistent_volume" "mysql_pv" {
  metadata {
    name = "mysql-pv"
    labels = {
      type = "local"
      app  = "mysql"
    }
  }
  spec {
    capacity = {
      storage = "5Gi"
    }
    access_modes                     = ["ReadWriteOnce"]
    storage_class_name               = "manual"
    persistent_volume_reclaim_policy = "Retain"

    persistent_volume_source {
      host_path {
        path = "/mnt/data"
        type = "DirectoryOrCreate"
      }
    }
  }
}

resource "kubernetes_persistent_volume_claim" "mysql_pvc" {
  metadata {
    name = "mysql-pvc"
    labels = {
      app = "mysql"
    }
  }
  spec {
    access_modes = ["ReadWriteOnce"]
    resources {
      requests = {
        storage = "5Gi"
      }
    }
    storage_class_name = "manual"
    volume_name        = kubernetes_persistent_volume.mysql_pv.metadata[0].name
  }
  depends_on = [
    kubernetes_persistent_volume.mysql_pv
  ]
}

##################################
# MYSQL: DEPLOYMENT E SERVICE
##################################
resource "kubernetes_deployment" "mysql" {
  metadata {
    name = "mysql"
    labels = {
      app = "mysql"
    }
  }
  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "mysql"
      }
    }
    template {
      metadata {
        labels = {
          app = "mysql"
        }
      }
      spec {
        service_account_name            = kubernetes_service_account.mysql.metadata[0].name
        automount_service_account_token = false

        # A imagem oficial do MySQL requer root para inicializar o banco.
        # O isolamento é garantido via NetworkPolicy — apenas o backend acessa o MySQL.

        container {
          name  = "mysql"
          image = var.mysql_image

          env {
            name  = "MYSQL_ROOT_PASSWORD"
            value = var.mysql_root_password
          }

          env {
            name  = "MYSQL_DATABASE"
            value = var.mysql_database
          }

          resources {
            limits = {
              memory = local.mysql_resources.limits.memory
              cpu    = local.mysql_resources.limits.cpu
            }
            requests = {
              memory = local.mysql_resources.requests.memory
              cpu    = local.mysql_resources.requests.cpu
            }
          }

          port {
            container_port = 3306
            name           = "mysql"
          }

          volume_mount {
            name       = "mysql-persistent-storage"
            mount_path = "/var/lib/mysql"
          }

          readiness_probe {
            tcp_socket {
              port = 3306
            }
            initial_delay_seconds = 15
            period_seconds        = 10
          }

          liveness_probe {
            tcp_socket {
              port = 3306
            }
            initial_delay_seconds = 20
            period_seconds        = 10
          }
        }

        volume {
          name = "mysql-persistent-storage"
          persistent_volume_claim {
            claim_name = kubernetes_persistent_volume_claim.mysql_pvc.metadata[0].name
          }
        }
      }
    }
  }
  depends_on = [kubernetes_persistent_volume_claim.mysql_pvc]
}

resource "kubernetes_service" "mysql_service" {
  metadata {
    name = "mysql-service"
    labels = {
      app = "mysql"
    }
  }
  spec {
    selector = {
      app = "mysql"
    }
    type = "ClusterIP"
    port {
      port        = 3306
      target_port = 3306
      name        = "mysql"
    }
  }
}

##################################
# BACKEND: DEPLOYMENT E SERVICE
##################################
resource "kubernetes_deployment" "auth_api" {
  metadata {
    name = "auth-api"
    labels = {
      app = "auth-api"
    }
  }
  spec {
    replicas = var.backend_replica_count
    selector {
      match_labels = {
        app = "auth-api"
      }
    }
    template {
      metadata {
        labels = {
          app = "auth-api"
        }
      }
      spec {
        service_account_name            = kubernetes_service_account.backend.metadata[0].name
        automount_service_account_token = false

        security_context {
          run_as_non_root = local.pod_security_context.run_as_non_root
          run_as_user     = local.pod_security_context.run_as_user
          run_as_group    = local.pod_security_context.run_as_group
          fs_group        = local.pod_security_context.fs_group
          seccomp_profile { type = "RuntimeDefault" }
        }

        init_container {
          name  = "wait-for-mysql"
          image = "busybox:1.28"
          command = [
            "sh", "-c",
            "until nc -z mysql-service 3306; do echo waiting for mysql; sleep 2; done;"
          ]
          security_context {
            allow_privilege_escalation = false
            read_only_root_filesystem  = true
            run_as_non_root            = true
            run_as_user                = 65534
            capabilities { drop = ["ALL"] }
          }
        }

        container {
          name  = "auth-api"
          image = var.backend_image

          env {
            name  = "MYSQL_ROOT_PASSWORD"
            value = var.mysql_root_password
          }

          env {
            name  = "DB_DSN"
            value = "root:${var.mysql_root_password}@tcp(mysql-service:3306)/${var.mysql_database}?parseTime=true"
          }

          env {
            name = "GMAIL_USERNAME"
            value_from {
              secret_key_ref {
                name = kubernetes_secret.gmail_credentials.metadata[0].name
                key  = "username"
              }
            }
          }

          env {
            name = "GMAIL_PASSWORD"
            value_from {
              secret_key_ref {
                name = kubernetes_secret.gmail_credentials.metadata[0].name
                key  = "password"
              }
            }
          }

          env {
            name  = "APP_URL"
            value = "http://${data.kubernetes_service.ingress_nginx.status[0].load_balancer[0].ingress[0].ip}"
          }

          security_context {
            allow_privilege_escalation = local.container_security_context.allow_privilege_escalation
            read_only_root_filesystem  = local.container_security_context.read_only_root_filesystem
            run_as_non_root            = local.container_security_context.run_as_non_root
            run_as_user                = local.container_security_context.run_as_user
            capabilities { drop = local.container_security_context.drop_capabilities }
          }

          port {
            container_port = 3000
            name           = "http"
          }

          resources {
            limits = {
              cpu    = local.backend_resources.limits.cpu
              memory = local.backend_resources.limits.memory
            }
            requests = {
              cpu    = local.backend_resources.requests.cpu
              memory = local.backend_resources.requests.memory
            }
          }

          volume_mount {
            name       = "tmp-dir"
            mount_path = "/tmp"
          }

          readiness_probe {
            http_get {
              path = "/api/health"
              port = 3000
            }
            initial_delay_seconds = 15
            period_seconds        = 10
          }

          liveness_probe {
            http_get {
              path = "/api/health"
              port = 3000
            }
            initial_delay_seconds = 20
            period_seconds        = 10
          }
        }

        volume {
          name = "tmp-dir"
          empty_dir { medium = "Memory" }
        }
      }
    }
  }
  depends_on = [
    kubernetes_deployment.mysql,
    kubernetes_service.mysql_service
  ]
}

resource "kubernetes_service" "auth_api" {
  metadata {
    name = "auth-api-service"
    labels = {
      app = "auth-api"
    }
  }
  spec {
    selector = {
      app = "auth-api"
    }
    type = "ClusterIP"
    port {
      port        = 3000
      target_port = 3000
      name        = "http"
    }
  }
}

##################################
# FRONTEND: DEPLOYMENT E SERVICE
##################################
resource "kubernetes_deployment" "auth_ui" {
  metadata {
    name = "auth-ui"
    labels = {
      app = "auth-ui"
    }
  }
  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "auth-ui"
      }
    }
    template {
      metadata {
        labels = {
          app = "auth-ui"
        }
      }
      spec {
        service_account_name            = kubernetes_service_account.frontend.metadata[0].name
        automount_service_account_token = false

        security_context {
          run_as_non_root = true
          run_as_user     = 101
          run_as_group    = 101
          fs_group        = 101
          seccomp_profile { type = "RuntimeDefault" }
        }

        container {
          name  = "auth-ui"
          image = var.frontend_image

          env {
            name  = "REACT_APP_API_URL"
            value = var.react_app_api_url
          }

          command = ["/bin/sh", "-c"]
          args = [
            <<-EOT
              echo 'window._env_ = { REACT_APP_API_URL: \"${var.react_app_api_url}\" };' > /usr/share/nginx/html/config.js
              nginx -g 'daemon off;'
            EOT
          ]

          security_context {
            allow_privilege_escalation = false
            read_only_root_filesystem  = true
            run_as_non_root            = true
            run_as_user                = 101
            capabilities { drop = ["ALL"] }
          }

          port {
            container_port = 80
            name           = "http"
          }

          resources {
            limits = {
              cpu    = local.frontend_resources.limits.cpu
              memory = local.frontend_resources.limits.memory
            }
            requests = {
              cpu    = local.frontend_resources.requests.cpu
              memory = local.frontend_resources.requests.memory
            }
          }

          volume_mount {
            name       = "nginx-cache"
            mount_path = "/var/cache/nginx"
          }
          volume_mount {
            name       = "nginx-run"
            mount_path = "/var/run"
          }
          volume_mount {
            name       = "tmp-dir"
            mount_path = "/tmp"
          }

          readiness_probe {
            http_get {
              path = "/index.html"
              port = 80
            }
            initial_delay_seconds = 10
            period_seconds        = 5
            failure_threshold     = 3
            success_threshold     = 1
            timeout_seconds       = 1
          }

          liveness_probe {
            http_get {
              path = "/index.html"
              port = 80
            }
            initial_delay_seconds = 15
            period_seconds        = 10
            failure_threshold     = 3
            success_threshold     = 1
            timeout_seconds       = 1
          }
        }

        volume {
          name = "nginx-cache"
          empty_dir { medium = "Memory" }
        }
        volume {
          name = "nginx-run"
          empty_dir { medium = "Memory" }
        }
        volume {
          name = "tmp-dir"
          empty_dir { medium = "Memory" }
        }
      }
    }
  }

  depends_on = [
    kubernetes_deployment.auth_api
  ]
}

resource "kubernetes_service" "auth_ui" {
  metadata {
    name = "auth-ui-service"
    labels = {
      app = "auth-ui"
    }
  }
  spec {
    selector = {
      app = "auth-ui"
    }
    type = "ClusterIP"
    port {
      port        = 80
      target_port = 80
      name        = "http"
    }
  }
}

devops/network_policy.tf ← NOVO

Implementa Zero Trust networking — tudo bloqueado por padrão, só o que é explicitamente permitido funciona.

Nossa arquitetura Zero Trust após esta aula:

  • Frontend → Backend (porta 3000): ✅ permitido
  • Backend → MySQL (porta 3306): ✅ permitido
  • Backend → Gmail SMTP (porta 587): ✅ permitido
  • Frontend → MySQL diretamente: ❌ bloqueado
  • Qualquer pod invasor → qualquer serviço: ❌ bloqueado
  • default-deny-all — bloqueia TODO tráfego Ingress e Egress no namespace.
  • backend-ingress-policy — o backend só aceita do frontend (porta 3000) ou do Ingress Controller.
  • backend-egress-policy — o backend só pode sair para MySQL (3306), Gmail SMTP (587, 465) e DNS (53).
  • frontend-policy — o frontend só aceita do Ingress Controller e só fala com o backend. Nunca acessa o MySQL diretamente.
  • mysql-ingress-policy — o MySQL só aceita conexões do backend.

user-auth-system/devops/network_policy.tf

resource "kubernetes_network_policy" "default_deny_all" {
  metadata {
    name   = "default-deny-all"
    labels = local.common_labels
  }
  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

resource "kubernetes_network_policy" "backend_ingress" {
  metadata {
    name   = "backend-ingress-policy"
    labels = local.common_labels
  }
  spec {
    pod_selector {
      match_labels = {
        app = "auth-api"
      }
    }
    policy_types = ["Ingress"]

    ingress {
      from {
        pod_selector {
          match_labels = {
            app = "auth-ui"
          }
        }
      }
      ports {
        port     = "3000"
        protocol = "TCP"
      }
    }

    ingress {
      from {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "ingress-nginx"
          }
        }
      }
      ports {
        port     = "3000"
        protocol = "TCP"
      }
    }
  }
}

resource "kubernetes_network_policy" "backend_egress" {
  metadata {
    name   = "backend-egress-policy"
    labels = local.common_labels
  }
  spec {
    pod_selector {
      match_labels = {
        app = "auth-api"
      }
    }
    policy_types = ["Egress"]

    egress {
      to {
        pod_selector {
          match_labels = {
            app = "mysql"
          }
        }
      }
      ports {
        port     = "3306"
        protocol = "TCP"
      }
    }

    egress {
      ports {
        port     = "587"
        protocol = "TCP"
      }
    }

    egress {
      ports {
        port     = "465"
        protocol = "TCP"
      }
    }

    egress {
      ports {
        port     = "53"
        protocol = "UDP"
      }
    }

    egress {
      ports {
        port     = "53"
        protocol = "TCP"
      }
    }
  }
}

resource "kubernetes_network_policy" "frontend_policy" {
  metadata {
    name   = "frontend-policy"
    labels = local.common_labels
  }
  spec {
    pod_selector {
      match_labels = {
        app = "auth-ui"
      }
    }
    policy_types = ["Ingress", "Egress"]

    ingress {
      from {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "ingress-nginx"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }

    egress {
      to {
        pod_selector {
          match_labels = {
            app = "auth-api"
          }
        }
      }
      ports {
        port     = "3000"
        protocol = "TCP"
      }
    }

    egress {
      ports {
        port     = "53"
        protocol = "UDP"
      }
    }
  }
}

resource "kubernetes_network_policy" "mysql_ingress" {
  metadata {
    name   = "mysql-ingress-policy"
    labels = local.common_labels
  }
  spec {
    pod_selector {
      match_labels = {
        app = "mysql"
      }
    }
    policy_types = ["Ingress", "Egress"]

    ingress {
      from {
        pod_selector {
          match_labels = {
            app = "auth-api"
          }
        }
      }
      ports {
        port     = "3306"
        protocol = "TCP"
      }
    }
  }
}

devops/helm_ingress.tf ← MODIFICADO

O que mudou e por quê:

  • namespace = "ingress-nginx" e create_namespace = true — o Ingress Controller agora roda em namespace dedicado, que é o padrão recomendado. Evita conflitos com recursos do namespace default.
  • admissionWebhooks.enabled = false — o job de admission webhook falha em clusters novos porque tenta validar recursos antes do controller estar pronto. Desabilitar resolve o problema sem impacto funcional.

user-auth-system/devops/helm_ingress.tf

resource "helm_release" "ingress_nginx" {
  name             = "ingress-nginx"
  repository       = "https://kubernetes.github.io/ingress-nginx"
  chart            = "ingress-nginx"
  namespace        = "ingress-nginx"
  create_namespace = true
  timeout          = 600

  # Precisamos do Service tipo LoadBalancer
  # para a DigitalOcean provisionar IP externo
  set {
    name  = "controller.service.type"
    value = "LoadBalancer"
  }

  # Publicar service do controller para relatarmos IP
  set {
    name  = "controller.publishService.enabled"
    value = "true"
  }

  # Desabilitar admission webhook que causa falha no job de pre-install
  set {
    name  = "controller.admissionWebhooks.enabled"
    value = "false"
  }
}

devops/terraform.tfvars.example ← MODIFICADO

O que mudou e por quê:

  • Reflete todas as variáveis do variables.tf.
  • O cluster_version atualizado para 1.36.0-do.0 — sempre verifique a versão mais recente com doctl kubernetes options versions.

user-auth-system/devops/terraform.tfvars.example

# cp terraform.tfvars.example terraform.tfvars
# NUNCA commitar o terraform.tfvars

do_token            = "SEU_TOKEN_DIGITALOCEAN"
do_region           = "nyc1"
cluster_name        = "meu-cluster"
cluster_version     = "1.36.0-do.0"

namespace             = "default"
environment           = "production"

backend_image         = "toticavalcanti/fiber-auth-api:v1.0"
frontend_image        = "toticavalcanti/auth-ui:v1.1"
mysql_image           = "mysql:8.0"
backend_replica_count = 1

mysql_database      = "auth_db"
mysql_root_password = "SUBSTITUA_POR_SENHA_FORTE"

gmail_username      = "SEU_EMAIL@gmail.com"
gmail_password      = "SENHA_DE_APP_GMAIL"

react_app_api_url   = "/api/"

🧪 Parte 3 — Deploy Passo a Passo

Passo 1: Preparar o terraform.tfvars

🐧 Linux / Mac:

cd devops
cp terraform.tfvars.example terraform.tfvars
nano terraform.tfvars

⊞ Windows (PowerShell):

cd devops
Copy-Item terraform.tfvars.example terraform.tfvars
notepad terraform.tfvars

⊞ Windows (CMD):

cd devops
copy terraform.tfvars.example terraform.tfvars
notepad terraform.tfvars

Preencha com seus dados reais: do_token, cluster_version, mysql_root_password, gmail_username e gmail_password.

Passo 2: Inicializar o Terraform

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

terraform init

Passo 3: Verificar o plano

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

terraform plan

Passo 4: Criar o cluster e fazer o deploy

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

terraform apply

Digite yes quando solicitado. O processo leva cerca de 5 a 10 minutos.

Passo 5: Atualizar o kubeconfig

Após o terraform apply, o kubeconfig gerado automaticamente pode apontar para uma versão antiga do doctl. Execute este comando para garantir que está usando o doctl correto:

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

doctl kubernetes cluster kubeconfig save meu-cluster --set-current-context

Passo 6: Verificar o cluster e os pods

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

kubectl get nodes
kubectl get pods
kubectl get svc
kubectl get svc -n ingress-nginx
kubectl get networkpolicies
kubectl get serviceaccounts,roles,rolebindings
kubectl get secrets

Passo 7: Aguardar todos os pods ficarem Ready

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

kubectl wait --for=condition=ready pod -l app=auth-api --timeout=120s
kubectl wait --for=condition=ready pod -l app=auth-ui --timeout=120s
kubectl wait --for=condition=ready pod -l app=mysql --timeout=120s

🔒 Parte 4 — Testes de Segurança

✅ Teste 1: Fluxo legítimo funcionando

🐧 Linux / Mac:

curl http://SEU_IP_DO_INGRESS/api/health

⊞ Windows (PowerShell):

Invoke-WebRequest -Uri "http://SEU_IP_DO_INGRESS/api/health" | Select-Object -ExpandProperty Content

❌ Teste 2: Frontend não acessa MySQL (deve ser bloqueado)

O comando vai travar por timeout — isso confirma que a NetworkPolicy está bloqueando o acesso.

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

kubectl exec deployment/auth-ui -- nc -zv -w 5 mysql-service 3306

❌ Teste 3: Pod invasor sem labels (deve ser bloqueado)

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

kubectl run invasor --image=busybox:1.36 --restart=Never -- sh -c "nc -zv -w 5 auth-api-service 3000 || echo 'ACESSO NEGADO'"
kubectl logs invasor
kubectl delete pod invasor

🧪 Teste 4: RBAC em ação

🐧 Linux / Mac:

# Frontend NAO pode listar Secrets — esperado: no
kubectl auth can-i get secrets \
  --as=system:serviceaccount:default:frontend-service-account

Backend PODE listar pods — esperado: yes

kubectl auth can-i list pods
--as=system:serviceaccount:default:backend-service-account

Backend NAO pode deletar pods — esperado: no

kubectl auth can-i delete pods
--as=system:serviceaccount:default:backend-service-account

⊞ Windows (PowerShell):

kubectl auth can-i get secrets `
  --as=system:serviceaccount:default:frontend-service-account

kubectl auth can-i list pods ` --as=system:serviceaccount:default:backend-service-account

kubectl auth can-i delete pods ` --as=system:serviceaccount:default:backend-service-account

📊 Checklist de Segurança

  • ☑️ RBAC: ServiceAccounts separadas com permissões mínimas
  • ☑️ NetworkPolicy deny-all: tudo bloqueado por padrão
  • ☑️ NetworkPolicy frontend→backend: frontend só acessa backend na porta 3000
  • ☑️ NetworkPolicy backend→MySQL: só o backend acessa MySQL na porta 3306
  • ☑️ NetworkPolicy backend→Gmail: backend pode sair na porta 587
  • ☑️ SecurityContext: runAsNonRoot, readOnlyRootFilesystem, capabilities drop ALL (backend e frontend)
  • ☑️ Resource limits: CPU e memória em todos os containers
  • ☑️ Secrets: MySQL e Gmail via kubernetes_secret, nunca hardcoded
  • ☑️ .gitignore: terraform.tfvars e *.tfstate protegidos

🚨 Troubleshooting

❓ kubectl não conecta após o terraform apply

O kubeconfig pode estar apontando para uma versão antiga do doctl. Execute:

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

doctl kubernetes cluster kubeconfig save meu-cluster --set-current-context

❓ Backend não conecta ao MySQL após NetworkPolicy

🐧 Linux / Mac:

kubectl get pods --show-labels | grep auth-api

⊞ Windows (PowerShell):

kubectl get pods --show-labels | Select-String "auth-api"

❓ NetworkPolicy não está bloqueando

🐧 Linux / Mac:

kubectl get pods -n kube-system | grep cilium

⊞ Windows (PowerShell):

kubectl get pods -n kube-system | Select-String "cilium"

O DOKS usa Cilium como CNI desde 2023 — NetworkPolicies são suportadas nativamente.

✅ Conclusão

Parabéns! Você blindou o projeto fiber-auth-api + auth-ui com 5 camadas de segurança:

  • 🔐 RBAC granular: identidades separadas, permissões mínimas
  • 🏰 Zero Trust de rede: frontend não acessa MySQL, invasores bloqueados
  • 🛡️ Containers endurecidos: não-root, filesystem readonly, capabilities removidas (backend e frontend)
  • 🗝️ Secrets gerenciados: MySQL e Gmail via kubernetes_secret, nunca hardcoded
  • Resource limits: DoS prevenido em todos os containers

🚀 Próximos Passos

  • Aula 16: Pod Security Standards (PSS/PSA)
  • Aula 17: OPA Gatekeeper
  • Aula 18: Falco — monitoramento em runtime
  • Aula 19: cert-manager — TLS automático com Let's Encrypt
  • Aula 20: Istio Service Mesh — mTLS automático

📚 Limpeza do Ambiente

🐧 Linux / Mac e ⊞ Windows (mesmo comando):

terraform destroy

Isso destrói o cluster inteiro na DigitalOcean. Confirme com yes quando solicitado.