DevSecOps - Sécurisez, Auditez, Automatisez vos bases de données PostgreSQL (2/4)

Dans le 1er article, nous avons balayé l’ensemble des aspects "sécurité", couverts par le module. Passons à la pratique.

Pour des raisons de simplicité, le code construit, dans un seul composant terraform, l’ensemble des ressources nécessaires pour la démonstration. De même, la base de données et l’instance Elasticsearch seront exposées publiquement mais avec un accès restreint. Dans la réalité, vous décomposerez votre architecture en plusieurs composants (le VPC, la base RDS, la configuration de votre base PostgreSQL, l’outil de SOC) et vous déploierez vos bases dans un réseau privé. Cela étant dit, nous pouvons commencer.

Afin d’illustrer tous ces principes de sécurité, prenons le cas d’une “fake application” de gestion d’un panier, implémentée avec le modèle de données suivant :

L’application possédera 3 composants :

  • un composant web permettant la gestion des clients et de leurs paniers,
  • un composant backoffice permettant de mettre à jour les produits en vente sur le site,
  • et enfin, un batch permettant de mettre à jour une table de statistiques permettant de comptabiliser le montant total vendu par produit. Le traitement sera exécuté par une invraisemblable procédure stockée !

En terme de permissions “nécessaires et suffisantes”, cela donne le tableau suivant :

Composant CUSTOMER BASKET PRODUCT STATS
Web Write Operations Write Operations Read Operations N/A
BackOffice Read Operations Read Operations Write Operations Read Operations
Batch(Stats) Read Operations Read Operations Read Operations Write Operations

Vous pouvez retrouver l’ensemble du code source ici.

Notez que j’utilise l’excellente librairie direnv pour positionner un certain nombre de variables d’environnement. Voici mon fichier .envrc :

export PGPASSWORD="My-Strong-Password"
export AWS_PROFILE=ippon-sandbox
export TF_VAR_rds_root_password="${PGPASSWORD}"

  • PGPASSWORD : variable d’environnement native de PostgreSQL pour récupérer le mot de passe. C’est comme cela que le provider PostgreSQL fonctionne.
  • AWS_PROFILE : Le profil AWS contenant les éléments d’authentification de votre compte AWS.
  • TF_VAR_rds_root_password : valorisation de la variable terraform rds_root_password

Construction de notre “playground”

Comme dit en préambule, on va construire une infrastructure sur AWS. Pour cela, on doit construire en premier lieu un VPC. Vous pouvez retrouver le code dans le fichier vpc.tf.

Notez qu’en plus du vpc, on déclare un security_group nous permettant de joindre la base de données sur son adresse IP publique, port 5432, et qui limite l’accès à notre adresse IP de sortie.

Construction de l’instance RDS

Continuons par déployer notre base RDS, en utilisant les modules officiels de la registry terraform :

######################################
# Deploy RDS Instance
######################################
module "rds" {
   source  = "terraform-aws-modules/rds/aws"
   version = "3.5.0"

   identifier = local.name

   engine                = "postgres"
   engine_version        = var.rds_engine_version
   family                = var.rds_family
   major_engine_version  = var.rds_major_engine_version
   instance_class        = var.rds_instance_class

   allocated_storage     = var.rds_allocated_storage
   max_allocated_storage = var.rds_max_allocated_storage
   storage_encrypted     = var.rds_storage_encrypted

   name     = var.inputs["db_name"]
   username = var.rds_superuser_name
   password = var.rds_root_password
   port     = 5432

   multi_az               = true
   # because we want reach the database from our local workstation, 
   # we need to deploy our RDS in the public subnets
   # DO NOT DO THAT IN PRODUCTION
   # to reduce the attack surface, limit the access of the RDS Instance 
   # to our personal IP addresses
   publicly_accessible    = true
   subnet_ids             = module.vpc.public_subnets
   vpc_security_group_ids = [module.security_group.security_group_id]

   maintenance_window              = "Mon:00:00-Mon:03:00"
   backup_window                   = "03:00-06:00"
   enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]

   backup_retention_period = 0
   skip_final_snapshot     = true
   deletion_protection     = false

   create_db_parameter_group = false
   parameter_group_name      = aws_db_parameter_group.postgres.id

   create_db_option_group = false
   create_db_subnet_group = false
   db_subnet_group_name   = aws_db_subnet_group.main_db_subnet_group.id

   tags = local.tags
}

le fichier terraform.tfvars nous fournit les paramètres :

# rds settings
rds_name                  = "myfullrdsexample"
rds_engine_version        = "13.5"
rds_major_engine_version  = "13"
rds_family                = "postgres13"
rds_instance_class        = "db.t3.micro"
rds_allocated_storage     = 10
rds_max_allocated_storage = 20
allowed_ip_addresses      = ["X.X.X.X/32"]
rds_superuser_name        = "root"

Notez qu’on renomme le superuser en root et non postgres (valeur par défaut dans le moteur PostgreSQL).

Construction de la database PostgreSQL

Quelques mots sur 3 attributs relatifs à la ressource terraform aws_db_instance :

identifier = var.rds_name
name       = var.inputs["db_name"]
username   = var.rds_superuser_name

  • identifier : c’est le nom du moteur postgresql. C’est une enveloppe représentant l’installation d’un serveur postgresql. Il faut le voir comme le nom de la machine sur laquelle est installée le serveur postgresql. Ce n’est pas l’objet “database” au sens postgresql. C’est ce que vous verrez dans le dashboard de synthèse du service RDS dans la console AWS.
  • name :  c’est le nom de la database au sens postgresql que l’api va nous créer. C’est un attribut optionnel. Si vous ne le spécifiez pas, c’est la database postgres qui sera créée.
  • username : Il s'agit du libellé de l’utilisateur “super-user” associée à votre instance RDS. Si vous ne le spécifiez pas, ce sera le user postgres.

Dans notre démonstration, lorsque terraform va nous déployer notre instance RDS, nous aurons donc :

  • une instance RDS myfullrdsexample
  • une database mydatabase à l’intérieur de cette instance
  • un user root super-user relatif à toutes les databases déployées dans l’instance RDS.


Construction de notre user “admin”

Appelons notre module pour générer les rôles et permissions associées à notre futur utilisateur admin :

########################################
# Initialize the database and the objects
# (roles & grants), the default privileges
########################################
module "initdb" {
   source  = "jparnaudeau/database-admin/postgresql//create-database"
   version = "2.0.0"
   
   depends_on = [module.rds]
   
   # set the provider
   providers = {
      postgresql = postgresql.pgadm
   }
   
   # targetted rds
   pgadmin_user = var.rds_superuser_name
   dbhost       = module.rds.db_instance_address
   dbport       = var.dbport
   
   # input parameters for creating database & objects inside database
   create_database = false
   inputs          = var.inputs
   
   # because the superuser is not "postgres", need to set it in the module
   default_superusers_list = [var.rds_superuser_name]
}

Remarque : on passe l’attribut create_database à false car l’API RDS nous a créé la database pour nous.

Spécifions ensuite ceci dans le fichier terraform.tfvars :

# database and objects creation
inputs = {
   # parameters used for creating database
   db_schema_name = "public"
   db_name        = "mydatabase" # should be the same as var.rds_name. if not, a new database will be created
   db_admin       = "app_admin_role" # owner of the database
   
   # install extensions if needed
   extensions = ["pgaudit"]

   # CREATE ROLES
   db_roles = [
      { id = "admin", role = "app_admin_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE", "CREATE"], createrole = true },
],

   #  GRANT PERMISSIONS ON ROLES
   db_grants = [
   # define grants for app_admin_role :
   # - access to all objects on database
      { object_type = "database", privileges = ["CREATE", "CONNECT", "TEMPORARY"], objects = [], role = "app_admin_role", owner_role = "root", grant_option = true },
      { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_admin_role", owner_role = "root", grant_option = true },
   ],

   # CREATE USERS
   db_users = [
      { name = "admin", inherit = true, login = true, membership = ["app_admin_role"], validity = "infinity", connection_limit = -1, createrole = true },
   ]
}

# Refresh or not refresh passwords
refresh_passwords = ["all"]

Quelques mots sur la structure d’un grant :

  • object_type : la liste des objets supportés par le provider est disponible ici.
  • privileges : la liste des privilèges est disponible ici.
  • objects : la liste éventuelle des objets sur lesquels le grant s’applique. Liste vide signifie all.
  • role : le rôle sur lequel on souhaite ajouter les permissions.
  • owner_role : le rôle à partir duquel le grant est exécuté. Le rôle spécifié doit avoir les permissions suffisantes pour réaliser le grant en question. Ici, parce qu’on est en train de créer l’utilisateur “admin” (il n’existe donc pas encore), on utilise notre super-user “root”.
  • grant_option : à true, cela signifie qu’on délègue, au rôle qu’on est en train de créer, le droit d’affecter ses privilèges à d’autres “rôles”.


La création des users se fait en utilisant le second sous-module create-users:

#########################################
# Create the users inside the database
#########################################
# AWS Region
data "aws_region" "current" {}


module "create_users" {
   source  = "jparnaudeau/database-admin/postgresql//create-users"
   version = "2.0.0"

   # need that all objects, managed inside the module "initdb", are created
   depends_on = [module.initdb]

   # set the provider
   providers = {
      postgresql = postgresql.pgadm
   }

   # targetted rds
   pgadmin_user = var.rds_superuser_name
   dbhost       = module.rds.db_instance_address
   dbport       = var.dbport
   
   # input parameters for creating users inside database
   db_users = var.inputs["db_users"]

   # set passwords
   passwords = { for user in var.inputs["db_users"] : user.name => random_password.passwords[user.name].result }

   # set postprocessing playbook
   postprocessing_playbook_params = {
      enable  = true
      db_name = var.inputs["db_name"]
      extra_envs = {
         REGION   = data.aws_region.current.name
         RDS_NAME = var.rds_name
      }
      refresh_passwords = var.refresh_passwords
      shell_name        = "./gen-password-in-secretsmanager.py"
   }
}

Concernant l’attribut passwords, nous passons des random_password. On pourrait penser que c’est dangereux car le password correspondant sera stocké en clair dans le remote_state ? Oui mais non.

Ici, on exécute le script gen-password-in-secretsmanager.py. Celui-ci va générer un mot de passe aléatoire, écraser le mot de passe de l’utilisateur créé en base et le stocker dans AWS SecretsManager. Le mot de passe contenu dans le remote_state n’est donc plus l’actuel mot de passe de l’utilisateur.

Le système de postprocessing_playbook injecte automatiquement des variables d’environnement accessibles dans le script :

  • Les variables d’environnement natives PostgreSQL : PGHOST, PGPORT, PGUSER, PGDATABASE.
  • Une variable d’environnement DBUSER représentant le user pour lequel on souhaite lui affecter un nouveau mot de passe.
  • Toutes les variables que vous aurez définies dans extra_envs.

Concernant la rotation des mots de passe  :

  • en laissant refresh_passwords = ["all"], cela indique au post-processing playbook de s’exécuter pour tous les utilisateurs définis. Cela signifie qu’à chaque fois que l’apply est exécuté, les mots de passe des utilisateurs seront redéfinis.
  • Pour suspendre la rotation des mots de passe, vous pouvez mettre refresh_passwords = [""].
  • Si vous souhaitez rafraîchir uniquement le mot de passe d'un user, par exemple  “redfang”, il suffira alors de mettre refresh_passwords = ["redfang"].

Pour compléter le tout, notons que pour simplifier l’écriture du script, nous créons via terraform des entrées dans AWS SecretsManager. Là aussi, le password qui est utilisé lors de la création de ces secrets n’a pas d’importance. Il sera écrasé lors de l’exécution du post-processing plakbook. Cela évite de gérer dans le playbook le cas où le secret n’existe pas.

A l’apply, si tout se passe bien, on dispose donc d’un utilisateur admin ayant tous les droits sur la database mydatabase et dont le mot de passe est récupérable depuis AWS SecretsManager.

Apply complete! Resources: 44 added, 0 changed, 0 destroyed.
Outputs:
affected_schema = "public"
created_roles = [
   "app_admin_role",
]
db_users = {
   "admin" = {
      "connect_command" = "psql -h myfullrdsexample.pandemonium.eu-west-3.rds.amazonaws.com -p 5432 -U admin -d mydatabase -W"
      "secret_arn" = "arn:aws:secretsmanager:eu-west-3:444444444444:secret:secret-kv-myfullrdsexample-admin-PFxvvF"
      "secret_name" = "secret-kv-myfullrdsexample-admin"
   }
}

Tentons de nous connecter :

psql -h myfullrdsexample.pandemonium.eu-west-3.rds.amazonaws.com -p 5432 -U admin -d mydatabase -W
Password: <find password in AWS SecretsManager in secret-kv-myfullrdsexample-admin>
psql (13.5 (Ubuntu 13.5-2.pgdg20.04+1))
SSL connection (protocol: TLSv1.2, cipher: ECDHE-RSA-AES256-GCM-SHA384, bits: 256, compression: off)
Type "help" for help.
mydatabase=> \l
List of databases
Name    |  Owner   | Encoding |   Collate   |    Ctype    |     Access privileges
------------+----------+----------+-------------+-------------+----------------------------
mydatabase | root     | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =Tc/root                   +
           |          |          |             |             | root=CTc/root              +
           |          |          |             |             | app_admin_role=C*T*c*/root +
postgres   | root     | UTF8     | en_US.UTF-8 | en_US.UTF-8 | 
rdsadmin   | rdsadmin | UTF8     | en_US.UTF-8 | en_US.UTF-8 | rdsadmin=CTc/rdsadmin      +
           |          |          |             |             | rdstopmgr=Tc/rdsadmin            
template0  | rdsadmin | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/rdsadmin                +
           |          |          |             |             | rdsadmin=CTc/rdsadmin
template1  | root     | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/root                    +
           |          |          |             |             |root=CTc/root
(5 rows)

On vient de terminer les étapes de configuration de notre user admin. A partir de maintenant, ce sera ce user qui sera utilisé pour les tâches quotidiennes de maintenance de notre base. C'est ce que je vous propose de découvrir dans le prochain article de cette série, où nous déploierons les objets nécessaires à notre "fake application" en adoptant le principe de Least Privilege.

Stay Tuned,