├── .gitignore ├── README.md ├── create-database ├── README.md ├── db_objects.tf ├── extensions.tf ├── outputs.tf ├── variables.tf └── versions.tf ├── create-users ├── README.md ├── db_users.tf ├── outputs.tf ├── variables.tf └── versions.tf ├── docker-compose.yml ├── examples ├── README.md ├── all-in-one │ ├── .envrc │ ├── README.md │ ├── gen-password-in-ps.sh │ ├── main.tf │ ├── outputs.tf │ ├── providers.tf │ ├── terraform.tfvars │ └── variables.tf ├── create-users-on-existent-database │ ├── .envrc │ ├── .json │ ├── README.md │ ├── gen-password.sh │ ├── main.tf │ ├── providers.tf │ ├── terraform.tfvars │ └── variables.tf ├── full-rds-example │ ├── README.md │ ├── create-procedure-statistiques.sql │ ├── create-tables.sql │ ├── elasticsearch.tf │ ├── gen-password-in-secretsmanager.py │ ├── locals.tf │ ├── outputs.tf │ ├── policies │ │ ├── lambda_policy.tpl │ │ └── lambda_role.json │ ├── postgresql.tf │ ├── providers.tf │ ├── rds.tf │ ├── retrieve-audit-logs.sh │ ├── terraform.tfvars │ ├── terraform.tfvars.step5 │ ├── variables.tf │ └── vpc.tf └── simple-database │ ├── .envrc │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── providers.tf │ ├── terraform.tfvars │ └── variables.tf └── schemas ├── Diagram-Relations.png ├── Diagram.excalidraw ├── ELK1.png └── FakeApplication.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | *.terraform.lock.hcl 8 | 9 | .idea 10 | .env 11 | /.bin 12 | 13 | .project 14 | 15 | *.zip 16 | 17 | **/trace.txt 18 | 19 | **/*Zone.Identifier 20 | 21 | gen-docs.sh 22 | 23 | examples/full-rds-example/.envrc 24 | 25 | **/builds/* -------------------------------------------------------------------------------- /create-database/README.md: -------------------------------------------------------------------------------- 1 | ## Requirements 2 | 3 | | Name | Version | 4 | |------|---------| 5 | | [terraform](#requirement\_terraform) | >= 1.0.4 | 6 | | [null](#requirement\_null) | >= 3.0.0 | 7 | | [postgresql](#requirement\_postgresql) | >= 1.15.0 | 8 | | [random](#requirement\_random) | >= 3.0.0 | 9 | 10 | ## Providers 11 | 12 | | Name | Version | 13 | |------|---------| 14 | | [postgresql](#provider\_postgresql) | >= 1.15.0 | 15 | 16 | ## Modules 17 | 18 | No modules. 19 | 20 | ## Resources 21 | 22 | | Name | Type | 23 | |------|------| 24 | | [postgresql_database.db](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/database) | resource | 25 | | [postgresql_default_privileges.alter_defaults_privs](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/default_privileges) | resource | 26 | | [postgresql_extension.psql_extension](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/extension) | resource | 27 | | [postgresql_grant.grant_roles_schema](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | 28 | | [postgresql_grant.privileges](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | 29 | | [postgresql_grant.revoke_create_public](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | 30 | | [postgresql_role.app_role_admin](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | 31 | | [postgresql_role.app_roles](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | 32 | | [postgresql_schema.schema](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/schema) | resource | 33 | 34 | ## Inputs 35 | 36 | | Name | Description | Type | Default | Required | 37 | |------|-------------|------|---------|:--------:| 38 | | [create\_database](#input\_create\_database) | Enable/Disable the creation of the database. Except for local tests or Cloud environment, the database creation is not possible. Disabled by default | `bool` | `false` | no | 39 | | [dbhost](#input\_dbhost) | The Postgresql Database Hostname | `string` | n/a | yes | 40 | | [dbport](#input\_dbport) | The Postgresql Database Port | `string` | n/a | yes | 41 | | [default\_superusers\_list](#input\_default\_superusers\_list) | List the super-users. By default, it's the postgres user. | `list(string)` |
[
"postgres"
]
| no | 42 | | [inputs](#input\_inputs) | The Inputs parameters for objects to create inside the database |
object({
db_schema_name = string
db_name = string
db_admin = string
extensions = list(string)
db_roles = list(object({
id = string
role = string
inherit = bool
login = bool
validity = string
privileges = list(string)
createrole = bool
}))
db_grants = list(object({
object_type = string
privileges = list(string)
role = string
owner_role = string
objects = list(string)
grant_option = bool
}))
})
| `null` | no | 43 | | [pgadmin\_user](#input\_pgadmin\_user) | The Postgresql username | `string` | n/a | yes | 44 | | [revoke\_create\_public](#input\_revoke\_create\_public) | Enable/Disable the revoke command for create table in schema public | `bool` | `true` | no | 45 | 46 | ## Outputs 47 | 48 | No outputs. 49 | -------------------------------------------------------------------------------- /create-database/db_objects.tf: -------------------------------------------------------------------------------- 1 | 2 | ######################################## 3 | # Database Creation 4 | ######################################## 5 | resource "postgresql_database" "db" { 6 | for_each = var.create_database ? toset([var.inputs["db_name"]]) : [] 7 | 8 | name = var.inputs["db_name"] 9 | owner = var.inputs["db_admin"] 10 | template = "template0" 11 | encoding = "UTF8" 12 | lc_collate = "en_US.UTF-8" 13 | lc_ctype = "en_US.UTF-8" 14 | connection_limit = -1 15 | allow_connections = true 16 | 17 | depends_on = [ 18 | postgresql_role.app_role_admin, 19 | ] 20 | } 21 | 22 | ######################################## 23 | # Roles Creation 24 | ######################################## 25 | # the adminsitration role 26 | resource "postgresql_role" "app_role_admin" { 27 | # because there is a dependency between the admin role used to be the owner of the objects (var.inputs["db_admin"]) and the database and the other roles, 28 | # we need to create this role first. Except when the role is a user that already exists, like when var.inputs["db_admin"] == 'postgres" by example. 29 | for_each = { for tuple in var.inputs["db_roles"] : tuple.role => tuple if tuple.role == var.inputs["db_admin"] && !contains(var.default_superusers_list, var.inputs["db_admin"]) } 30 | 31 | 32 | name = each.value.role 33 | login = each.value.login 34 | inherit = each.value.inherit 35 | valid_until = each.value.validity 36 | create_role = lookup(each.value, "createrole", false) 37 | roles = lookup(each.value, "membership", null) 38 | search_path = lookup(each.value, "search_path", null) 39 | } 40 | 41 | # other roles 42 | resource "postgresql_role" "app_roles" { 43 | # because there is a dependency between the admin role used to be the owner of the objects (var.inputs["db_admin"]) and the database and the other roles, 44 | # we need to create other roles in a second step, after the creation of the var.inputs["db_admin"] 45 | for_each = { for tuple in var.inputs["db_roles"] : tuple.role => tuple if tuple.role != var.inputs["db_admin"] } 46 | 47 | name = each.value.role 48 | login = each.value.login 49 | inherit = each.value.inherit 50 | valid_until = each.value.validity 51 | create_role = lookup(each.value, "createrole", false) 52 | roles = lookup(each.value, "membership", null) 53 | search_path = lookup(each.value, "search_path", null) 54 | 55 | 56 | provisioner "local-exec" { 57 | when = create 58 | environment = { 59 | PGHOST = var.dbhost 60 | PGPORT = var.dbport 61 | PGUSER = var.pgadmin_user 62 | PGAPPNAME = "terraform-psql" 63 | PGDATABASE = var.inputs["db_name"] 64 | } 65 | command = < tuple.privileges } 97 | 98 | database = var.inputs["db_name"] 99 | schema = var.inputs["db_schema_name"] 100 | role = each.key 101 | object_type = "schema" 102 | privileges = try(each.value, null) 103 | 104 | depends_on = [ 105 | postgresql_role.app_roles, 106 | postgresql_database.db, 107 | postgresql_schema.schema, 108 | ] 109 | } 110 | 111 | 112 | 113 | 114 | ######################################## 115 | # Creation of grants for each role 116 | ######################################## 117 | resource "postgresql_grant" "privileges" { 118 | 119 | for_each = { for tuple in var.inputs["db_grants"] : 120 | join("_", [tuple.role, tuple.object_type, "privs", join(",",tuple.objects)]) => tuple if tuple.object_type != "type" } 121 | 122 | database = var.inputs["db_name"] 123 | schema = var.inputs["db_schema_name"] 124 | role = each.value.role 125 | objects = try(each.value.objects, []) 126 | object_type = each.value.object_type 127 | privileges = each.value.privileges 128 | with_grant_option = each.value.grant_option 129 | 130 | depends_on = [ 131 | postgresql_role.app_roles, 132 | postgresql_database.db, 133 | postgresql_schema.schema, 134 | ] 135 | } 136 | 137 | 138 | ######################################## 139 | # Update default privileges according to parameters setted in var.inputs 140 | ######################################## 141 | resource "postgresql_default_privileges" "alter_defaults_privs" { 142 | 143 | for_each = { for tuple in var.inputs["db_grants"] : 144 | join("_", [tuple.role, tuple.object_type, "defaults", "privs", join(",",tuple.objects)]) => tuple if tuple.object_type != "database" 145 | } 146 | 147 | database = var.inputs["db_name"] 148 | schema = var.inputs["db_schema_name"] 149 | owner = each.value.owner_role 150 | role = each.value.role 151 | object_type = each.value.object_type 152 | privileges = each.value.privileges 153 | 154 | depends_on = [ 155 | postgresql_grant.privileges, 156 | postgresql_role.app_roles, 157 | postgresql_schema.schema, 158 | postgresql_database.db, 159 | postgresql_grant.revoke_create_public 160 | ] 161 | } 162 | 163 | ######################################## 164 | # REVOKE CREATE ON SCHEMA public FROM PUBLIC; 165 | # Because by default, the default privileges allow any user ("public") 166 | # to create table inside "public" schema 167 | ######################################## 168 | resource "postgresql_grant" "revoke_create_public" { 169 | 170 | count = var.revoke_create_public ? 1 : 0 171 | database = var.inputs["db_name"] 172 | schema = "public" 173 | role = "public" 174 | object_type = "schema" 175 | privileges = [] 176 | 177 | depends_on = [ 178 | postgresql_schema.schema, 179 | postgresql_database.db, 180 | postgresql_grant.privileges 181 | ] 182 | } -------------------------------------------------------------------------------- /create-database/extensions.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Extras Schemas Creation 3 | # + creation of grants for the role "app_releng_role" inside schema 4 | # here, we assume that the role "app_releng_role" is defined 5 | ######################################## 6 | resource "postgresql_extension" "psql_extension" { 7 | 8 | for_each = toset(var.inputs["extensions"]) 9 | name = each.key 10 | 11 | depends_on = [ 12 | postgresql_role.app_roles, 13 | postgresql_database.db, 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /create-database/outputs.tf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jparnaudeau/terraform-postgresql-database-admin/ef34b2d22741907712c405a555013ff71da3fbab/create-database/outputs.tf -------------------------------------------------------------------------------- /create-database/variables.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Provider vars 3 | ######################################## 4 | variable "pgadmin_user" { 5 | type = string 6 | description = "The Postgresql username" 7 | } 8 | 9 | variable "dbhost" { 10 | type = string 11 | description = "The Postgresql Database Hostname" 12 | } 13 | 14 | variable "dbport" { 15 | type = string 16 | description = "The Postgresql Database Port" 17 | } 18 | 19 | ######################################## 20 | # Input vars for Creating Objects inside Database 21 | ######################################## 22 | variable "revoke_create_public" { 23 | type = bool 24 | description = "Enable/Disable the revoke command for create table in schema public" 25 | default = true 26 | } 27 | 28 | variable "create_database" { 29 | type = bool 30 | description = "Enable/Disable the creation of the database. Except for local tests or Cloud environment, the database creation is not possible. Disabled by default" 31 | default = false 32 | } 33 | 34 | variable "inputs" { 35 | type = object({ 36 | db_schema_name = string 37 | db_name = string 38 | db_admin = string 39 | extensions = list(string) 40 | db_roles = list(object({ 41 | id = string 42 | role = string 43 | inherit = bool 44 | login = bool 45 | validity = string 46 | privileges = list(string) 47 | createrole = bool 48 | })) 49 | db_grants = list(object({ 50 | object_type = string 51 | privileges = list(string) 52 | role = string 53 | owner_role = string 54 | objects = list(string) 55 | grant_option = bool 56 | })) 57 | }) 58 | description = "The Inputs parameters for objects to create inside the database" 59 | default = null 60 | } 61 | 62 | variable "default_superusers_list" { 63 | type = list(string) 64 | description = "List the super-users. By default, it's the postgres user." 65 | default = ["postgres"] 66 | } 67 | -------------------------------------------------------------------------------- /create-database/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.0.4" 3 | required_providers { 4 | null = { 5 | source = "hashicorp/null" 6 | version = ">= 3.0.0" 7 | } 8 | postgresql = { 9 | source = "cyrilgdn/postgresql" 10 | version = ">= 1.15.0" 11 | } 12 | random = { 13 | source = "hashicorp/random" 14 | version = ">= 3.0.0" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /create-users/README.md: -------------------------------------------------------------------------------- 1 | ## Requirements 2 | 3 | | Name | Version | 4 | |------|---------| 5 | | [terraform](#requirement\_terraform) | >= 1.0.4 | 6 | | [null](#requirement\_null) | >= 3.0.0 | 7 | | [postgresql](#requirement\_postgresql) | >= 1.15.0 | 8 | | [random](#requirement\_random) | >= 3.0.0 | 9 | 10 | ## Providers 11 | 12 | | Name | Version | 13 | |------|---------| 14 | | [null](#provider\_null) | >= 3.0.0 | 15 | | [postgresql](#provider\_postgresql) | >= 1.15.0 | 16 | 17 | ## Modules 18 | 19 | No modules. 20 | 21 | ## Resources 22 | 23 | | Name | Type | 24 | |------|------| 25 | | [null_resource.pgusers_postprocessing_playbook](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource | 26 | | [postgresql_role.app_users](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | 27 | 28 | ## Inputs 29 | 30 | | Name | Description | Type | Default | Required | 31 | |------|-------------|------|---------|:--------:| 32 | | [db\_users](#input\_db\_users) | The Inputs parameters for objects to create inside the database |
list(object({
name = string
inherit = bool
login = bool
membership = list(string)
validity = string
connection_limit = number
createrole = bool
})
)
| `null` | no | 33 | | [dbhost](#input\_dbhost) | The RDS DB Hostname | `string` | n/a | yes | 34 | | [dbport](#input\_dbport) | The RDS DB Port | `string` | n/a | yes | 35 | | [passwords](#input\_passwords) | Map of credentials, = | `map(string)` | `{}` | no | 36 | | [pgadmin\_user](#input\_pgadmin\_user) | The RDS Master username | `string` | n/a | yes | 37 | | [postprocessing\_playbook\_params](#input\_postprocessing\_playbook\_params) | params for postprocessing playbook |
object({
enable = bool
db_name = string
extra_envs = map(string)
shell_name = string
refresh_passwords = list(string)
})
|
{
"db_name": "",
"enable": false,
"extra_envs": {},
"refresh_passwords": [],
"shell_name": ""
}
| no | 38 | 39 | ## Outputs 40 | 41 | | Name | Description | 42 | |------|-------------| 43 | | [db\_users](#output\_db\_users) | The list of users created by the module | 44 | -------------------------------------------------------------------------------- /create-users/db_users.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Creation of users. a user is a role with 3 | # a permission to log in. 4 | ######################################## 5 | resource "postgresql_role" "app_users" { 6 | for_each = { for tuple in var.db_users : tuple.name => tuple } 7 | 8 | name = each.value.name 9 | login = each.value.login 10 | roles = each.value.membership 11 | inherit = each.value.inherit 12 | valid_until = each.value.validity 13 | encrypted_password = true 14 | password = var.passwords[each.key] 15 | skip_drop_role = false 16 | skip_reassign_owned = false 17 | create_role = lookup(each.value, "createrole", false) 18 | connection_limit = each.value.connection_limit 19 | search_path = lookup(each.value, "search_path", null) 20 | } 21 | 22 | ####################################### 23 | # modify postgres app_users (previously created) password 24 | # and update the corresponding parameter store value 25 | ######################################## 26 | locals { 27 | postprocessing_users = var.postprocessing_playbook_params["enable"] ? var.db_users : [] 28 | } 29 | 30 | resource "null_resource" "pgusers_postprocessing_playbook" { 31 | depends_on = [postgresql_role.app_users] 32 | 33 | for_each = { for tuple in local.postprocessing_users : tuple.name => tuple } 34 | 35 | triggers = { 36 | appuser_to_update = postgresql_role.app_users[each.key].name 37 | refresh_password = timestamp() 38 | } 39 | 40 | provisioner "local-exec" { 41 | when = create 42 | environment = merge({ 43 | DBUSER = self.triggers.appuser_to_update 44 | PGHOST = var.dbhost 45 | PGPORT = var.dbport 46 | PGUSER = var.pgadmin_user 47 | PGDATABASE = var.postprocessing_playbook_params["db_name"] 48 | SHELL_TO_EXECUTE = var.postprocessing_playbook_params["shell_name"] 49 | REFRESH_PASSWORD = contains(var.postprocessing_playbook_params["refresh_passwords"], each.key) || try(var.postprocessing_playbook_params["refresh_passwords"][0], "") == "all" 50 | }, 51 | var.postprocessing_playbook_params["extra_envs"] 52 | ) 53 | 54 | command = < merge(tuple, { "password" = var.passwords[tuple.name] }) } 4 | sensitive = true 5 | } 6 | -------------------------------------------------------------------------------- /create-users/variables.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Provider vars 3 | ######################################## 4 | variable "pgadmin_user" { 5 | type = string 6 | description = "The RDS Master username" 7 | } 8 | 9 | variable "dbhost" { 10 | type = string 11 | description = "The RDS DB Hostname" 12 | } 13 | 14 | variable "dbport" { 15 | type = string 16 | description = "The RDS DB Port" 17 | } 18 | 19 | ######################################## 20 | # passwords vars 21 | ######################################## 22 | variable "passwords" { 23 | type = map(string) 24 | description = "Map of credentials, = " 25 | default = {} 26 | } 27 | 28 | ######################################## 29 | # Input vars for creating users inside database 30 | ######################################## 31 | variable "db_users" { 32 | type = list(object({ 33 | name = string 34 | inherit = bool 35 | login = bool 36 | membership = list(string) 37 | validity = string 38 | connection_limit = number 39 | createrole = bool 40 | }) 41 | ) 42 | description = "The Inputs parameters for objects to create inside the database" 43 | default = null 44 | } 45 | 46 | 47 | ######################################## 48 | # params used inside postprocessing playbook. 49 | # this playbook allows you to update in-fly the password and store it inside the secrets vault of your choice 50 | # for doing this, you need to : 51 | # enable : enable the postprocessing playbook. disable (false) by default. 52 | # db_name : the database name on which the user is created 53 | # shell_name : provide a shell that will be executed by the playbook. The playbook set environment variables : 54 | # - postgresql native environment variables : DBUSER, PGHOST, PGPORT, PGUSER, PGDATABASE 55 | # - any extra environment variables setted in extra_envs 56 | # extra_envs : a map containing extra environments variables that you want manipulate inside your shell. 57 | ######################################## 58 | variable "postprocessing_playbook_params" { 59 | description = "params for postprocessing playbook" 60 | type = object({ 61 | enable = bool 62 | db_name = string 63 | extra_envs = map(string) 64 | shell_name = string 65 | refresh_passwords = list(string) 66 | }) 67 | default = { 68 | enable = false 69 | db_name = "" 70 | extra_envs = {} 71 | shell_name = "" 72 | refresh_passwords = [] 73 | } 74 | } -------------------------------------------------------------------------------- /create-users/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.0.4" 3 | required_providers { 4 | null = { 5 | source = "hashicorp/null" 6 | version = ">= 3.0.0" 7 | } 8 | postgresql = { 9 | source = "cyrilgdn/postgresql" 10 | #source = "terraform-providers/postgresql" 11 | version = ">= 1.15.0" 12 | } 13 | random = { 14 | source = "hashicorp/random" 15 | version = ">= 3.0.0" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Use postgres/example user/password credentials 2 | version: '3.1' 3 | 4 | networks: 5 | unNetwork: 6 | driver: bridge 7 | 8 | 9 | services: 10 | 11 | db: 12 | image: postgres:13.4 13 | restart: on-failure 14 | networks: 15 | - unNetwork 16 | ports: 17 | - 5432:5432 18 | # volumes: 19 | # - ./postgres-data:/var/lib/postgresql/data 20 | environment: 21 | POSTGRES_PASSWORD: password 22 | 23 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | ## Examples 2 | 3 | |Example|UseCase| 4 | |-------|--------| 5 | |[simple-database](https://github.com/jparnaudeau/terraform-postgresql-database-admin/tree/master/examples/simple-database/README.md)|Demonstration How to create Database, Roles, and Grants objects.| 6 | |[create-users-on-existent-database](https://github.com/jparnaudeau/terraform-postgresql-database-admin/tree/master/examples/create-users-on-existent-database/README.md)|From an existent database, you can create several users. This usecase use a trivial postprocessing playbook for example. **DO NOT USE THIS PLAYBOOK IN PRODUCTION, IT's NOT SAFE.**| 7 | |[all-in-one](https://github.com/jparnaudeau/terraform-postgresql-database-admin/tree/master/examples/all-in-one/README.md)|Demonstration How to create Database, Roles, Users in one phase. This usecase use a postprocessing playbook that generate passwords, set password for each user, and store the password in the parameterStore into an AWS Account.| 8 | |[full-rds-example](https://github.com/jparnaudeau/terraform-postgresql-database-admin/tree/master/examples/full-rds-example/README.md)|Demonstration for other features covered by the module : Demonstrate an another postprocessing playbook that generate passwords into AWS SecretsManager, deploy the `pgaudit` extension for real-time monitoring, illustrates the `least privileges access` with deployment of roles & users, simulates a SOC with an `elasticsearch` that indexes rds logs.| 9 | -------------------------------------------------------------------------------- /examples/all-in-one/.envrc: -------------------------------------------------------------------------------- 1 | export PGPASSWORD=password 2 | export AWS_PROFILE=ippon-sandbox 3 | export AWS_DEFAULT_REGION=eu-west-3 4 | -------------------------------------------------------------------------------- /examples/all-in-one/README.md: -------------------------------------------------------------------------------- 1 | # all-in-one 2 | 3 | This example shows a complete real case. In this example, we will : 4 | 5 | * create the database, create the admin, write and readOnly roles. 6 | 7 | * create 3 users 8 | 9 | * generate passwords, update the password for each user, and store it into AWS ParameterStore. 10 | 11 | 12 | ## Prepare you postgresql provider 13 | 14 | ```hcl 15 | 16 | ####################################### 17 | # Define Providers pgadm & pgmgm for postgresql 18 | ####################################### 19 | provider "postgresql" { 20 | alias = "pgadm" 21 | host = var.dbhost 22 | port = var.dbport 23 | username = var.pgadmin_user 24 | sslmode = var.sslmode 25 | connect_timeout = var.connect_timeout 26 | superuser = var.superuser 27 | expected_version = var.expected_version 28 | } 29 | 30 | provider "postgresql" { 31 | alias = "pgmgm" 32 | host = var.dbhost 33 | port = var.dbport 34 | database = var.inputs["db_name"] 35 | username = var.pgadmin_user 36 | sslmode = var.sslmode 37 | connect_timeout = var.connect_timeout 38 | superuser = var.superuser 39 | expected_version = var.expected_version 40 | } 41 | 42 | ``` 43 | 44 | Note : the password of the `var.pgadmin_user` are stored in the environment variable **PGPASSWORD** that you must setted before the terraform plan or apply. 45 | 46 | ## Prepare fake passwords in ParameterStore 47 | 48 | ```hcl 49 | 50 | #################################################################### 51 | # for each users defined in var.inputs, create 52 | # - a parameter in parameterStore for storing the user (path : /_user) 53 | # - create a fake password for this user and 54 | # - save it into parameterStore at /_password 55 | # 56 | # we do this for having only one case to manage in the postprocessing shell : 57 | # we update systematically the value of the parameter 58 | #################################################################### 59 | locals { 60 | namespace = format("/%s/%s",var.environment,var.inputs["db_name"]) 61 | tags = merge(var.tags,{"environment" = var.environment}) 62 | } 63 | 64 | # the ssm parameters for storing username 65 | module "ssm_db_users" { 66 | source = "jparnaudeau/ssm-parameter/aws" 67 | version = "1.0.0" 68 | 69 | for_each = { for user in var.inputs["db_users"] : user.name => user } 70 | 71 | namespace = local.namespace 72 | tags = local.tags 73 | 74 | parameters = { 75 | format("%s_user", each.key) = { 76 | description = "db user param value rds database" 77 | value = each.key 78 | overwrite = false 79 | }, 80 | } 81 | } 82 | 83 | # the random passwords for each user 84 | resource "random_password" "passwords" { 85 | for_each = { for user in var.inputs["db_users"] : user.name => user } 86 | 87 | length = 16 88 | special = true 89 | upper = true 90 | lower = true 91 | min_upper = 1 92 | number = true 93 | min_numeric = 1 94 | min_special = 3 95 | override_special = "@#%&?" 96 | } 97 | 98 | # the ssm parameters for storing password of each user 99 | module "fake_user_password" { 100 | source = "jparnaudeau/ssm-parameter/aws" 101 | version = "1.0.0" 102 | 103 | for_each = { for user in var.inputs["db_users"] : user.name => user } 104 | 105 | namespace = local.namespace 106 | tags = local.tags 107 | 108 | parameters = { 109 | format("%s_password", each.key) = { 110 | description = "db user param value rds database" 111 | value = random_password.passwords[each.key].result 112 | type = "SecureString" 113 | overwrite = false 114 | }, 115 | } 116 | } 117 | 118 | ``` 119 | 120 | Notes : 121 | 122 | * here, we use an another submodule `ssm-parameter` that creates parameter in the parameterStore. Don't forget to set yours AWS Credentials by setting the variable **AWS_PROFILE**. 123 | * for each user, we create 2 parameters in the parameterStore : `/_user` and `/_password` 124 | * by creating the parameters before the postprocessing playbook, it simplifies the shell executed by the playbook. 125 | 126 | 127 | ## call the module to initialize the database and all objects (roles,grants) 128 | 129 | ```hcl 130 | 131 | ######################################## 132 | # Initialize the database and the objects 133 | # (roles & grants), the default privileges 134 | ######################################## 135 | module "initdb" { 136 | 137 | source = "jparnaudeau/database-admin/postgresql//create-database" 138 | version = "2.0.2" 139 | 140 | 141 | # set the provider 142 | providers = { 143 | postgresql = postgresql.pgadm 144 | } 145 | 146 | # targetted rds 147 | pgadmin_user = var.pgadmin_user 148 | dbhost = var.dbhost 149 | dbport = var.dbport 150 | 151 | # input parameters for creating database & objects inside database 152 | create_database = true 153 | inputs = var.inputs 154 | } 155 | 156 | 157 | ``` 158 | 159 | 160 | ## call the module to create the users and use the postprocessing playbook to store passwords in parameterStore. 161 | 162 | ```hcl 163 | 164 | ######################################### 165 | # Create the users inside the database 166 | ######################################### 167 | # AWS Region 168 | data "aws_region" "current" {} 169 | 170 | module "create_users" { 171 | source = "jparnaudeau/database-admin/postgresql//create-users" 172 | version = "2.0.2" 173 | 174 | # need that all objects, managed inside the module "initdb", are created 175 | depends_on = [module.initdb] 176 | 177 | # set the provider 178 | providers = { 179 | postgresql = postgresql.pgadm 180 | } 181 | 182 | # targetted rds 183 | pgadmin_user = var.pgadmin_user 184 | dbhost = var.dbhost 185 | dbport = var.dbport 186 | 187 | # input parameters for creating users inside database 188 | db_users = var.inputs["db_users"] 189 | 190 | # set passwords 191 | passwords = { for user in var.inputs["db_users"] : user.name => random_password.passwords[user.name].result } 192 | 193 | # set postprocessing playbook 194 | postprocessing_playbook_params = { 195 | enable = true 196 | db_name = var.inputs["db_name"] 197 | extra_envs = { 198 | REGION = data.aws_region.current.name 199 | ENVIRONMENT = var.environment 200 | } 201 | refresh_passwords = ["all"] 202 | shell_name = "./gen-password-in-ps.sh" 203 | } 204 | 205 | } 206 | 207 | ``` 208 | 209 | Note : note the "depends_on" on this module : the initialization of the database need to be done before creating users. 210 | 211 | 212 | ## Define the inputs 213 | 214 | in the `terraform.tfvars`, you could find : 215 | 216 | ```hcl 217 | 218 | # database and objects creation 219 | inputs = { 220 | 221 | # parameters used for creating database 222 | db_schema_name = "public" 223 | db_name = "mydatabase" 224 | db_admin = "app_admin_role" #owner of the database 225 | 226 | # install extensions if needed 227 | extensions = [] 228 | 229 | # https://aws.amazon.com/blogs/database/managing-postgresql-users-and-roles/ 230 | # 1) create Roles that are a set of permissions (named grant inside postgresql) 231 | # 2) set grants on role 232 | # 3) create User (these users have username/password) that inherits their permissions from the role. 233 | # You can retrieve the password from the parameterStore. cf shell gen-password-in-ps.sh 234 | 235 | # ---------------------------------- ROLES ------------------------------------------------------------------------------------ 236 | # In this example, we create 3 roles 237 | # - "app_admin_role" will be the role used for creation, deletion, grant operations on objects, especially for tables. 238 | # - "app_write_role" for write operations. If you have a backend that insert lines into tables, it will used a user that inherits permissions from it. 239 | # - "app_readonly_role" for readonly operations. 240 | # Notes : 241 | # - "write" role does not have the permissions to create table. 242 | # - the 'createrole' field is a boolean that provides a way to create other roles and put grants on it. Be carefull when you give this permission. 243 | db_roles = [ 244 | { id = "admin", role = "app_admin_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE", "CREATE"], createrole = true }, 245 | { id = "readonly", role = "app_readonly_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 246 | { id = "write", role = "app_write_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 247 | ], 248 | 249 | # ---------------------------------- GRANT PERMISSIONS ON ROLES ------------------------------------------------------------------------------------ 250 | # you could find the available privileges on official postgresql doc : https://www.postgresql.org/docs/13/ddl-priv.html 251 | # Notes : 252 | # - "role" corresponds to the role on which the grants will be applied. 253 | # - "owner_role" is the role used to create grants on "role". 254 | # - object_type = "type" is used only for default privileges 255 | # - objects = [] means "all". Use this attribut if you want to allow permissions on specific tables, functions, procedures, sequences. Concept of Least privileges 256 | # - object_type = "type" is used only for default privileges 257 | 258 | db_grants = [ 259 | # role app_admin_role : define grants to apply on db 'mydatabase', schema 'public' 260 | { object_type = "database", privileges = ["CREATE", "CONNECT", "TEMPORARY"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 261 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 262 | 263 | # role app_readonly_role : define grant to apply on db 'mydatabase', schema 'public' 264 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 265 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = true }, 266 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 267 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 268 | 269 | # role app_write_role : define grant to apply on db 'mydatabase', schema 'public' 270 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 271 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = true }, 272 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER", "INSERT", "UPDATE", "DELETE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 273 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 274 | { object_type = "function", privileges = ["EXECUTE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 275 | 276 | ], 277 | 278 | db_users = [ 279 | { name = "readonly", inherit = true, login = true, membership = ["app_readonly_role"], validity = "infinity", connection_limit = -1, createrole = false }, 280 | { name = "backend", inherit = true, login = true, membership = ["app_write_role"], validity = "infinity", connection_limit = -1, createrole = false }, 281 | { name = "admin", inherit = true, login = true, membership = ["app_admin_role"], validity = "infinity", connection_limit = -1, createrole = false }, 282 | ] 283 | } 284 | 285 | # set tags & environment 286 | environment = "test" 287 | tags = { 288 | createdBy = "terraform" 289 | } 290 | 291 | ``` 292 | 293 | ## Allowed UseCase Matrix 294 | 295 | Based on those inputs, this is the matrix providing permissions for the different users defined in this example : 296 | 297 | |DDB User|Login on database|Create/Drop Database|Create/Drop Schema|Create/Drop Role|Create/Drop Table|Insert/Delete items in Table|Select on table| 298 | |--------|-----------------|--------------------|------------------|----------------|-----------------|----------------------------|---------------| 299 | |postgres|OK | OK| OK| OK| OK| OK| OK| 300 | |admin | OK| OK| OK|OK (By default can't create role)|OK|OK|OK| 301 | |backend |OK |OK (Permission denied)|OK (Permission denied)|OK (Permission denied)|OK (Permission denied)|OK|OK| 302 | |readonly|OK |OK (Permission denied)|OK (Permission denied)|OK (Permission denied)|OK (Permission denied)|OK (Permission denied)|OK| 303 | 304 | Note : you can allow the user `admin` to create role, by using the field **createrole** in the **db_users** declaration. 305 | 306 | 307 | 308 | ## script used by the postprocessing playbook 309 | 310 | The postprocessing playbook generates a set of environments variables : 311 | 312 | * Native postgresql environment variables : PGHOST, PGPORT, PGUSER, PGDATABASE. So you can use it inside your shell. 313 | * a variable `DBUSER` representing the user that we want update his password. 314 | * a variable `REFRESH_PASSWORD` if you want control the execution of the update. 315 | * all extra variables defined in `extra_envs`. 316 | 317 | ``` 318 | 319 | #!/bin/bash 320 | 321 | if [ "${REFRESH_PASSWORD}" == "true" ] 322 | then 323 | 324 | # generate a random password 325 | USERPWD=$(openssl rand -base64 16 |tr -d '[;+%$!/]'); 326 | 327 | # generate the parameterStore path 328 | USER_PWD_PATH="/${ENVIRONMENT}/${PGDATABASE}/${DBUSER}_password" 329 | 330 | # Alter user inside postgresql database 331 | psql -c "ALTER USER $DBUSER WITH PASSWORD '$USERPWD'"; 332 | 333 | # Alter Secret Storage 334 | aws ssm put-parameter --name $USER_PWD_PATH --type SecureString --overwrite --value $USERPWD --region $REGION; 335 | 336 | fi 337 | 338 | exit 0 339 | 340 | ``` 341 | 342 | Notes : 343 | 344 | * By using a direct call on the api aws ssm put-parameter (and not using the terraform resource), we assure that the password is not stored into clear text in the tfstate. 345 | * note the use of the variable `REGION`, setted in the map extra_envs in the main.tf. 346 | 347 | ## To summarize 348 | 349 | launch `terraform apply --auto-approve` 350 | 351 | ``` 352 | 353 | Outputs: 354 | 355 | affected_schema = "public" 356 | connect_string = "psql -h localhost -p 5432 -U app_admin_role -d mydatabase" 357 | created_database = "mydatabase" 358 | created_roles = [ 359 | "app_admin_role", 360 | "app_readonly_role", 361 | "app_write_role", 362 | ] 363 | db_users = { 364 | "admin" = { 365 | "connect_command" = "psql -h localhost -p 5432 -U admin -d mydatabase -W" 366 | "parameter_store_user" = "/test/mydatabase/admin_user" 367 | "parameter_store_user_password" = "/test/mydatabase/admin_password" 368 | } 369 | "backend" = { 370 | "connect_command" = "psql -h localhost -p 5432 -U backend -d mydatabase -W" 371 | "parameter_store_user" = "/test/mydatabase/backend_user" 372 | "parameter_store_user_password" = "/test/mydatabase/backend_password" 373 | } 374 | "readonly" = { 375 | "connect_command" = "psql -h localhost -p 5432 -U readonly -d mydatabase -W" 376 | "parameter_store_user" = "/test/mydatabase/readonly_user" 377 | "parameter_store_user_password" = "/test/mydatabase/readonly_password" 378 | } 379 | } 380 | 381 | ``` 382 | 383 | 384 | Connect with the admin user to create table 385 | 386 | ``` 387 | 388 | psql -h localhost -p 5432 -U admin -d mydatabase -W 389 | Password: 390 | 391 | psql (12.8 (Ubuntu 12.8-0ubuntu0.20.04.1), server 13.4 (Debian 13.4-4.pgdg110+1)) 392 | WARNING: psql major version 12, server major version 13. 393 | Some psql features might not work. 394 | Type "help" for help. 395 | 396 | mydatabase=> create table table1(col1 TEXT); 397 | CREATE TABLE 398 | mydatabase=> \q 399 | 400 | ``` 401 | 402 | Connect with the backend user to insert line into this table 403 | 404 | ``` 405 | 406 | psql -h localhost -p 5432 -U backend -d mydatabase -W 407 | Password: 408 | 409 | psql (12.8 (Ubuntu 12.8-0ubuntu0.20.04.1), server 13.4 (Debian 13.4-4.pgdg110+1)) 410 | WARNING: psql major version 12, server major version 13. 411 | Some psql features might not work. 412 | Type "help" for help. 413 | 414 | mydatabase=> insert into table1 values ('first line'); 415 | ERROR: permission denied for table table1 416 | 417 | ``` 418 | 419 | * It's normal, we need to re-execute the terraform apply to propage permissions on this new table 420 | * be carefull to pass the refresh_passwords to [""] if you don't want regenerate new password. 421 | 422 | 423 | ``` 424 | 425 | terraform apply --auto-approve 426 | 427 | ... 428 | 429 | # Test with backend user 430 | psql -h localhost -p 5432 -U backend -d mydatabase -W 431 | 432 | Password: 433 | psql (12.8 (Ubuntu 12.8-0ubuntu0.20.04.1), server 13.4 (Debian 13.4-4.pgdg110+1)) 434 | WARNING: psql major version 12, server major version 13. 435 | Some psql features might not work. 436 | Type "help" for help. 437 | 438 | mydatabase=> insert into table1 values ('first line'); 439 | INSERT 0 1 440 | 441 | ``` 442 | 443 | Test the permissions for readonly user : 444 | 445 | ``` 446 | 447 | psql -h localhost -p 5432 -U readonly -d mydatabase -W 448 | Password: 449 | psql (12.8 (Ubuntu 12.8-0ubuntu0.20.04.1), server 13.4 (Debian 13.4-4.pgdg110+1)) 450 | WARNING: psql major version 12, server major version 13. 451 | Some psql features might not work. 452 | Type "help" for help. 453 | 454 | mydatabase=> select * from table1; 455 | col1 456 | ------------ 457 | first line 458 | (1 row) 459 | 460 | mydatabase=> create table table2(col1 TEXT); 461 | ERROR: permission denied for schema public 462 | LINE 1: create table table2(col1 TEXT); 463 | 464 | ``` 465 | -------------------------------------------------------------------------------- /examples/all-in-one/gen-password-in-ps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "${REFRESH_PASSWORD}" == "true" ] 4 | then 5 | 6 | # generate a random password 7 | USERPWD=$(openssl rand -base64 16 |tr -d '[;+%$!/]'); 8 | 9 | # generate the parameterStore path 10 | USER_PWD_PATH="/${ENVIRONMENT}/${PGDATABASE}/${DBUSER}_password" 11 | 12 | # Alter user inside postgresql database 13 | psql -c "ALTER USER $DBUSER WITH PASSWORD '$USERPWD'"; 14 | 15 | # Alter Secret Storage 16 | aws ssm put-parameter --name $USER_PWD_PATH --type SecureString --overwrite --value $USERPWD --region $REGION; 17 | 18 | fi 19 | 20 | exit 0 21 | -------------------------------------------------------------------------------- /examples/all-in-one/main.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Initialize the database and the objects 3 | # (roles & grants), the default privileges 4 | ######################################## 5 | module "initdb" { 6 | 7 | source = "../../create-database" 8 | 9 | # set the provider 10 | providers = { 11 | postgresql = postgresql.pgadm 12 | } 13 | 14 | # targetted rds 15 | pgadmin_user = var.pgadmin_user 16 | dbhost = var.dbhost 17 | dbport = var.dbport 18 | 19 | # input parameters for creating database & objects inside database 20 | create_database = true 21 | inputs = var.inputs 22 | } 23 | 24 | #################################################################### 25 | # for each users defined in var.inputs, create 26 | # - a parameter in parameterStore for storing the user (path : /_user) 27 | # - create a fake password for this user and 28 | # - save it into parameterStore at /_password 29 | # 30 | # we do this for having only one case to manage in the postprocessing shell : 31 | # we update systematically the value of the parameter 32 | #################################################################### 33 | locals { 34 | namespace = format("/%s/%s", var.environment, var.inputs["db_name"]) 35 | tags = merge(var.tags, { "environment" = var.environment }) 36 | } 37 | 38 | # the ssm parameters for storing username 39 | module "ssm_db_users" { 40 | source = "jparnaudeau/ssm-parameter/aws" 41 | version = "1.0.0" 42 | 43 | for_each = { for user in var.inputs["db_users"] : user.name => user } 44 | 45 | namespace = local.namespace 46 | tags = local.tags 47 | 48 | parameters = { 49 | format("%s_user", each.key) = { 50 | description = "db user param value rds database" 51 | value = each.key 52 | overwrite = false 53 | }, 54 | } 55 | } 56 | 57 | # the random passwords for each user 58 | resource "random_password" "passwords" { 59 | for_each = { for user in var.inputs["db_users"] : user.name => user } 60 | 61 | length = 16 62 | special = true 63 | upper = true 64 | lower = true 65 | min_upper = 1 66 | number = true 67 | min_numeric = 1 68 | min_special = 3 69 | override_special = "@#%&?" 70 | } 71 | 72 | # the ssm parameters for storing password of each user 73 | module "fake_user_password" { 74 | source = "jparnaudeau/ssm-parameter/aws" 75 | version = "1.0.0" 76 | 77 | for_each = { for user in var.inputs["db_users"] : user.name => user } 78 | 79 | namespace = local.namespace 80 | tags = local.tags 81 | 82 | parameters = { 83 | format("%s_password", each.key) = { 84 | description = "db user param value rds database" 85 | value = random_password.passwords[each.key].result 86 | type = "SecureString" 87 | overwrite = false 88 | }, 89 | } 90 | } 91 | 92 | ######################################### 93 | # Create the users inside the database 94 | ######################################### 95 | # AWS Region 96 | data "aws_region" "current" {} 97 | 98 | module "create_users" { 99 | source = "../../create-users" 100 | 101 | # need that all objects, managed inside the module "initdb", are created 102 | depends_on = [module.initdb] 103 | 104 | # set the provider 105 | providers = { 106 | postgresql = postgresql.pgadm 107 | } 108 | 109 | # targetted rds 110 | pgadmin_user = var.pgadmin_user 111 | dbhost = var.dbhost 112 | dbport = var.dbport 113 | 114 | # input parameters for creating users inside database 115 | db_users = var.inputs["db_users"] 116 | 117 | # set passwords 118 | passwords = { for user in var.inputs["db_users"] : user.name => random_password.passwords[user.name].result } 119 | 120 | # set postprocessing playbook 121 | postprocessing_playbook_params = { 122 | enable = true 123 | db_name = var.inputs["db_name"] 124 | extra_envs = { 125 | REGION = data.aws_region.current.name 126 | ENVIRONMENT = var.environment 127 | } 128 | refresh_passwords = ["all"] 129 | shell_name = "./gen-password-in-ps.sh" 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /examples/all-in-one/outputs.tf: -------------------------------------------------------------------------------- 1 | output "created_database" { 2 | description = "the name of the database created by the module" 3 | value = var.inputs["db_name"] 4 | } 5 | 6 | output "affected_schema" { 7 | description = "the name of the schema in which the db objects have been created by the module" 8 | value = var.inputs["db_schema_name"] 9 | } 10 | 11 | output "created_roles" { 12 | description = "The list of roles created by the module" 13 | value = [for obj_role in var.inputs["db_roles"] : obj_role["role"]] 14 | } 15 | 16 | output "connect_string" { 17 | description = "The connect string to use to connect on the database" 18 | value = format("psql -h %s -p %s -U %s -d %s", var.dbhost, var.dbport, var.inputs["db_admin"], var.inputs["db_name"]) 19 | } 20 | 21 | output "db_users" { 22 | description = "The list of users created by the module" 23 | value = { for user in var.inputs["db_users"] : 24 | user.name => { 25 | "parameter_store_user" = format("%s/%s_user", local.namespace, user.name), 26 | "parameter_store_user_password" = format("%s/%s_password", local.namespace, user.name), 27 | "connect_command" = format("psql -h %s -p %s -U %s -d %s -W", var.dbhost, var.dbport, user.name, var.inputs["db_name"]) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/all-in-one/providers.tf: -------------------------------------------------------------------------------- 1 | ####################################### 2 | # Define Providers pgadm & pgmgm for postgresql 3 | ####################################### 4 | provider "postgresql" { 5 | alias = "pgadm" 6 | host = var.dbhost 7 | port = var.dbport 8 | username = var.pgadmin_user 9 | sslmode = var.sslmode 10 | connect_timeout = var.connect_timeout 11 | superuser = var.superuser 12 | expected_version = var.expected_version 13 | } 14 | 15 | provider "postgresql" { 16 | alias = "pgmgm" 17 | host = var.dbhost 18 | port = var.dbport 19 | database = var.inputs["db_name"] 20 | username = var.pgadmin_user 21 | sslmode = var.sslmode 22 | connect_timeout = var.connect_timeout 23 | superuser = var.superuser 24 | expected_version = var.expected_version 25 | } 26 | 27 | 28 | ####################################### 29 | # Manage version of providers 30 | ####################################### 31 | terraform { 32 | required_version = ">= 1.0.4" 33 | required_providers { 34 | postgresql = { 35 | source = "cyrilgdn/postgresql" 36 | version = ">= 1.15.0" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/all-in-one/terraform.tfvars: -------------------------------------------------------------------------------- 1 | # provider connection infos 2 | pgadmin_user = "postgres" 3 | dbhost = "localhost" 4 | expected_version = "12.0.0" 5 | sslmode = "disable" 6 | 7 | # database and objects creation 8 | inputs = { 9 | 10 | # parameters used for creating database 11 | db_schema_name = "public" 12 | db_name = "mydatabase" 13 | db_admin = "app_admin_role" #owner of the database 14 | 15 | # install extensions if needed 16 | extensions = [] 17 | 18 | # https://aws.amazon.com/blogs/database/managing-postgresql-users-and-roles/ 19 | # 1) create Roles that are a set of permissions (named grant inside postgresql) 20 | # 2) set grants on role 21 | # 3) create User (these users have username/password) that inherits their permissions from the role. 22 | # You can retrieve the password from the parameterStore. cf shell gen-password-in-ps.sh 23 | 24 | # ---------------------------------- ROLES ------------------------------------------------------------------------------------ 25 | # In this example, we create 3 roles 26 | # - "app_admin_role" will be the role used for creation, deletion, grant operations on objects, especially for tables. 27 | # - "app_write_role" for write operations. If you have a backend that insert lines into tables, it will used a user that inherits permissions from it. 28 | # - "app_readonly_role" for readonly operations. 29 | # Notes : 30 | # - "write" role does not have the permissions to create table. 31 | # - the 'createrole' field is a boolean that provides a way to create other roles and put grants on it. Be carefull when you give this permission. 32 | db_roles = [ 33 | { id = "admin", role = "app_admin_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE", "CREATE"], createrole = true }, 34 | { id = "readonly", role = "app_readonly_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 35 | { id = "write", role = "app_write_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 36 | ], 37 | 38 | # ---------------------------------- GRANT PERMISSIONS ON ROLES ------------------------------------------------------------------------------------ 39 | # you could find the available privileges on official postgresql doc : https://www.postgresql.org/docs/13/ddl-priv.html 40 | # Notes : 41 | # - "role" corresponds to the role on which the grants will be applied. 42 | # - "owner_role" is the role used to create grants on "role". 43 | # - object_type = "type" is used only for default privileges 44 | # - objects = [] means "all". Use this attribut if you want to allow permissions on specific tables, functions, procedures, sequences. Concept of Least privileges 45 | # - object_type = "type" is used only for default privileges 46 | 47 | db_grants = [ 48 | # role app_admin_role : define grants to apply on db 'mydatabase', schema 'public' 49 | { object_type = "database", privileges = ["CREATE", "CONNECT", "TEMPORARY"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 50 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 51 | 52 | # role app_readonly_role : define grant to apply on db 'mydatabase', schema 'public' 53 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 54 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = true }, 55 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 56 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 57 | 58 | # role app_write_role : define grant to apply on db 'mydatabase', schema 'public' 59 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 60 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = true }, 61 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER", "INSERT", "UPDATE", "DELETE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 62 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 63 | { object_type = "function", privileges = ["EXECUTE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 64 | 65 | ], 66 | 67 | db_users = [ 68 | { name = "readonly", inherit = true, login = true, membership = ["app_readonly_role"], validity = "infinity", connection_limit = -1, createrole = false }, 69 | { name = "backend", inherit = true, login = true, membership = ["app_write_role"], validity = "infinity", connection_limit = -1, createrole = false }, 70 | { name = "admin", inherit = true, login = true, membership = ["app_admin_role"], validity = "infinity", connection_limit = -1, createrole = false }, 71 | ] 72 | 73 | } 74 | 75 | # set tags & environment 76 | environment = "test" 77 | tags = { 78 | createdBy = "terraform" 79 | } 80 | 81 | -------------------------------------------------------------------------------- /examples/all-in-one/variables.tf: -------------------------------------------------------------------------------- 1 | variable "dbhost" { 2 | type = string 3 | default = "localhost" 4 | description = "The database host" 5 | } 6 | 7 | variable "dbport" { 8 | type = number 9 | default = 5432 10 | description = "The database port" 11 | } 12 | 13 | variable "pgadmin_user" { 14 | type = string 15 | description = "The RDS user to used for creating/managing other user in the database." 16 | } 17 | 18 | variable "sslmode" { 19 | type = string 20 | description = "Set the priority for an SSL connection to the server. Valid values are [disable,require,verify-ca,verify-full]" 21 | default = "require" 22 | } 23 | 24 | variable "connect_timeout" { 25 | type = number 26 | description = "Maximum wait for connection, in seconds. The default is 180s. Zero or not specified means wait indefinitely." 27 | default = 180 28 | } 29 | 30 | variable "superuser" { 31 | type = bool 32 | description = "Should be set to false if the user to connect is not a PostgreSQL superuser" 33 | default = false 34 | } 35 | 36 | variable "expected_version" { 37 | type = string 38 | description = "Specify a hint to Terraform regarding the expected version that the provider will be talking with. This is a required hint in order for Terraform to talk with an ancient version of PostgreSQL. This parameter is expected to be a PostgreSQL Version or current. Once a connection has been established, Terraform will fingerprint the actual version. Default: 9.0.0" 39 | default = "9.0.0" 40 | } 41 | 42 | variable "inputs" { 43 | type = any 44 | description = "The map containing all elements for creating objects inside database" 45 | default = null 46 | } 47 | 48 | variable "tags" { 49 | type = map(string) 50 | description = "a map of string used to tag entries in AWS Secrets Manager" 51 | default = {} 52 | } 53 | 54 | variable "environment" { 55 | type = string 56 | description = "environment name" 57 | default = "sta" 58 | } -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/.envrc: -------------------------------------------------------------------------------- 1 | export PGPASSWORD=password 2 | export AWS_PROFILE=ippon-sandbox 3 | export AWS_DEFAULT_REGION=eu-west-3 4 | -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/.json: -------------------------------------------------------------------------------- 1 | {password: MMWQ4brgDxslppbVL0Sclw==} 2 | -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/README.md: -------------------------------------------------------------------------------- 1 | # create-users-on-existent-database 2 | 3 | This example shows you how to create users after a clean initialisation of a database i.e, with roles created in the example [simple-database](https://github.com/jparnaudeau/terraform-postgresql-database-admin/tree/master/examples/simple-database). 4 | 5 | You can find a complete example for creating database, roles and users in the example [all-in-one](https://github.com/jparnaudeau/terraform-postgresql-database-admin/tree/master/examples/all-in-one). 6 | 7 | This example provide a first illustration to "How to set password" with the postprocessing playbook. 8 | 9 | ## Prepare you postgresql provider 10 | 11 | ```hcl 12 | 13 | ####################################### 14 | # Define Providers pgadm & pgmgm for postgresql 15 | ####################################### 16 | provider "postgresql" { 17 | alias = "pgadm" 18 | host = var.dbhost 19 | port = var.dbport 20 | username = var.pgadmin_user 21 | sslmode = var.sslmode 22 | connect_timeout = var.connect_timeout 23 | superuser = var.superuser 24 | expected_version = var.expected_version 25 | } 26 | 27 | provider "postgresql" { 28 | alias = "pgmgm" 29 | host = var.dbhost 30 | port = var.dbport 31 | database = var.inputs["db_name"] 32 | username = var.pgadmin_user 33 | sslmode = var.sslmode 34 | connect_timeout = var.connect_timeout 35 | superuser = var.superuser 36 | expected_version = var.expected_version 37 | } 38 | 39 | ``` 40 | 41 | Note : the password of the `var.pgadmin_user` are stored in the environment variable **PGPASSWORD** that you must setted before the terraform plan or apply. 42 | 43 | ## Call the module 44 | 45 | ```hcl 46 | 47 | ####################################### 48 | # Create Random Passwords for each user 49 | ####################################### 50 | resource "random_password" "passwords" { 51 | for_each = { for user in var.inputs["db_users"] : user.name => user } 52 | 53 | length = 16 54 | special = true 55 | upper = true 56 | lower = true 57 | min_upper = 1 58 | number = true 59 | min_numeric = 1 60 | min_special = 3 61 | override_special = "@#%&?" 62 | } 63 | 64 | 65 | ######################################### 66 | # Create the users inside the database 67 | ######################################### 68 | module "create_users" { 69 | 70 | source = "jparnaudeau/database-admin/postgresql//create-users" 71 | version = "2.0.0" 72 | 73 | # set the provider 74 | providers = { 75 | postgresql = postgresql.pgadm 76 | } 77 | 78 | # targetted rds 79 | pgadmin_user = var.pgadmin_user 80 | dbhost = var.dbhost 81 | dbport = var.dbport 82 | 83 | # input parameters for creating users inside database 84 | db_users = var.inputs["db_users"] 85 | 86 | # set passwords 87 | passwords = { for user in var.inputs["db_users"] : user.name => random_password.passwords[user.name].result } 88 | 89 | # set postprocessing playbook 90 | postprocessing_playbook_params = var.postprocessing_playbook_params 91 | 92 | } 93 | 94 | 95 | ``` 96 | 97 | Note : we use terraform resource `random_password` to initialize passwords, but the real passwords are setted by the postprocessing playbook. So even if the value of random_password are in clear text in the tfstate, the real passwords are not stored in the tfstate. 98 | 99 | 100 | ## Define the inputs 101 | 102 | in the `terraform.tfvars`, you could find : 103 | 104 | ```hcl 105 | 106 | inputs = { 107 | 108 | # ---------------------------------- USER ------------------------------------------------------------------------------------ 109 | # finally, we create : 110 | # - a human user with the readonly permission and an expiration date (for troubelshooting by example) 111 | # - a user for a reporting application that requires only readonly permissions 112 | # - a user for a backend application that requires write permissions 113 | # 114 | # Regarding passwords, it's the shell "gen-password.sh" executed in the postprocessing playbook that in charge to set password for each user. 115 | db_users = [ 116 | { name = "audejavel", inherit = true, login = true, membership = ["app_readonly_role"], validity = "2021-12-31 00:00:00+00", connection_limit = -1, createrole = false }, 117 | { name = "reporting", inherit = true, login = true, membership = ["app_readonly_role"], validity = "infinity", connection_limit = -1, createrole = false }, 118 | { name = "backend", inherit = true, login = true, membership = ["app_write_role"], validity = "infinity", connection_limit = -1, createrole = false }, 119 | ] 120 | 121 | } 122 | 123 | ``` 124 | 125 | # Define the passwords with the postprocessing playbook 126 | 127 | in the `terraform.tfvars`, you could find : 128 | 129 | ```hcl 130 | 131 | # for post processing 132 | postprocessing_playbook_params = { 133 | enable = true 134 | db_name = "mydatabase" 135 | extra_envs = { 136 | REGION="paris" 137 | } 138 | refresh_passwords = ["all"] 139 | shell_name = "./gen-password.sh" 140 | } 141 | 142 | ``` 143 | 144 | The different parameters available in the object `postprocessing_playbook_params` are : 145 | 146 | * **enable** : you need to enable the postprocessing playbook execution. If by example, you prepare passwords in a secure way, by example in an encrypted file, you can use a terraform datasource to read this file (see this [post](https://blog.gruntwork.io/a-comprehensive-guide-to-managing-secrets-in-your-terraform-code-1d586955ace1) ), you can pass directly the passwords into the module without the need to execute the postprocessing playbook. Otherwise, enable it. 147 | * **db_name** : set the name of the database in which the users are related. 148 | * **extra_envs** : you can pass extra environment variables that are available inside your script. 149 | * **refresh_passwords** : you can force the execution of the postprocessing playbook for particular passwords. Just set in this field, the list of users for which you want a new password. In this case, a variable **REFRESH_PASSWORD** will be setted to `true`. Keep `all` if you want systematically regenerate new password for each user. 150 | * **shell_name** : it's your responsability to write a shell that generate passwords, update the user in the postgresql database, and store it in a safe place. 151 | 152 | 153 | # a dummy script used by the postprocessing playbook 154 | 155 | The postprocessing playbook put the native postgresql environment variables : DBUSER, PGHOST, PGPORT, PGUSER, PGDATABASE. So you can use it inside your shell. 156 | 157 | ``` 158 | 159 | #!/bin/bash 160 | 161 | 162 | if [ "${REFRESH_PASSWORD}" == "true" ] 163 | then 164 | 165 | # generate a random password 166 | USERPWD=$(openssl rand -base64 16 |tr -d '[;+%$!/]'); 167 | 168 | # Alter user inside postgresql database 169 | psql -c "ALTER USER $DBUSER WITH PASSWORD '$USERPWD'"; 170 | 171 | # Alter Secret Storage 172 | echo "{password: $USERPWD}" > ./$DBUSER.json 173 | 174 | fi 175 | 176 | exit 0 177 | 178 | ``` 179 | 180 | As you can see, we generate a random password and store the password in a file !! DO NOT DO THIS IN PRODUCTION !!. You can find a real secure script in the [all-in-one](https://github.com/jparnaudeau/terraform-postgresql-database-admin/tree/master/examples/all-in-one) example. -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/gen-password.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | if [ "${REFRESH_PASSWORD}" == "true" ] 5 | then 6 | 7 | # generate a random password 8 | USERPWD=$(openssl rand -base64 16 |tr -d '[;+%$!/]'); 9 | 10 | # Alter user inside postgresql database 11 | psql -c "ALTER USER $DBUSER WITH PASSWORD '$USERPWD'"; 12 | 13 | # Alter Secret Storage 14 | echo "{password: $USERPWD}" > ./$DBUSER.json 15 | 16 | fi 17 | 18 | exit 0 19 | -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/main.tf: -------------------------------------------------------------------------------- 1 | ####################################### 2 | # Create Random Passwords for each user 3 | ####################################### 4 | resource "random_password" "passwords" { 5 | for_each = { for user in var.inputs["db_users"] : user.name => user } 6 | 7 | length = 16 8 | special = true 9 | upper = true 10 | lower = true 11 | min_upper = 1 12 | number = true 13 | min_numeric = 1 14 | min_special = 3 15 | override_special = "@#%&?" 16 | } 17 | 18 | 19 | ######################################### 20 | # Create the users inside the database 21 | ######################################### 22 | module "create_users" { 23 | source = "../../create-users" 24 | 25 | 26 | # set the provider 27 | providers = { 28 | postgresql = postgresql.pgadm 29 | } 30 | 31 | # targetted rds 32 | pgadmin_user = var.pgadmin_user 33 | dbhost = var.dbhost 34 | dbport = var.dbport 35 | 36 | # input parameters for creating users inside database 37 | db_users = var.inputs["db_users"] 38 | 39 | # set passwords 40 | passwords = { for user in var.inputs["db_users"] : user.name => random_password.passwords[user.name].result } 41 | 42 | # set postprocessing playbook 43 | postprocessing_playbook_params = var.postprocessing_playbook_params 44 | 45 | } 46 | -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/providers.tf: -------------------------------------------------------------------------------- 1 | ####################################### 2 | # Define Providers pgadm & pgmgm for postgresql 3 | ####################################### 4 | provider "postgresql" { 5 | alias = "pgadm" 6 | host = var.dbhost 7 | port = var.dbport 8 | username = var.pgadmin_user 9 | sslmode = var.sslmode 10 | connect_timeout = var.connect_timeout 11 | superuser = var.superuser 12 | expected_version = var.expected_version 13 | } 14 | 15 | provider "postgresql" { 16 | alias = "pgmgm" 17 | host = var.dbhost 18 | port = var.dbport 19 | database = var.inputs["db_name"] 20 | username = var.pgadmin_user 21 | sslmode = var.sslmode 22 | connect_timeout = var.connect_timeout 23 | superuser = var.superuser 24 | expected_version = var.expected_version 25 | } 26 | 27 | 28 | ####################################### 29 | # Manage version of providers 30 | ####################################### 31 | terraform { 32 | required_version = ">= 1.0.4" 33 | required_providers { 34 | postgresql = { 35 | source = "cyrilgdn/postgresql" 36 | version = ">= 1.15.0" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/terraform.tfvars: -------------------------------------------------------------------------------- 1 | # provider connection infos 2 | pgadmin_user = "postgres" 3 | dbhost = "localhost" 4 | sslmode = "disable" 5 | 6 | # for post processing 7 | postprocessing_playbook_params = { 8 | enable = true 9 | db_name = "mydatabase" 10 | extra_envs = { 11 | REGION = "paris" 12 | } 13 | refresh_passwords = ["all"] 14 | shell_name = "./gen-password.sh" 15 | } 16 | 17 | inputs = { 18 | 19 | # ---------------------------------- USER ------------------------------------------------------------------------------------ 20 | # finally, we create : 21 | # - a human user with the readonly permission and an expiration date (for troubelshooting by example) 22 | # - a user for a reporting application that requires only readonly permissions 23 | # - a user for a backend application that requires write permissions 24 | # 25 | # Regarding passwords, it's the shell "gen-password.sh" executed in the postprocessing playbook that in charge to set password for each user. 26 | db_users = [ 27 | { name = "audejavel", inherit = true, login = true, membership = ["app_readonly_role"], validity = "2021-12-31 00:00:00+00", connection_limit = -1, createrole = false }, 28 | { name = "reporting", inherit = true, login = true, membership = ["app_readonly_role"], validity = "infinity", connection_limit = -1, createrole = false }, 29 | { name = "backend", inherit = true, login = true, membership = ["app_write_role"], validity = "infinity", connection_limit = -1, createrole = false }, 30 | ] 31 | 32 | } 33 | 34 | 35 | -------------------------------------------------------------------------------- /examples/create-users-on-existent-database/variables.tf: -------------------------------------------------------------------------------- 1 | variable "dbhost" { 2 | type = string 3 | default = "localhost" 4 | description = "The database host" 5 | } 6 | 7 | variable "dbport" { 8 | type = number 9 | default = 5432 10 | description = "The database port" 11 | } 12 | 13 | variable "pgadmin_user" { 14 | type = string 15 | description = "The RDS user to used for creating/managing other user in the database." 16 | } 17 | 18 | variable "sslmode" { 19 | type = string 20 | description = "Set the priority for an SSL connection to the server. Valid values are [disable,require,verify-ca,verify-full]" 21 | default = "require" 22 | } 23 | 24 | variable "connect_timeout" { 25 | type = number 26 | description = "Maximum wait for connection, in seconds. The default is 180s. Zero or not specified means wait indefinitely." 27 | default = 180 28 | } 29 | 30 | variable "superuser" { 31 | type = bool 32 | description = "Should be set to false if the user to connect is not a PostgreSQL superuser" 33 | default = false 34 | } 35 | 36 | variable "expected_version" { 37 | type = string 38 | description = "Specify a hint to Terraform regarding the expected version that the provider will be talking with. This is a required hint in order for Terraform to talk with an ancient version of PostgreSQL. This parameter is expected to be a PostgreSQL Version or current. Once a connection has been established, Terraform will fingerprint the actual version. Default: 9.0.0" 39 | default = "12.0.0" 40 | } 41 | 42 | variable "inputs" { 43 | type = any 44 | description = "The map containing all elements for creating objects inside database" 45 | default = null 46 | } 47 | 48 | variable "postprocessing_playbook_params" { 49 | description = "params for postprocessing playbook" 50 | type = any 51 | default = null 52 | } -------------------------------------------------------------------------------- /examples/full-rds-example/create-procedure-statistiques.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE PROCEDURE FEED_STATS(ProductId INTEGER) LANGUAGE plpgsql AS $$ 2 | DECLARE 3 | 4 | infos record; 5 | 6 | BEGIN 7 | 8 | -- retrieve the total amount for a specific product 9 | for infos in ( 10 | select product.label as ProductLabel, sum(quantity * cost) as totalAmount 11 | from customer,product,basket 12 | where basket.customer_id = customer.id 13 | and basket.product_id = product.id 14 | and product.id = ProductId 15 | group by product.label) 16 | loop 17 | -- insert into stats table 18 | insert into stats (product,value) values (infos.ProductLabel,infos.totalAmount); 19 | end loop; 20 | 21 | END; 22 | $$; 23 | -------------------------------------------------------------------------------- /examples/full-rds-example/create-tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE product ( 2 | id SERIAL PRIMARY KEY, 3 | label TEXT NOT NULL, 4 | owner TEXT NOT NULL, 5 | cost NUMERIC(5,2) NOT NULL, 6 | time DATE NOT NULL DEFAULT current_timestamp 7 | ); 8 | 9 | INSERT INTO product(label,owner,cost) VALUES ('Tee-shirt','textile-team',2.5); 10 | 11 | 12 | CREATE TABLE Customer ( 13 | id SERIAL PRIMARY KEY, 14 | firstname TEXT NOT NULL, 15 | lastname TEXT NULL, 16 | address TEXT NULL, 17 | time DATE NOT NULL DEFAULT current_timestamp 18 | ); 19 | 20 | CREATE INDEX idx_Customer_lastname ON Customer(lastname); 21 | 22 | 23 | CREATE TABLE Basket ( 24 | id SERIAL PRIMARY KEY, 25 | customer_id INT NOT NULL, 26 | product_id INT NOT NULL, 27 | quantity INT NOT NULL, 28 | CONSTRAINT fk_customer FOREIGN KEY(customer_id) REFERENCES Customer(id), 29 | CONSTRAINT fk_product FOREIGN KEY(product_id) REFERENCES Product(id) 30 | ); 31 | 32 | CREATE TABLE Stats( 33 | id SERIAL PRIMARY KEY, 34 | product TEXT NOT NULL, 35 | value NUMERIC(8,2) 36 | ); 37 | -------------------------------------------------------------------------------- /examples/full-rds-example/elasticsearch.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Retrieve infos on AWS STS Caller 3 | ######################################## 4 | data "aws_caller_identity" "current" {} 5 | 6 | ######################################### 7 | # Because of a cyclic dependency, we need to 8 | # create the role of the lambda. 9 | ######################################### 10 | resource "aws_iam_role" "lambda-role" { 11 | name = format("role-%s-%s", var.environment, local.lambda_function_name) 12 | assume_role_policy = file("${path.module}/policies/lambda_role.json") 13 | } 14 | 15 | resource "aws_iam_role_policy" "lambda-policy" { 16 | name = format("policy-%s-%s", var.environment, local.lambda_function_name) 17 | role = aws_iam_role.lambda-role.id 18 | policy = templatefile("${path.module}/policies/lambda_policy.tpl", { 19 | account_id = data.aws_caller_identity.current.account_id, 20 | region = var.region 21 | }) 22 | } 23 | 24 | 25 | ########################################### 26 | # Deploy an ElasticSearch Cluster 27 | ########################################### 28 | module "elasticsearch" { 29 | source = "cloudposse/elasticsearch/aws" 30 | version = "0.35.0" 31 | 32 | # create or not all related resources inside the module 33 | enabled = var.create_elasticsearch 34 | 35 | #naming 36 | namespace = "soc" 37 | stage = var.environment 38 | name = "es" 39 | 40 | # config 41 | vpc_enabled = false 42 | zone_awareness_enabled = false 43 | elasticsearch_version = "7.4" 44 | instance_type = var.es_instance_type 45 | instance_count = var.es_instance_count 46 | ebs_volume_size = var.es_ebs_volume_size 47 | # because of a cyclic dependencies, create in a first step the elasticsearch without allowing the role of the lambda streaming 48 | iam_role_arns = [aws_iam_role.lambda-role.arn] 49 | iam_actions = ["es:ESHttpGet", "es:ESHttpPut", "es:ESHttpPost"] 50 | encrypt_at_rest_enabled = "true" 51 | kibana_subdomain_name = "kibana-soc" 52 | create_iam_service_linked_role = false 53 | allowed_cidr_blocks = var.allowed_ip_addresses 54 | 55 | advanced_options = { 56 | "rest.action.multi.allow_explicit_index" = "true" 57 | } 58 | } 59 | 60 | ########################################### 61 | # Deploy a subscription filter on RDS CloudWatch Logs 62 | # to stream logs on an ElasticSearch domain endpoint 63 | ########################################### 64 | module "stream2es" { 65 | source = "jparnaudeau/cloudwatch-subscription-elasticsearch/aws" 66 | version = "1.0.0" 67 | 68 | for_each = var.create_elasticsearch ? toset(["1"]) : [] 69 | 70 | # gloval variables 71 | region = var.region 72 | environment = var.environment 73 | tags = local.tags 74 | 75 | # other variables 76 | function_name = local.lambda_function_name 77 | rds_name = var.rds_name 78 | rds_cloudwatch_log_name = format("/aws/rds/instance/%s/postgresql", var.rds_name) 79 | es_domain_endpoint = try(module.elasticsearch.domain_endpoint, "") 80 | source_account_id = data.aws_caller_identity.current.account_id 81 | lambda_role_arn = aws_iam_role.lambda-role.arn 82 | } -------------------------------------------------------------------------------- /examples/full-rds-example/gen-password-in-secretsmanager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import traceback 5 | import boto3 6 | import os 7 | from botocore.config import Config 8 | import subprocess 9 | import shlex 10 | 11 | 12 | ########################### 13 | # MAIN 14 | ########################### 15 | if __name__ == '__main__': 16 | 17 | try: 18 | 19 | # Retrieve environment variables 20 | region = os.getenv('REGION') 21 | refresh_password = os.getenv('REFRESH_PASSWORD') 22 | rds_name = os.getenv('RDS_NAME') 23 | database_user = os.getenv('DBUSER') 24 | 25 | if refresh_password == "true": 26 | 27 | my_config = Config( 28 | region_name = region, 29 | # signature_version = 'v4', 30 | # retries = { 31 | # 'max_attempts': 10, 32 | # 'mode': 'standard' 33 | # } 34 | ) 35 | client = boto3.client('secretsmanager',config=my_config) 36 | 37 | # generate a random password 38 | response = client.get_random_password( 39 | PasswordLength=32, 40 | ExcludeNumbers=False, 41 | ExcludePunctuation=True, 42 | ExcludeUppercase=False, 43 | ExcludeLowercase=False, 44 | IncludeSpace=False, 45 | RequireEachIncludedType=True 46 | ) 47 | secret_value = response['RandomPassword'] 48 | 49 | # retrieve secret-id 50 | secret_name = "secret-kv-{rdsName}-{userName}".format(rdsName=rds_name,userName=database_user) 51 | response = client.list_secrets(Filters=[ 52 | { 53 | 'Key': 'name', 54 | 'Values': [ 55 | secret_name, 56 | ] 57 | }, 58 | ] 59 | ) 60 | 61 | secret_id = response['SecretList'][0]['ARN'] 62 | 63 | # update password in database : psql -c "ALTER USER $DBUSER WITH PASSWORD '$USERPWD'" 64 | postgresql_ddl = shlex.split("psql -c \"ALTER USER {userName} WITH PASSWORD '{secretValue}'\"".format(userName=database_user,secretValue=secret_value)) 65 | process = subprocess.Popen(postgresql_ddl, 66 | stdout=subprocess.PIPE, 67 | stderr=subprocess.PIPE, 68 | universal_newlines=True) 69 | while True: 70 | output = process.stdout.readline() 71 | print(output.strip()) 72 | # Do something else 73 | return_code = process.poll() 74 | if return_code is not None: 75 | print('RETURN CODE', return_code) 76 | # Process has finished, read rest of the output 77 | for output in process.stdout.readlines(): 78 | print(output.strip()) 79 | break 80 | else: 81 | 82 | # alter secret value 83 | response = client.put_secret_value(SecretId=secret_id, 84 | SecretString=secret_value, 85 | ) 86 | 87 | print("Succesfully alter secret {}".format(secret_name)) 88 | break 89 | 90 | except Exception as err: 91 | print("Exception during processing: {0}".format(err)) 92 | traceback.print_exc() 93 | -------------------------------------------------------------------------------- /examples/full-rds-example/locals.tf: -------------------------------------------------------------------------------- 1 | ################################################################## 2 | # Define locals 3 | ################################################################## 4 | locals { 5 | name = var.rds_name 6 | subnet_grp_name = format("subnetsgrp-%s", local.name) 7 | tags = merge(var.tags, { "environment" = var.environment }) 8 | namespace = format("/%s/%s", var.environment, var.inputs["db_name"]) 9 | lambda_function_name = format("streamLogsToEsFor-%s", var.rds_name) 10 | } 11 | -------------------------------------------------------------------------------- /examples/full-rds-example/outputs.tf: -------------------------------------------------------------------------------- 1 | ########################################## 2 | # Outputs for VPC 3 | ########################################## 4 | output "vpc_infos" { 5 | description = "map of vpc informations" 6 | value = { 7 | vpc_id = module.vpc.vpc_id, 8 | vpc_name = module.vpc.name, 9 | public_subnets = module.vpc.public_subnets, 10 | private_subnets = module.vpc.private_subnets, 11 | database_subnets = module.vpc.database_subnets 12 | } 13 | } 14 | 15 | ################################################ 16 | # Outputs for RDS 17 | ################################################ 18 | output "rds_infos" { 19 | description = "map of rds informations" 20 | value = { 21 | db_instance_address = module.rds.db_instance_address, 22 | db_instance_arn = module.rds.db_instance_arn, 23 | db_instance_endpoint = module.rds.db_instance_endpoint, 24 | db_instance_id = module.rds.db_instance_id, 25 | db_instance_name = module.rds.db_instance_name, 26 | "connect_command" = format("psql -h %s -p %s -U %s -d %s -W", module.rds.db_instance_address, var.dbport, var.rds_superuser_name, var.inputs["db_name"]) 27 | } 28 | } 29 | 30 | output "affected_schema" { 31 | description = "the name of the schema in which the db objects have been created by the module" 32 | value = var.inputs["db_schema_name"] 33 | } 34 | 35 | output "created_roles" { 36 | description = "The list of roles created by the module" 37 | value = [for obj_role in var.inputs["db_roles"] : obj_role["role"]] 38 | } 39 | 40 | output "db_users" { 41 | description = "The list of users created by the module" 42 | value = { for user in var.inputs["db_users"] : 43 | user.name => { 44 | "secret_name" = join(",", keys(module.secrets-manager[user.name].secret_arns)), 45 | "secret_arn" = join(",", values(module.secrets-manager[user.name].secret_arns)), 46 | "connect_command" = format("psql -h %s -p %s -U %s -d %s -W", module.rds.db_instance_address, var.dbport, user.name, var.inputs["db_name"]) 47 | } 48 | } 49 | } 50 | 51 | ################################################ 52 | # Outputs for elasticSearch 53 | ################################################ 54 | output "domain_arn" { 55 | description = "The ElasticSearch Domain ARN" 56 | value = try(module.elasticsearch.domain_arn, "") 57 | } 58 | 59 | output "domain_endpoint" { 60 | description = "The ElasticSearch Domain Endpoint" 61 | value = try(module.elasticsearch.domain_endpoint, "") 62 | } 63 | output "domain_hostname" { 64 | description = "The ElasticSearch Domain Hostname" 65 | value = try(module.elasticsearch.domain_hostname, "") 66 | } 67 | output "domain_id" { 68 | description = "The ElasticSearch Domain Id" 69 | value = try(module.elasticsearch.domain_id, "") 70 | } 71 | output "domain_name" { 72 | description = "The ElasticSearch Domain Name" 73 | value = try(module.elasticsearch.domain_name, "") 74 | } 75 | output "elasticsearch_user_iam_role_arn" { 76 | description = "The ElasticSearch User IAM Role ARN" 77 | value = try(module.elasticsearch.elasticsearch_user_iam_role_arn, "") 78 | } 79 | output "elasticsearch_user_iam_role_name" { 80 | description = "The ElasticSearch User IAM Role Name" 81 | value = try(module.elasticsearch.elasticsearch_user_iam_role_name, "") 82 | } 83 | output "kibana_endpoint" { 84 | description = "The ElasticSearch Kibana Endpoint" 85 | value = try(module.elasticsearch.kibana_endpoint, "") 86 | } 87 | 88 | ################################################ 89 | # Outputs for streaming lambda 90 | ################################################ 91 | output "streaming_lambda_arn" { 92 | description = "The Lambda ARN responsible of streaming RDS Logs to ElasticSearch" 93 | value = try(module.stream2es["1"].lambda_function_arn, "Not Deploy") 94 | } 95 | 96 | output "streamed_cloudwatch_log_arn" { 97 | description = "The CloudWatch Log ARN being streamed by the lambda" 98 | value = try(module.stream2es["1"].streamed_cloudwatch_log_arn, "Not Deploy") 99 | } 100 | 101 | output "streaming_lambda_role_arn" { 102 | description = "The Role ARN of the streaming lambda" 103 | value = aws_iam_role.lambda-role.arn 104 | } 105 | -------------------------------------------------------------------------------- /examples/full-rds-example/policies/lambda_policy.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "CopiedFromTemplateAWSLambdaVPCAccessExecutionRole1", 6 | "Effect": "Allow", 7 | "Action": [ 8 | "logs:Create*", 9 | "logs:Describe*", 10 | "es:ESHttpPost" 11 | ], 12 | "Resource": "*" 13 | }, 14 | { 15 | "Sid": "CopiedFromTemplateAWSLambdaBasicExecutionRole2", 16 | "Effect": "Allow", 17 | "Action": [ 18 | "logs:CreateLogStream", 19 | "logs:Put*", 20 | "logs:FilterLogEvents" 21 | ], 22 | "Resource": [ 23 | "arn:aws:logs:${region}:${account_id}:log-group:*" 24 | ] 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /examples/full-rds-example/policies/lambda_role.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Action": "sts:AssumeRole", 6 | "Principal": { 7 | "Service": "lambda.amazonaws.com" 8 | }, 9 | "Effect": "Allow" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /examples/full-rds-example/postgresql.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Initialize the database and the objects 3 | # (roles & grants), the default privileges 4 | ######################################## 5 | module "initdb" { 6 | 7 | source = "../../create-database" 8 | 9 | depends_on = [module.rds] 10 | 11 | # set the provider 12 | providers = { 13 | postgresql = postgresql.pgadm 14 | } 15 | 16 | # targetted rds 17 | pgadmin_user = var.rds_superuser_name 18 | dbhost = module.rds.db_instance_address 19 | dbport = var.dbport 20 | 21 | # input parameters for creating database & objects inside database 22 | create_database = false 23 | inputs = var.inputs 24 | 25 | # because the superuser is not "postgres", need to set it in the module 26 | default_superusers_list = [var.rds_superuser_name] 27 | } 28 | 29 | #################################################################### 30 | # for each users defined in var.inputs, create 31 | # - create a fake password for this user 32 | # - save it into secretsManager with key = "secret-kv-${rds_name}-${username}" 33 | # 34 | # we do this for having only one case to manage in the postprocessing shell : 35 | # we update systematically the value of the secret. 36 | #################################################################### 37 | 38 | # the random passwords for each user 39 | resource "random_password" "passwords" { 40 | for_each = { for user in var.inputs["db_users"] : user.name => user } 41 | 42 | length = 16 43 | special = true 44 | upper = true 45 | lower = true 46 | min_upper = 1 47 | number = true 48 | min_numeric = 1 49 | min_special = 3 50 | override_special = "@#%&?" 51 | } 52 | 53 | ######################################### 54 | # Store key/value username/password in AWS SecretsManager 55 | ######################################### 56 | module "secrets-manager" { 57 | for_each = { for user in var.inputs["db_users"] : user.name => user } 58 | source = "lgallard/secrets-manager/aws" 59 | version = "0.5.1" 60 | 61 | secrets = { 62 | "secret-kv-${local.name}-${each.key}" = { 63 | description = format("Password for username %s for database %s", each.key, local.name) 64 | secret_key_value = { 65 | username = each.key 66 | password = random_password.passwords[each.key].result 67 | } 68 | recovery_window_in_days = var.recovery_window_in_days 69 | }, 70 | } 71 | 72 | tags = local.tags 73 | } 74 | 75 | ######################################### 76 | # Create the users inside the database 77 | ######################################### 78 | # AWS Region 79 | data "aws_region" "current" {} 80 | 81 | module "create_users" { 82 | source = "../../create-users" 83 | 84 | # need that all objects, managed inside the module "initdb", are created 85 | depends_on = [module.initdb] 86 | 87 | # set the provider 88 | providers = { 89 | postgresql = postgresql.pgadm 90 | } 91 | 92 | # targetted rds 93 | pgadmin_user = var.rds_superuser_name 94 | dbhost = module.rds.db_instance_address 95 | dbport = var.dbport 96 | 97 | # input parameters for creating users inside database 98 | db_users = var.inputs["db_users"] 99 | 100 | # set passwords 101 | passwords = { for user in var.inputs["db_users"] : user.name => random_password.passwords[user.name].result } 102 | 103 | # set postprocessing playbook 104 | postprocessing_playbook_params = { 105 | enable = true 106 | db_name = var.inputs["db_name"] 107 | extra_envs = { 108 | REGION = data.aws_region.current.name 109 | RDS_NAME = var.rds_name 110 | } 111 | refresh_passwords = var.refresh_passwords 112 | shell_name = "./gen-password-in-secretsmanager.py" 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /examples/full-rds-example/providers.tf: -------------------------------------------------------------------------------- 1 | ####################################### 2 | # Define Providers pgadm & pgmgm for postgresql 3 | ####################################### 4 | provider "postgresql" { 5 | alias = "pgadm" 6 | host = module.rds.db_instance_address 7 | port = var.dbport 8 | username = var.rds_superuser_name 9 | sslmode = var.sslmode 10 | connect_timeout = var.connect_timeout 11 | superuser = var.superuser 12 | expected_version = var.expected_version 13 | } 14 | 15 | provider "postgresql" { 16 | alias = "pgmgm" 17 | host = module.rds.db_instance_address 18 | port = var.dbport 19 | database = var.inputs["db_name"] 20 | username = var.rds_superuser_name 21 | sslmode = var.sslmode 22 | connect_timeout = var.connect_timeout 23 | superuser = var.superuser 24 | expected_version = var.expected_version 25 | } 26 | 27 | ####################################### 28 | # Define Provider for aws 29 | ####################################### 30 | provider "aws" { 31 | region = var.region 32 | } 33 | 34 | ####################################### 35 | # Manage version of providers 36 | ####################################### 37 | terraform { 38 | required_version = ">= 1.0.4" 39 | 40 | required_providers { 41 | aws = { 42 | source = "hashicorp/aws" 43 | version = ">= 3.15" 44 | } 45 | postgresql = { 46 | source = "cyrilgdn/postgresql" 47 | version = ">= 1.11.0" 48 | } 49 | random = { 50 | source = "hashicorp/random" 51 | version = ">= 3.0.0" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/full-rds-example/rds.tf: -------------------------------------------------------------------------------- 1 | ###################################### 2 | # Deploy RDS Instance 3 | ###################################### 4 | module "rds" { 5 | source = "terraform-aws-modules/rds/aws" 6 | version = "3.5.0" 7 | 8 | identifier = local.name 9 | 10 | # All available versions: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_PostgreSQL.html#PostgreSQL.Concepts 11 | engine = "postgres" 12 | engine_version = var.rds_engine_version 13 | family = var.rds_family 14 | major_engine_version = var.rds_major_engine_version 15 | instance_class = var.rds_instance_class 16 | 17 | allocated_storage = var.rds_allocated_storage 18 | max_allocated_storage = var.rds_max_allocated_storage 19 | storage_encrypted = var.rds_storage_encrypted 20 | 21 | # NOTE: Do NOT use 'user' as the value for 'username' as it throws: 22 | # "Error creating DB Instance: InvalidParameterValue: MasterUsername 23 | # user cannot be used as it is a reserved word used by the engine" 24 | name = var.inputs["db_name"] 25 | username = var.rds_superuser_name 26 | # password is setted inside environment variable TF_VAR_rds_root_password 27 | password = var.rds_root_password 28 | port = 5432 29 | 30 | multi_az = true 31 | 32 | # because we want reach the database from our local workstation, we need to deploy our RDS in the public subnets 33 | # DO NOT DO THAT IN PRODUCTION 34 | # to reduce the attack surface, limit the access of the RDS Instance to our personal IP addresses 35 | publicly_accessible = true 36 | subnet_ids = module.vpc.public_subnets 37 | vpc_security_group_ids = [module.security_group.security_group_id] 38 | 39 | maintenance_window = "Mon:00:00-Mon:03:00" 40 | backup_window = "03:00-06:00" 41 | enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"] 42 | 43 | backup_retention_period = 0 44 | skip_final_snapshot = true 45 | deletion_protection = false # for test purpose !! 46 | 47 | create_db_parameter_group = false 48 | parameter_group_name = aws_db_parameter_group.postgres.id 49 | 50 | create_db_option_group = false 51 | 52 | create_db_subnet_group = false 53 | db_subnet_group_name = aws_db_subnet_group.main_db_subnet_group.id 54 | 55 | tags = local.tags 56 | } 57 | 58 | resource "random_id" "val" { 59 | byte_length = 4 60 | } 61 | 62 | resource "aws_db_parameter_group" "postgres" { 63 | name = format("param-%s-%s", local.name, random_id.val.hex) 64 | description = "Parameter group for our postgresql rds instance" 65 | family = var.rds_family 66 | 67 | dynamic "parameter" { 68 | for_each = var.parameter_group_params["immediate"] 69 | content { 70 | name = parameter.key 71 | value = parameter.value 72 | } 73 | } 74 | dynamic "parameter" { 75 | for_each = var.parameter_group_params["pending-reboot"] 76 | content { 77 | name = parameter.key 78 | value = parameter.value 79 | apply_method = "pending-reboot" 80 | } 81 | } 82 | 83 | tags = local.tags 84 | } 85 | 86 | 87 | resource "aws_db_subnet_group" "main_db_subnet_group" { 88 | name = local.subnet_grp_name 89 | description = format("%s db subnet group", local.name) 90 | subnet_ids = module.vpc.public_subnets 91 | 92 | tags = local.tags 93 | } 94 | -------------------------------------------------------------------------------- /examples/full-rds-example/retrieve-audit-logs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export INSTANCE_IDENTIFIER=`terraform output|grep db_instance_id|awk -F '=' '{print $2}'|sed 's/^ *//g'|sed 's/"//g'` 4 | 5 | LOGFILE=$(aws rds describe-db-log-files --db-instance-identifier ${INSTANCE_IDENTIFIER} --query 'DescribeDBLogFiles[-1].[LogFileName]' --output text) 6 | 7 | echo "LOGFILE = $LOGFILE" 8 | 9 | aws rds download-db-log-file-portion \ 10 | --db-instance-identifier ${INSTANCE_IDENTIFIER} \ 11 | --starting-token 0 \ 12 | --log-file-name "${LOGFILE}" \ 13 | --output text | grep AUDIT 14 | -------------------------------------------------------------------------------- /examples/full-rds-example/terraform.tfvars: -------------------------------------------------------------------------------- 1 | # database and objects creation 2 | inputs = { 3 | 4 | # parameters used for creating database 5 | db_schema_name = "public" 6 | db_name = "mydatabase" # should be the same as var.rds_name. if not, a new database will be created 7 | db_admin = "app_admin_role" #owner of the database 8 | 9 | # install extensions if needed 10 | extensions = ["pgaudit"] 11 | 12 | # CREATE ROLE 13 | db_roles = [ 14 | { id = "admin", role = "app_admin_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE", "CREATE"], createrole = true }, 15 | ], 16 | 17 | # GRANT PERMISSIONS ON ROLES 18 | db_grants = [ 19 | # define grants for app_admin_role : 20 | # - access to all objects on database 21 | { object_type = "database", privileges = ["CREATE", "CONNECT", "TEMPORARY"], objects = [], role = "app_admin_role", owner_role = "root", grant_option = true }, 22 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_admin_role", owner_role = "root", grant_option = true }, 23 | 24 | ], 25 | 26 | # CREATE USER 27 | db_users = [ 28 | { name = "admin", inherit = true, login = true, membership = ["app_admin_role"], validity = "infinity", connection_limit = -1, createrole = true }, 29 | ] 30 | 31 | } 32 | 33 | # Refresh or not refresh passwords 34 | refresh_passwords = ["all"] 35 | 36 | # set tags & environment 37 | environment = "test" 38 | tags = { 39 | createdBy = "terraform" 40 | "ippon:owner" = "jparnaudeau" 41 | } 42 | 43 | ################################################ 44 | # VPC & RDS Customization 45 | ################################################ 46 | 47 | # a standard vpc 48 | vpc_cidr = "10.66.0.0/18" 49 | 50 | vpc_public_subnets = ["10.66.0.0/24", "10.66.1.0/24", "10.66.2.0/24"] 51 | vpc_private_subnets = ["10.66.3.0/24", "10.66.4.0/24", "10.66.5.0/24"] 52 | vpc_database_subnets = ["10.66.6.0/24", "10.66.7.0/24", "10.66.8.0/24"] 53 | 54 | # rds settings 55 | rds_name = "myfullrdsexample" 56 | rds_engine_version = "13.5" 57 | rds_major_engine_version = "13" 58 | rds_family = "postgres13" 59 | rds_instance_class = "db.t3.micro" 60 | rds_allocated_storage = 10 61 | rds_max_allocated_storage = 20 62 | allowed_ip_addresses = ["X.X.X.X/32"] # your personal Outbound IP Address 63 | rds_superuser_name = "root" 64 | 65 | # define parameter groups for our RDS, apply_method = "immediate" 66 | # for setting pg_extension parameters, the apply_method need to be "pending-reboot" 67 | # reboot required if the database already exsits : aws rds reboot-db-instance --db-instance-identifier xxx 68 | # extension pg_stat_statements : https://pganalyze.com/docs/install/amazon_rds/01_configure_rds_instance 69 | # extension pg_audit : https://aws.amazon.com/premiumsupport/knowledge-center/rds-postgresql-pgaudit/?nc1=h_ls 70 | parameter_group_params = { 71 | immediate = { 72 | autovacuum = 1 73 | client_encoding = "utf8" 74 | log_connections = "1" 75 | log_disconnections = "1" 76 | log_statement = "all" 77 | } 78 | pending-reboot = { 79 | shared_preload_libraries = "pgaudit", 80 | track_activity_query_size = "2048", 81 | "pgaudit.log" = "ALL", 82 | "pgaudit.log_level" = "info", 83 | "pgaudit.log_statement_once" = "1" 84 | } 85 | } 86 | 87 | ################################################ 88 | # ElasticSearch 89 | ################################################ 90 | create_elasticsearch = false 91 | es_instance_type = "t3.small.elasticsearch" 92 | es_instance_count = 1 93 | es_ebs_volume_size = 10 94 | -------------------------------------------------------------------------------- /examples/full-rds-example/terraform.tfvars.step5: -------------------------------------------------------------------------------- 1 | # database and objects creation 2 | inputs = { 3 | 4 | # parameters used for creating database 5 | db_schema_name = "public" 6 | db_name = "mydatabase" # should be the same as var.rds_name. if not, a new database will be created 7 | db_admin = "app_admin_role" #owner of the database 8 | 9 | # install extensions if needed 10 | extensions = ["pgaudit"] 11 | 12 | # ---------------------------------- ROLES ------------------------------------------------------------------------------------ 13 | # In this example, we want illustrate the "least privilege pattern". We have a schema in which 4 tables have been created : Customer, Product, Basket, Stats 14 | # We will create 4 roles : 15 | # - "app_admin_role" will be the role used for creation, deletion, grant operations on objects etc .. It's the "admin" role used for managed objects inside db 'mydatabase' (var.inputs['db_name']), schema 'public' 16 | # - "app_readonly_role" for readonly operations. 17 | # - "app_writeweb_role" for operations allowed from "web" application. 18 | # - "app_writebo_role" for operations allowed from "backoffice" application. 19 | db_roles = [ 20 | { id = "admin", role = "app_admin_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE", "CREATE"], createrole = true }, 21 | { id = "readonly", role = "app_readonly_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 22 | { id = "web", role = "app_writeweb_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 23 | { id = "backoffice", role = "app_writebo_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 24 | { id = "batch", role = "app_writebatch_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 25 | ], 26 | 27 | # ---------------------------------- GRANT PERMISSIONS ON ROLES ------------------------------------------------------------------------------------ 28 | # you could find the available privileges on official postgresql doc : https://www.postgresql.org/docs/13/ddl-priv.html 29 | # Notes : 30 | # - "role" corresponds to the role on which the grants will be applied. 31 | # - "owner_role" is the role used to create grants on "role". 32 | # - object_type = "type" is used only for default privileges 33 | # - objects = [] means "all" 34 | # all these grants are related to db 'mydatabase' (var.inputs['db_name']), schema 'public' (var.inputs['db_schema_name']) 35 | db_grants = [ 36 | # define grants for app_admin_role : 37 | # - access to all objects on database 38 | { object_type = "database", privileges = ["CREATE", "CONNECT", "TEMPORARY"], objects = [], role = "app_admin_role", owner_role = "root", grant_option = true }, 39 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_admin_role", owner_role = "root", grant_option = true }, 40 | 41 | # define grants for app_readonly_role 42 | # - access to 'SELECT' on all tables 43 | # - access to 'SELECT' on all sequences 44 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 45 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 46 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 47 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 48 | 49 | # define grants for app_writeweb_role 50 | # - access in Read/Write on tables "customer" & "basket" 51 | # - access in Read on table "Product" 52 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_writeweb_role", owner_role = "app_admin_role", grant_option = false }, 53 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_writeweb_role", owner_role = "app_admin_role", grant_option = false }, 54 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER", "INSERT", "UPDATE", "DELETE"], objects = ["customer", "basket"], role = "app_writeweb_role", owner_role = "app_admin_role", grant_option = false }, 55 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = ["product"], role = "app_writeweb_role", owner_role = "app_admin_role", grant_option = false }, 56 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_writeweb_role", owner_role = "app_admin_role", grant_option = false }, 57 | { object_type = "function", privileges = ["EXECUTE"], objects = [], role = "app_writeweb_role", owner_role = "app_admin_role", grant_option = false }, 58 | 59 | # define grants for app_writebo_role 60 | # - access in Read/Write on table "product" 61 | # - access in Read on table "customer", "basket", "stats" 62 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_writebo_role", owner_role = "app_admin_role", grant_option = false }, 63 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_writebo_role", owner_role = "app_admin_role", grant_option = false }, 64 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER", "INSERT", "UPDATE", "DELETE"], objects = ["product"], role = "app_writebo_role", owner_role = "app_admin_role", grant_option = false }, 65 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = ["customer", "basket", "stats"], role = "app_writebo_role", owner_role = "app_admin_role", grant_option = false }, 66 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_writebo_role", owner_role = "app_admin_role", grant_option = false }, 67 | { object_type = "function", privileges = ["EXECUTE"], objects = [], role = "app_writebo_role", owner_role = "app_admin_role", grant_option = false }, 68 | 69 | # define grants for app_writebatch_role 70 | # - access in Read/Write on table "stats" 71 | # - access in Read on table "customer", "basket", "product" 72 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_writebatch_role", owner_role = "app_admin_role", grant_option = false }, 73 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_writebatch_role", owner_role = "app_admin_role", grant_option = false }, 74 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER", "INSERT", "UPDATE", "DELETE"], objects = ["stats"], role = "app_writebatch_role", owner_role = "app_admin_role", grant_option = false }, 75 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = ["customer", "basket", "product"], role = "app_writebatch_role", owner_role = "app_admin_role", grant_option = false }, 76 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_writebatch_role", owner_role = "app_admin_role", grant_option = false }, 77 | { object_type = "function", privileges = ["EXECUTE"], objects = [], role = "app_writebatch_role", owner_role = "app_admin_role", grant_option = false }, 78 | 79 | ], 80 | 81 | db_users = [ 82 | { name = "admin", inherit = true, login = true, membership = ["app_admin_role"], validity = "infinity", connection_limit = -1, createrole = true }, 83 | { name = "web", inherit = true, login = true, membership = ["app_writeweb_role"], validity = "infinity", connection_limit = -1, createrole = false }, 84 | { name = "backoffice", inherit = true, login = true, membership = ["app_writebo_role"], validity = "infinity", connection_limit = -1, createrole = false }, 85 | { name = "batch", inherit = true, login = true, membership = ["app_writebatch_role"], validity = "infinity", connection_limit = -1, createrole = false }, 86 | { name = "lemmy", inherit = true, login = true, membership = ["app_readonly_role"], validity = "2022-07-31 00:00:00+00", connection_limit = -1, createrole = false }, 87 | ] 88 | 89 | } 90 | 91 | # Refresh or not refresh passwords 92 | refresh_passwords = ["web","backoffice","batch","lemmy"] 93 | 94 | # set tags & environment 95 | environment = "test" 96 | tags = { 97 | createdBy = "terraform" 98 | "ippon:owner" = "jparnaudeau" 99 | } 100 | 101 | ################################################ 102 | # VPC & RDS Customization 103 | ################################################ 104 | 105 | # a standard vpc 106 | vpc_cidr = "10.66.0.0/18" 107 | 108 | vpc_public_subnets = ["10.66.0.0/24", "10.66.1.0/24", "10.66.2.0/24"] 109 | vpc_private_subnets = ["10.66.3.0/24", "10.66.4.0/24", "10.66.5.0/24"] 110 | vpc_database_subnets = ["10.66.6.0/24", "10.66.7.0/24", "10.66.8.0/24"] 111 | 112 | # rds settings 113 | rds_name = "myfullrdsexample" 114 | rds_engine_version = "13.5" 115 | rds_major_engine_version = "13" 116 | rds_family = "postgres13" 117 | rds_instance_class = "db.t3.micro" 118 | rds_allocated_storage = 10 119 | rds_max_allocated_storage = 20 120 | allowed_ip_addresses = ["X.X.X.X/32"] # your personal Outbound IP Address 121 | rds_superuser_name = "root" 122 | 123 | # define parameter groups for our RDS, apply_method = "immediate" 124 | # for setting pg_extension parameters, the apply_method need to be "pending-reboot" 125 | # reboot required if the database already exsits : aws rds reboot-db-instance --db-instance-identifier xxx 126 | # extension pg_stat_statements : https://pganalyze.com/docs/install/amazon_rds/01_configure_rds_instance 127 | # extension pg_audit : https://aws.amazon.com/premiumsupport/knowledge-center/rds-postgresql-pgaudit/?nc1=h_ls 128 | parameter_group_params = { 129 | immediate = { 130 | autovacuum = 1 131 | client_encoding = "utf8" 132 | log_connections = "1" 133 | log_disconnections = "1" 134 | log_statement = "all" 135 | } 136 | pending-reboot = { 137 | shared_preload_libraries = "pgaudit", 138 | track_activity_query_size = "2048", 139 | "pgaudit.log" = "ALL", 140 | "pgaudit.log_level" = "info", 141 | "pgaudit.log_statement_once" = "1" 142 | } 143 | } 144 | 145 | ################################################ 146 | # ElasticSearch 147 | ################################################ 148 | create_elasticsearch = false 149 | es_instance_type = "t3.small.elasticsearch" 150 | es_instance_count = 1 151 | es_ebs_volume_size = 10 152 | -------------------------------------------------------------------------------- /examples/full-rds-example/variables.tf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # define variables for postgresql database connectivity 3 | ######################################## 4 | variable "dbport" { 5 | type = number 6 | default = 5432 7 | description = "The database port" 8 | } 9 | 10 | variable "sslmode" { 11 | type = string 12 | description = "Set the priority for an SSL connection to the server. Valid values are [disable,require,verify-ca,verify-full]" 13 | default = "require" 14 | } 15 | 16 | variable "connect_timeout" { 17 | type = number 18 | description = "Maximum wait for connection, in seconds. The default is 180s. Zero or not specified means wait indefinitely." 19 | default = 180 20 | } 21 | 22 | variable "superuser" { 23 | type = bool 24 | description = "Should be set to false if the user to connect is not a PostgreSQL superuser" 25 | default = false 26 | } 27 | 28 | variable "expected_version" { 29 | type = string 30 | description = "Specify a hint to Terraform regarding the expected version that the provider will be talking with. This is a required hint in order for Terraform to talk with an ancient version of PostgreSQL. This parameter is expected to be a PostgreSQL Version or current. Once a connection has been established, Terraform will fingerprint the actual version. Default: 9.0.0" 31 | default = "12.0.0" 32 | } 33 | 34 | ######################################## 35 | # define variables for postgresql database creation 36 | ######################################## 37 | variable "inputs" { 38 | type = any 39 | description = "The map containing all elements for creating objects inside database" 40 | default = null 41 | } 42 | 43 | ######################################## 44 | # define global variables tags, env, ... 45 | ######################################## 46 | variable "tags" { 47 | type = map(string) 48 | description = "a map of string used to tag entries in AWS Secrets Manager" 49 | default = {} 50 | } 51 | 52 | variable "environment" { 53 | type = string 54 | description = "environment name" 55 | default = "sta" 56 | } 57 | 58 | ######################################## 59 | # define variables for vpc 60 | ######################################## 61 | variable "vpc_cidr" { 62 | type = string 63 | description = "VPC CIDR" 64 | default = "10.0.0.0/16" 65 | } 66 | 67 | variable "vpc_public_subnets" { 68 | type = list(string) 69 | description = "list of public subnets range" 70 | default = ["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24"] 71 | } 72 | 73 | variable "vpc_private_subnets" { 74 | type = list(string) 75 | description = "list of private subnets range" 76 | default = ["10.0.3.0/24", "10.0.4.0/24", "10.0.5.0/24"] 77 | } 78 | 79 | variable "vpc_database_subnets" { 80 | type = list(string) 81 | description = "list of database subnets range" 82 | default = ["10.0.6.0/24", "10.0.7.0/24", "10.0.8.0/24"] 83 | } 84 | 85 | ######################################## 86 | # define variables for rds 87 | ######################################## 88 | variable "region" { 89 | type = string 90 | description = "AWS Region name" 91 | default = "eu-west-3" 92 | } 93 | 94 | variable "rds_name" { 95 | type = string 96 | description = "RDS Database Name" 97 | default = "mydatabase" 98 | } 99 | 100 | variable "allowed_ip_addresses" { 101 | type = list(string) 102 | description = "List of allowed IP addresses" 103 | default = [] 104 | } 105 | 106 | variable "rds_major_engine_version" { 107 | type = string 108 | description = "RDS Major Engine Version" 109 | default = "13" 110 | } 111 | 112 | variable "rds_engine_version" { 113 | type = string 114 | description = "RDS Engine Version" 115 | default = "13.5" 116 | } 117 | 118 | variable "rds_family" { 119 | type = string 120 | description = "RDS Family" 121 | default = "postgres13" 122 | } 123 | 124 | variable "rds_instance_class" { 125 | type = string 126 | description = "RDS Instance class" 127 | default = "db.t3.micro" 128 | } 129 | 130 | variable "rds_allocated_storage" { 131 | type = number 132 | description = "RDS Inital Allocated Storage" 133 | default = 10 134 | } 135 | 136 | variable "rds_max_allocated_storage" { 137 | type = number 138 | description = "RDS Max Allocated Storage" 139 | default = 20 140 | } 141 | 142 | variable "rds_storage_encrypted" { 143 | type = bool 144 | description = "Enable encryption at rest" 145 | default = true 146 | } 147 | 148 | variable "rds_superuser_name" { 149 | type = string 150 | description = "The default super-user name" 151 | default = "root" 152 | } 153 | 154 | variable "rds_root_password" { 155 | type = string 156 | description = "Password for RDS super-user" 157 | sensitive = true 158 | } 159 | 160 | variable "parameter_group_params" { 161 | type = map(any) 162 | description = "custom parameter group instance params" 163 | default = {} 164 | } 165 | 166 | 167 | ######################################## 168 | # define variables for AWS SecretsManager 169 | ######################################## 170 | variable "recovery_window_in_days" { 171 | type = number 172 | description = "delay in days during a secret can be recoverd" 173 | default = 7 174 | } 175 | 176 | variable "refresh_passwords" { 177 | type = list(string) 178 | description = "The list of users that we want refresh its password. Default '[all]'" 179 | default = ["all"] 180 | } 181 | 182 | ######################################## 183 | # define variables for ElasticSearch 184 | ######################################## 185 | variable "create_elasticsearch" { 186 | type = bool 187 | description = "Enable or Not the creation of an elasticSearch to simulate a SOC Tool" 188 | default = false 189 | } 190 | 191 | variable "es_instance_type" { 192 | type = string 193 | description = "InstanceType for ElasticSearch Node" 194 | default = "t3.small.elasticsearch" 195 | } 196 | 197 | variable "es_instance_count" { 198 | type = number 199 | description = "Number of instances in the ElasticSearch Domain" 200 | default = 1 201 | } 202 | 203 | variable "es_ebs_volume_size" { 204 | type = number 205 | description = "EBS Size associated to each node in the ElasticSearch Domain" 206 | default = 10 207 | } 208 | -------------------------------------------------------------------------------- /examples/full-rds-example/vpc.tf: -------------------------------------------------------------------------------- 1 | ###################################### 2 | # Create our playground - VPC 3 | ###################################### 4 | module "vpc" { 5 | source = "terraform-aws-modules/vpc/aws" 6 | version = "~> 2" 7 | 8 | name = format("vpc-%s-%s", var.environment, local.name) 9 | cidr = var.vpc_cidr 10 | 11 | azs = ["${var.region}a", "${var.region}b", "${var.region}c"] 12 | public_subnets = var.vpc_public_subnets 13 | private_subnets = var.vpc_private_subnets 14 | database_subnets = var.vpc_database_subnets 15 | 16 | create_database_subnet_group = false 17 | 18 | enable_dns_hostnames = true 19 | enable_dns_support = true 20 | 21 | tags = local.tags 22 | } 23 | 24 | 25 | ###################################### 26 | # Deploy Security Group for our RDS Instance 27 | # allow access from personal IP Address 28 | ###################################### 29 | module "security_group" { 30 | source = "terraform-aws-modules/security-group/aws" 31 | version = "~> 4" 32 | 33 | name = "${local.name}-postgresql" 34 | description = "PostgreSQL RDS security group" 35 | vpc_id = module.vpc.vpc_id 36 | 37 | tags = local.tags 38 | } 39 | 40 | resource "aws_security_group_rule" "allowed_ip_on_rds" { 41 | description = "Expose Postgresql Listener to Allowed IP Addresses" 42 | type = "ingress" 43 | from_port = 5432 44 | to_port = 5432 45 | protocol = "TCP" 46 | cidr_blocks = var.allowed_ip_addresses 47 | security_group_id = module.security_group.security_group_id 48 | } 49 | 50 | resource "aws_security_group_rule" "rds_outbound" { 51 | description = "Outbound access for ${local.name}" 52 | type = "egress" 53 | from_port = 0 54 | to_port = 0 55 | protocol = "-1" 56 | cidr_blocks = ["0.0.0.0/0"] 57 | security_group_id = module.security_group.security_group_id 58 | } 59 | -------------------------------------------------------------------------------- /examples/simple-database/.envrc: -------------------------------------------------------------------------------- 1 | export PGPASSWORD=password 2 | export AWS_PROFILE=ippon-sandbox 3 | export AWS_DEFAULT_REGION=eu-west-3 4 | -------------------------------------------------------------------------------- /examples/simple-database/README.md: -------------------------------------------------------------------------------- 1 | # simple-database 2 | 3 | This example shows you how to use the module to create a database and all roles and permissions. It is usefull for : 4 | 5 | * create a database locally. It's the case with the use of the docker-compose 6 | * in a cloud environment : After you have created an postgresql instance, you have a super-user and you want to create the database and prepare the database with roles and permissions. 7 | 8 | ## Prepare you postgresql provider 9 | 10 | ```hcl 11 | 12 | ####################################### 13 | # Define Providers pgadm & pgmgm for postgresql 14 | ####################################### 15 | provider "postgresql" { 16 | alias = "pgadm" 17 | host = var.dbhost 18 | port = var.dbport 19 | username = var.pgadmin_user 20 | sslmode = var.sslmode 21 | connect_timeout = var.connect_timeout 22 | superuser = var.superuser 23 | expected_version = var.expected_version 24 | } 25 | 26 | provider "postgresql" { 27 | alias = "pgmgm" 28 | host = var.dbhost 29 | port = var.dbport 30 | database = var.inputs["db_name"] 31 | username = var.pgadmin_user 32 | sslmode = var.sslmode 33 | connect_timeout = var.connect_timeout 34 | superuser = var.superuser 35 | expected_version = var.expected_version 36 | } 37 | 38 | ``` 39 | 40 | Note : the password of the `var.pgadmin_user` are stored in the environment variable **PGPASSWORD** that you must setted before the terraform plan or apply. 41 | 42 | ## Call the module 43 | 44 | ```hcl 45 | 46 | module "initdb" { 47 | 48 | source = "jparnaudeau/database-admin/postgresql//create-database" 49 | version = "2.0.0" 50 | 51 | # set the provider 52 | providers = { 53 | postgresql = postgresql.pgadm 54 | } 55 | 56 | # targetted rds 57 | pgadmin_user = var.pgadmin_user 58 | dbhost = var.dbhost 59 | dbport = var.dbport 60 | 61 | # input parameters for creating database & objects inside database 62 | create_database = true 63 | inputs = var.inputs 64 | } 65 | 66 | 67 | ``` 68 | 69 | 70 | ## Define the inputs 71 | 72 | in the `terraform.tfvars`, you could find : 73 | 74 | ```hcl 75 | 76 | inputs = { 77 | 78 | # parameters used for creating a database named 'mydatabase' and for creating objects in the public schema 79 | db_schema_name = "public" 80 | db_name = "mydatabase" 81 | db_admin = "app_admin_role" # owner of the database 82 | extensions = [] 83 | 84 | # ---------------------------------- ROLES ------------------------------------------------------------------------------------ 85 | # In this example, we create 3 roles 86 | # - "app_admin_role" will be the role used for creation, deletion, grant operations on objects, especially for tables. 87 | # - "app_write_role" for write operations. If you have a backend that insert lines into tables, it will used a user that inherits permissions from it. 88 | # - "app_readonly_role" for readonly operations. 89 | # Note : "write" role does not have the permissions to create table. 90 | # Note : the 'createrole' field is a boolean that provides a way to create other roles and put grants on it. Be carefull when you give this permission (privilege escalation). 91 | db_roles = [ 92 | { id = "admin", role = "app_admin_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE", "CREATE"], createrole = true }, 93 | { id = "readonly", role = "app_readonly_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 94 | { id = "write", role = "app_write_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 95 | 96 | ], 97 | 98 | # ---------------------------------- GRANT PERMISSIONS ON ROLES ------------------------------------------------------------------------------------ 99 | # Notes : 100 | # the concept of "Least privilege" need to be applied here. 101 | # in the structure of a grant, there is the "role" and "owner_role" 102 | # "role" corresponds to the role on which the grants will be applied 103 | # "owner_role" is the role used to create grants on "role". 104 | # you could find the available privileges on official postgresql doc : https://www.postgresql.org/docs/13/ddl-priv.html 105 | # Note object_type = "type" is used only for default privileges 106 | db_grants = [ 107 | # role app_admin_role : define grants to apply on db 'mydatabase', schema 'public' 108 | { object_type = "database", privileges = ["CREATE", "CONNECT", "TEMPORARY"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 109 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 110 | 111 | # role app_readonly_role : define grant to apply on db 'mydatabase', schema 'public' 112 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 113 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = true }, 114 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 115 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 116 | 117 | # role app_write_role : define grant to apply on db 'mydatabase', schema 'public' 118 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 119 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = true }, 120 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER", "INSERT", "UPDATE", "DELETE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 121 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 122 | { object_type = "function", privileges = ["EXECUTE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 123 | 124 | ], 125 | 126 | } 127 | 128 | ``` 129 | -------------------------------------------------------------------------------- /examples/simple-database/main.tf: -------------------------------------------------------------------------------- 1 | module "initdb" { 2 | 3 | source = "../../create-database" 4 | 5 | # set the provider 6 | providers = { 7 | postgresql = postgresql.pgadm 8 | } 9 | 10 | # targetted rds 11 | pgadmin_user = var.pgadmin_user 12 | dbhost = var.dbhost 13 | dbport = var.dbport 14 | 15 | # input parameters for creating database & objects inside database 16 | create_database = true 17 | inputs = var.inputs 18 | } 19 | 20 | -------------------------------------------------------------------------------- /examples/simple-database/outputs.tf: -------------------------------------------------------------------------------- 1 | output "created_database" { 2 | description = "the name of the database created by the module" 3 | value = var.inputs["db_name"] 4 | } 5 | 6 | output "affected_schema" { 7 | description = "the name of the schema in which the db objects have been created by the module" 8 | value = var.inputs["db_schema_name"] 9 | } 10 | 11 | output "created_roles" { 12 | description = "The list of roles created by the module" 13 | value = [for obj_role in var.inputs["db_roles"] : obj_role["role"]] 14 | } 15 | 16 | output "connect_string" { 17 | description = "The connect string to use to connect on the database" 18 | value = format("psql -h %s -p %s -U %s -d %s", var.dbhost, var.dbport, var.inputs["db_admin"], var.inputs["db_name"]) 19 | } 20 | -------------------------------------------------------------------------------- /examples/simple-database/providers.tf: -------------------------------------------------------------------------------- 1 | ####################################### 2 | # Define Providers pgadm & pgmgm for postgresql 3 | ####################################### 4 | provider "postgresql" { 5 | alias = "pgadm" 6 | host = var.dbhost 7 | port = var.dbport 8 | username = var.pgadmin_user 9 | sslmode = var.sslmode 10 | connect_timeout = var.connect_timeout 11 | superuser = var.superuser 12 | expected_version = var.expected_version 13 | } 14 | 15 | provider "postgresql" { 16 | alias = "pgmgm" 17 | host = var.dbhost 18 | port = var.dbport 19 | database = var.inputs["db_name"] 20 | username = var.pgadmin_user 21 | sslmode = var.sslmode 22 | connect_timeout = var.connect_timeout 23 | superuser = var.superuser 24 | expected_version = var.expected_version 25 | } 26 | 27 | ####################################### 28 | # Manage version of providers 29 | ####################################### 30 | terraform { 31 | required_version = ">= 1.0.4" 32 | 33 | required_providers { 34 | postgresql = { 35 | source = "cyrilgdn/postgresql" 36 | version = ">= 1.15.0" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/simple-database/terraform.tfvars: -------------------------------------------------------------------------------- 1 | # provider connection infos 2 | pgadmin_user = "postgres" 3 | dbhost = "localhost" 4 | expected_version = "12.0.0" 5 | sslmode = "disable" 6 | 7 | inputs = { 8 | 9 | # parameters used for creating a database named 'mydatabase' and for creating objects in the public schema 10 | db_schema_name = "public" 11 | db_name = "mydatabase" 12 | db_admin = "app_admin_role" # owner of the database 13 | extensions = [] 14 | 15 | # ---------------------------------- ROLES ------------------------------------------------------------------------------------ 16 | # In this example, we create 3 roles 17 | # - "app_admin_role" will be the role used for creation, deletion, grant operations on objects, especially for tables. 18 | # - "app_write_role" for write operations. If you have a backend that insert lines into tables, it will used a user that inherits permissions from it. 19 | # - "app_readonly_role" for readonly operations. 20 | # Note : "write" role does not have the permissions to create table. 21 | # Note : the 'createrole' field is a boolean that provides a way to create other roles and put grants on it. Be carefull when you give this permission (privilege escalation). 22 | db_roles = [ 23 | { id = "admin", role = "app_admin_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE", "CREATE"], createrole = true }, 24 | { id = "readonly", role = "app_readonly_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 25 | { id = "write", role = "app_write_role", inherit = true, login = false, validity = "infinity", privileges = ["USAGE"], createrole = false }, 26 | 27 | ], 28 | 29 | # ---------------------------------- GRANT PERMISSIONS ON ROLES ------------------------------------------------------------------------------------ 30 | # Notes : 31 | # the concept of "Least privilege" need to be applied here. 32 | # in the structure of a grant, there is the "role" and "owner_role" 33 | # "role" corresponds to the role on which the grants will be applied 34 | # "owner_role" is the role used to create grants on "role". 35 | # you could find the available privileges on official postgresql doc : https://www.postgresql.org/docs/13/ddl-priv.html 36 | # Note object_type = "type" is used only for default privileges 37 | db_grants = [ 38 | # role app_admin_role : define grants to apply on db 'mydatabase', schema 'public' 39 | { object_type = "database", privileges = ["CREATE", "CONNECT", "TEMPORARY"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 40 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_admin_role", owner_role = "postgres", grant_option = true }, 41 | 42 | # role app_readonly_role : define grant to apply on db 'mydatabase', schema 'public' 43 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 44 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = true }, 45 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 46 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_readonly_role", owner_role = "app_admin_role", grant_option = false }, 47 | 48 | # role app_write_role : define grant to apply on db 'mydatabase', schema 'public' 49 | { object_type = "database", privileges = ["CONNECT"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 50 | { object_type = "type", privileges = ["USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = true }, 51 | { object_type = "table", privileges = ["SELECT", "REFERENCES", "TRIGGER", "INSERT", "UPDATE", "DELETE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 52 | { object_type = "sequence", privileges = ["SELECT", "USAGE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 53 | { object_type = "function", privileges = ["EXECUTE"], objects = [], role = "app_write_role", owner_role = "app_admin_role", grant_option = false }, 54 | 55 | ], 56 | 57 | } 58 | 59 | -------------------------------------------------------------------------------- /examples/simple-database/variables.tf: -------------------------------------------------------------------------------- 1 | variable "dbhost" { 2 | type = string 3 | default = "localhost" 4 | description = "The database host" 5 | } 6 | 7 | variable "dbport" { 8 | type = number 9 | default = 5432 10 | description = "The database port" 11 | } 12 | 13 | variable "pgadmin_user" { 14 | type = string 15 | description = "The RDS user to used for creating/managing other user in the database." 16 | } 17 | 18 | variable "sslmode" { 19 | type = string 20 | description = "Set the priority for an SSL connection to the server. Valid values are [disable,require,verify-ca,verify-full]" 21 | default = "require" 22 | } 23 | 24 | variable "connect_timeout" { 25 | type = number 26 | description = "Maximum wait for connection, in seconds. The default is 180s. Zero or not specified means wait indefinitely." 27 | default = 180 28 | } 29 | 30 | variable "superuser" { 31 | type = bool 32 | description = "Should be set to false if the user to connect is not a PostgreSQL superuser" 33 | default = false 34 | } 35 | 36 | variable "expected_version" { 37 | type = string 38 | description = "Specify a hint to Terraform regarding the expected version that the provider will be talking with. This is a required hint in order for Terraform to talk with an ancient version of PostgreSQL. This parameter is expected to be a PostgreSQL Version or current. Once a connection has been established, Terraform will fingerprint the actual version. Default: 9.0.0" 39 | default = "9.0.0" 40 | } 41 | 42 | variable "inputs" { 43 | type = any 44 | description = "The map containing all elements for creating objects inside database" 45 | default = null 46 | } 47 | -------------------------------------------------------------------------------- /schemas/Diagram-Relations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jparnaudeau/terraform-postgresql-database-admin/ef34b2d22741907712c405a555013ff71da3fbab/schemas/Diagram-Relations.png -------------------------------------------------------------------------------- /schemas/Diagram.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "image", 8 | "version": 48, 9 | "versionNonce": 2003440017, 10 | "isDeleted": false, 11 | "id": "ZGNQIqdmRrmVsow0oe5C8", 12 | "fillStyle": "hachure", 13 | "strokeWidth": 1, 14 | "strokeStyle": "solid", 15 | "roughness": 1, 16 | "opacity": 100, 17 | "angle": 0, 18 | "x": 580, 19 | "y": 260, 20 | "strokeColor": "transparent", 21 | "backgroundColor": "transparent", 22 | "width": 73.99999999999999, 23 | "height": 73.99999999999999, 24 | "seed": 442442239, 25 | "groupIds": [], 26 | "strokeSharpness": "round", 27 | "boundElements": [ 28 | { 29 | "id": "JsQee1CZsxMflDtnJYmoG", 30 | "type": "arrow" 31 | } 32 | ], 33 | "updated": 1640116595542, 34 | "status": "saved", 35 | "fileId": "d1564f04fc39917fb6ab5b1bbcc0930445457501", 36 | "scale": [ 37 | 1, 38 | 1 39 | ] 40 | }, 41 | { 42 | "type": "image", 43 | "version": 144, 44 | "versionNonce": 1307701247, 45 | "isDeleted": false, 46 | "id": "vpmExZwo5PzL-bNlogjqI", 47 | "fillStyle": "hachure", 48 | "strokeWidth": 1, 49 | "strokeStyle": "solid", 50 | "roughness": 1, 51 | "opacity": 100, 52 | "angle": 0, 53 | "x": 1180, 54 | "y": 740, 55 | "strokeColor": "transparent", 56 | "backgroundColor": "transparent", 57 | "width": 73.99999999999999, 58 | "height": 73.99999999999999, 59 | "seed": 2075623889, 60 | "groupIds": [], 61 | "strokeSharpness": "round", 62 | "boundElements": [ 63 | { 64 | "id": "U-7HJBwjCMpEF_qZoEA0o", 65 | "type": "arrow" 66 | } 67 | ], 68 | "updated": 1640117298309, 69 | "status": "saved", 70 | "fileId": "d1564f04fc39917fb6ab5b1bbcc0930445457501", 71 | "scale": [ 72 | 1, 73 | 1 74 | ] 75 | }, 76 | { 77 | "type": "image", 78 | "version": 228, 79 | "versionNonce": 228892511, 80 | "isDeleted": false, 81 | "id": "pKRUl3Ql5U-p0RvDTUHjv", 82 | "fillStyle": "hachure", 83 | "strokeWidth": 1, 84 | "strokeStyle": "solid", 85 | "roughness": 1, 86 | "opacity": 100, 87 | "angle": 0, 88 | "x": 900, 89 | "y": 740, 90 | "strokeColor": "transparent", 91 | "backgroundColor": "transparent", 92 | "width": 73.99999999999999, 93 | "height": 73.99999999999999, 94 | "seed": 1941852671, 95 | "groupIds": [], 96 | "strokeSharpness": "round", 97 | "boundElements": [ 98 | { 99 | "id": "U-7HJBwjCMpEF_qZoEA0o", 100 | "type": "arrow" 101 | } 102 | ], 103 | "updated": 1640117221015, 104 | "status": "saved", 105 | "fileId": "d1564f04fc39917fb6ab5b1bbcc0930445457501", 106 | "scale": [ 107 | 1, 108 | 1 109 | ] 110 | }, 111 | { 112 | "type": "text", 113 | "version": 39, 114 | "versionNonce": 1604450207, 115 | "isDeleted": false, 116 | "id": "3bIzk1JFjmFkc105E70B-", 117 | "fillStyle": "hachure", 118 | "strokeWidth": 1, 119 | "strokeStyle": "solid", 120 | "roughness": 1, 121 | "opacity": 100, 122 | "angle": 0, 123 | "x": 537, 124 | "y": 340, 125 | "strokeColor": "#000000", 126 | "backgroundColor": "transparent", 127 | "width": 146, 128 | "height": 75, 129 | "seed": 1775664753, 130 | "groupIds": [], 131 | "strokeSharpness": "sharp", 132 | "boundElements": [], 133 | "updated": 1640116546658, 134 | "fontSize": 20, 135 | "fontFamily": 1, 136 | "text": "user 'postgres'\nor\nsuper-user", 137 | "baseline": 68, 138 | "textAlign": "left", 139 | "verticalAlign": "top", 140 | "containerId": null, 141 | "originalText": "user 'postgres'\nor\nsuper-user" 142 | }, 143 | { 144 | "type": "text", 145 | "version": 45, 146 | "versionNonce": 440911313, 147 | "isDeleted": false, 148 | "id": "swNg3khuQzQiAcvlhBLQt", 149 | "fillStyle": "hachure", 150 | "strokeWidth": 1, 151 | "strokeStyle": "solid", 152 | "roughness": 1, 153 | "opacity": 100, 154 | "angle": 0, 155 | "x": 800, 156 | "y": 360, 157 | "strokeColor": "#000000", 158 | "backgroundColor": "transparent", 159 | "width": 215, 160 | "height": 25, 161 | "seed": 911333631, 162 | "groupIds": [], 163 | "strokeSharpness": "sharp", 164 | "boundElements": [ 165 | { 166 | "id": "fs3uA4kk_LJzvj6WsE1eR", 167 | "type": "arrow" 168 | } 169 | ], 170 | "updated": 1640117175339, 171 | "fontSize": 20, 172 | "fontFamily": 1, 173 | "text": "Application Admin Role", 174 | "baseline": 18, 175 | "textAlign": "left", 176 | "verticalAlign": "top", 177 | "containerId": null, 178 | "originalText": "Application Admin Role" 179 | }, 180 | { 181 | "type": "arrow", 182 | "version": 99, 183 | "versionNonce": 1829309368, 184 | "isDeleted": false, 185 | "id": "JsQee1CZsxMflDtnJYmoG", 186 | "fillStyle": "hachure", 187 | "strokeWidth": 1, 188 | "strokeStyle": "solid", 189 | "roughness": 1, 190 | "opacity": 100, 191 | "angle": 0, 192 | "x": 660, 193 | "y": 300.00000000177386, 194 | "strokeColor": "#000000", 195 | "backgroundColor": "transparent", 196 | "width": 179, 197 | "height": 1.2913687896798365e-9, 198 | "seed": 2084684369, 199 | "groupIds": [], 200 | "strokeSharpness": "round", 201 | "boundElements": [], 202 | "updated": 1641594417619, 203 | "startBinding": { 204 | "elementId": "ZGNQIqdmRrmVsow0oe5C8", 205 | "focus": 0.0810810810810811, 206 | "gap": 6 207 | }, 208 | "endBinding": { 209 | "elementId": "PgXUgmO92DhydHPO4c4RD", 210 | "focus": 0.058823529411764705, 211 | "gap": 1 212 | }, 213 | "lastCommittedPoint": null, 214 | "startArrowhead": null, 215 | "endArrowhead": "arrow", 216 | "points": [ 217 | [ 218 | 0, 219 | 0 220 | ], 221 | [ 222 | 179, 223 | -1.2913687896798365e-9 224 | ] 225 | ] 226 | }, 227 | { 228 | "type": "text", 229 | "version": 14, 230 | "versionNonce": 609823039, 231 | "isDeleted": false, 232 | "id": "5XZrBM0G-ueZeuq1LGxKK", 233 | "fillStyle": "hachure", 234 | "strokeWidth": 1, 235 | "strokeStyle": "solid", 236 | "roughness": 1, 237 | "opacity": 100, 238 | "angle": 0, 239 | "x": 700, 240 | "y": 260, 241 | "strokeColor": "#000000", 242 | "backgroundColor": "transparent", 243 | "width": 66, 244 | "height": 25, 245 | "seed": 160733247, 246 | "groupIds": [], 247 | "strokeSharpness": "sharp", 248 | "boundElements": [], 249 | "updated": 1640116604199, 250 | "fontSize": 20, 251 | "fontFamily": 1, 252 | "text": "create", 253 | "baseline": 18, 254 | "textAlign": "left", 255 | "verticalAlign": "top", 256 | "containerId": null, 257 | "originalText": "create" 258 | }, 259 | { 260 | "type": "text", 261 | "version": 20, 262 | "versionNonce": 1688246591, 263 | "isDeleted": false, 264 | "id": "rwqR1rImrR1KXidtiL21W", 265 | "fillStyle": "hachure", 266 | "strokeWidth": 1, 267 | "strokeStyle": "solid", 268 | "roughness": 1, 269 | "opacity": 100, 270 | "angle": 0, 271 | "x": 1136, 272 | "y": 157, 273 | "strokeColor": "#000000", 274 | "backgroundColor": "transparent", 275 | "width": 200, 276 | "height": 25, 277 | "seed": 1843186577, 278 | "groupIds": [], 279 | "strokeSharpness": "sharp", 280 | "boundElements": [ 281 | { 282 | "id": "UnGhNOcSF_FoSQi1oGdvf", 283 | "type": "arrow" 284 | } 285 | ], 286 | "updated": 1640117643540, 287 | "fontSize": 20, 288 | "fontFamily": 1, 289 | "text": "Database + schema", 290 | "baseline": 18, 291 | "textAlign": "left", 292 | "verticalAlign": "top", 293 | "containerId": null, 294 | "originalText": "Database + schema" 295 | }, 296 | { 297 | "type": "arrow", 298 | "version": 121, 299 | "versionNonce": 1885263032, 300 | "isDeleted": false, 301 | "id": "UnGhNOcSF_FoSQi1oGdvf", 302 | "fillStyle": "hachure", 303 | "strokeWidth": 1, 304 | "strokeStyle": "solid", 305 | "roughness": 1, 306 | "opacity": 100, 307 | "angle": 0, 308 | "x": 981, 309 | "y": 298.18101032459657, 310 | "strokeColor": "#000000", 311 | "backgroundColor": "transparent", 312 | "width": 140.34388991312835, 313 | "height": 109.76240945513163, 314 | "seed": 945427103, 315 | "groupIds": [], 316 | "strokeSharpness": "round", 317 | "boundElements": [], 318 | "updated": 1641594417619, 319 | "startBinding": { 320 | "elementId": "PgXUgmO92DhydHPO4c4RD", 321 | "focus": 0.5263157894736842, 322 | "gap": 1 323 | }, 324 | "endBinding": { 325 | "elementId": "rwqR1rImrR1KXidtiL21W", 326 | "focus": 0.78, 327 | "gap": 16 328 | }, 329 | "lastCommittedPoint": null, 330 | "startArrowhead": null, 331 | "endArrowhead": "arrow", 332 | "points": [ 333 | [ 334 | 0, 335 | 0 336 | ], 337 | [ 338 | 140.34388991312835, 339 | -109.76240945513163 340 | ] 341 | ] 342 | }, 343 | { 344 | "type": "text", 345 | "version": 65, 346 | "versionNonce": 511525553, 347 | "isDeleted": false, 348 | "id": "cAjc0FHCCImV0Pf_GVsyk", 349 | "fillStyle": "hachure", 350 | "strokeWidth": 1, 351 | "strokeStyle": "solid", 352 | "roughness": 1, 353 | "opacity": 100, 354 | "angle": 0, 355 | "x": 1180, 356 | "y": 300, 357 | "strokeColor": "#000000", 358 | "backgroundColor": "transparent", 359 | "width": 238, 360 | "height": 25, 361 | "seed": 1146587327, 362 | "groupIds": [], 363 | "strokeSharpness": "sharp", 364 | "boundElements": [ 365 | { 366 | "id": "U-7HJBwjCMpEF_qZoEA0o", 367 | "type": "arrow" 368 | }, 369 | { 370 | "id": "ycChXnhlXWkTK9vlPvaRf", 371 | "type": "arrow" 372 | }, 373 | { 374 | "id": "fIVQAtg5TlTuuwKzGkZ00", 375 | "type": "arrow" 376 | } 377 | ], 378 | "updated": 1640117352140, 379 | "fontSize": 20, 380 | "fontFamily": 1, 381 | "text": "Tables inside Database", 382 | "baseline": 18, 383 | "textAlign": "left", 384 | "verticalAlign": "top", 385 | "containerId": null, 386 | "originalText": "Tables inside Database" 387 | }, 388 | { 389 | "type": "text", 390 | "version": 9, 391 | "versionNonce": 162855985, 392 | "isDeleted": false, 393 | "id": "nUMH0ER8Y-gc__9XhE1bC", 394 | "fillStyle": "hachure", 395 | "strokeWidth": 1, 396 | "strokeStyle": "solid", 397 | "roughness": 1, 398 | "opacity": 100, 399 | "angle": 0, 400 | "x": 1020, 401 | "y": 214, 402 | "strokeColor": "#000000", 403 | "backgroundColor": "transparent", 404 | "width": 66, 405 | "height": 25, 406 | "seed": 1942279839, 407 | "groupIds": [], 408 | "strokeSharpness": "sharp", 409 | "boundElements": [ 410 | { 411 | "id": "fs3uA4kk_LJzvj6WsE1eR", 412 | "type": "arrow" 413 | } 414 | ], 415 | "updated": 1640117197030, 416 | "fontSize": 20, 417 | "fontFamily": 1, 418 | "text": "create", 419 | "baseline": 18, 420 | "textAlign": "left", 421 | "verticalAlign": "top", 422 | "containerId": null, 423 | "originalText": "create" 424 | }, 425 | { 426 | "type": "arrow", 427 | "version": 57, 428 | "versionNonce": 1764750024, 429 | "isDeleted": false, 430 | "id": "gRg2SFar3ISdP0r2NvDz2", 431 | "fillStyle": "hachure", 432 | "strokeWidth": 1, 433 | "strokeStyle": "solid", 434 | "roughness": 1, 435 | "opacity": 100, 436 | "angle": 0, 437 | "x": 981, 438 | "y": 320, 439 | "strokeColor": "#000000", 440 | "backgroundColor": "transparent", 441 | "width": 179, 442 | "height": 0, 443 | "seed": 1901521151, 444 | "groupIds": [], 445 | "strokeSharpness": "round", 446 | "boundElements": [], 447 | "updated": 1641594417619, 448 | "startBinding": { 449 | "elementId": "PgXUgmO92DhydHPO4c4RD", 450 | "focus": 0.4117647058823529, 451 | "gap": 1 452 | }, 453 | "endBinding": null, 454 | "lastCommittedPoint": null, 455 | "startArrowhead": null, 456 | "endArrowhead": "arrow", 457 | "points": [ 458 | [ 459 | 0, 460 | 0 461 | ], 462 | [ 463 | 179, 464 | 0 465 | ] 466 | ] 467 | }, 468 | { 469 | "type": "text", 470 | "version": 8, 471 | "versionNonce": 1913699281, 472 | "isDeleted": false, 473 | "id": "YJi7lK5c__MtqubsW8Q3H", 474 | "fillStyle": "hachure", 475 | "strokeWidth": 1, 476 | "strokeStyle": "solid", 477 | "roughness": 1, 478 | "opacity": 100, 479 | "angle": 0, 480 | "x": 1049, 481 | "y": 299, 482 | "strokeColor": "#000000", 483 | "backgroundColor": "transparent", 484 | "width": 66, 485 | "height": 25, 486 | "seed": 1243774271, 487 | "groupIds": [], 488 | "strokeSharpness": "sharp", 489 | "boundElements": [], 490 | "updated": 1640116927996, 491 | "fontSize": 20, 492 | "fontFamily": 1, 493 | "text": "create", 494 | "baseline": 18, 495 | "textAlign": "left", 496 | "verticalAlign": "top", 497 | "containerId": null, 498 | "originalText": "create" 499 | }, 500 | { 501 | "type": "text", 502 | "version": 12, 503 | "versionNonce": 1113967048, 504 | "isDeleted": false, 505 | "id": "wCoXM4m4KhQ4kIRvjz4EE", 506 | "fillStyle": "hachure", 507 | "strokeWidth": 1, 508 | "strokeStyle": "solid", 509 | "roughness": 1, 510 | "opacity": 100, 511 | "angle": 0, 512 | "x": 1040, 513 | "y": 360, 514 | "strokeColor": "#000000", 515 | "backgroundColor": "transparent", 516 | "width": 66, 517 | "height": 25, 518 | "seed": 798923704, 519 | "groupIds": [], 520 | "strokeSharpness": "sharp", 521 | "boundElements": [], 522 | "updated": 1641594452945, 523 | "fontSize": 20, 524 | "fontFamily": 1, 525 | "text": "create", 526 | "baseline": 18, 527 | "textAlign": "left", 528 | "verticalAlign": "top", 529 | "containerId": null, 530 | "originalText": "create" 531 | }, 532 | { 533 | "type": "text", 534 | "version": 45, 535 | "versionNonce": 1878978353, 536 | "isDeleted": false, 537 | "id": "Fy4XTlFPh1iW-ZYOYrDxF", 538 | "fillStyle": "hachure", 539 | "strokeWidth": 1, 540 | "strokeStyle": "solid", 541 | "roughness": 1, 542 | "opacity": 100, 543 | "angle": 0, 544 | "x": 1160, 545 | "y": 620, 546 | "strokeColor": "#000000", 547 | "backgroundColor": "transparent", 548 | "width": 102, 549 | "height": 25, 550 | "seed": 1410422239, 551 | "groupIds": [], 552 | "strokeSharpness": "sharp", 553 | "boundElements": [], 554 | "updated": 1640117260992, 555 | "fontSize": 20, 556 | "fontFamily": 1, 557 | "text": "Write Role", 558 | "baseline": 18, 559 | "textAlign": "left", 560 | "verticalAlign": "top", 561 | "containerId": null, 562 | "originalText": "Write Role" 563 | }, 564 | { 565 | "type": "text", 566 | "version": 42, 567 | "versionNonce": 2042882815, 568 | "isDeleted": false, 569 | "id": "CnTPlYt0ojAFoKInH7fOA", 570 | "fillStyle": "hachure", 571 | "strokeWidth": 1, 572 | "strokeStyle": "solid", 573 | "roughness": 1, 574 | "opacity": 100, 575 | "angle": 0, 576 | "x": 1180, 577 | "y": 420, 578 | "strokeColor": "#000000", 579 | "backgroundColor": "transparent", 580 | "width": 285, 581 | "height": 25, 582 | "seed": 1138479199, 583 | "groupIds": [], 584 | "strokeSharpness": "sharp", 585 | "boundElements": [], 586 | "updated": 1640117371637, 587 | "fontSize": 20, 588 | "fontFamily": 1, 589 | "text": "select /insert/update/delete", 590 | "baseline": 18, 591 | "textAlign": "left", 592 | "verticalAlign": "top", 593 | "containerId": null, 594 | "originalText": "select /insert/update/delete" 595 | }, 596 | { 597 | "type": "arrow", 598 | "version": 222, 599 | "versionNonce": 2014543288, 600 | "isDeleted": false, 601 | "id": "fs3uA4kk_LJzvj6WsE1eR", 602 | "fillStyle": "hachure", 603 | "strokeWidth": 1, 604 | "strokeStyle": "solid", 605 | "roughness": 0, 606 | "opacity": 100, 607 | "angle": 0, 608 | "x": 915.0084127874368, 609 | "y": 360, 610 | "strokeColor": "#000000", 611 | "backgroundColor": "transparent", 612 | "width": 4.991587212563331, 613 | "height": 178.9999999999999, 614 | "seed": 1385079903, 615 | "groupIds": [], 616 | "strokeSharpness": "round", 617 | "boundElements": [], 618 | "updated": 1641594417620, 619 | "startBinding": { 620 | "elementId": "PgXUgmO92DhydHPO4c4RD", 621 | "focus": -0.047832585949177324, 622 | "gap": 15 623 | }, 624 | "endBinding": null, 625 | "lastCommittedPoint": null, 626 | "startArrowhead": null, 627 | "endArrowhead": "arrow", 628 | "points": [ 629 | [ 630 | 0, 631 | 0 632 | ], 633 | [ 634 | 4.991587212563331, 635 | 178.9999999999999 636 | ] 637 | ] 638 | }, 639 | { 640 | "type": "text", 641 | "version": 23, 642 | "versionNonce": 1960933553, 643 | "isDeleted": false, 644 | "id": "yxL_mrZgmuCbzB0EHh0Jg", 645 | "fillStyle": "hachure", 646 | "strokeWidth": 1, 647 | "strokeStyle": "solid", 648 | "roughness": 0, 649 | "opacity": 100, 650 | "angle": 0, 651 | "x": 880, 652 | "y": 620, 653 | "strokeColor": "#000000", 654 | "backgroundColor": "transparent", 655 | "width": 140, 656 | "height": 25, 657 | "seed": 1609439793, 658 | "groupIds": [], 659 | "strokeSharpness": "sharp", 660 | "boundElements": [], 661 | "updated": 1640117115054, 662 | "fontSize": 20, 663 | "fontFamily": 1, 664 | "text": "ReadOnly Role", 665 | "baseline": 18, 666 | "textAlign": "left", 667 | "verticalAlign": "top", 668 | "containerId": null, 669 | "originalText": "ReadOnly Role" 670 | }, 671 | { 672 | "type": "rectangle", 673 | "version": 258, 674 | "versionNonce": 916662472, 675 | "isDeleted": false, 676 | "id": "PgXUgmO92DhydHPO4c4RD", 677 | "fillStyle": "hachure", 678 | "strokeWidth": 1, 679 | "strokeStyle": "solid", 680 | "roughness": 0, 681 | "opacity": 100, 682 | "angle": 0, 683 | "x": 840, 684 | "y": 260, 685 | "strokeColor": "#000000", 686 | "backgroundColor": "transparent", 687 | "width": 140, 688 | "height": 85, 689 | "seed": 1544923071, 690 | "groupIds": [], 691 | "strokeSharpness": "sharp", 692 | "boundElements": [ 693 | { 694 | "type": "text", 695 | "id": "w0N7f3ezaS8vifFH-8yLd" 696 | }, 697 | { 698 | "id": "JsQee1CZsxMflDtnJYmoG", 699 | "type": "arrow" 700 | }, 701 | { 702 | "id": "UnGhNOcSF_FoSQi1oGdvf", 703 | "type": "arrow" 704 | }, 705 | { 706 | "id": "gRg2SFar3ISdP0r2NvDz2", 707 | "type": "arrow" 708 | }, 709 | { 710 | "id": "fs3uA4kk_LJzvj6WsE1eR", 711 | "type": "arrow" 712 | } 713 | ], 714 | "updated": 1641594417571 715 | }, 716 | { 717 | "type": "rectangle", 718 | "version": 266, 719 | "versionNonce": 1568548383, 720 | "isDeleted": false, 721 | "id": "YOvj5fIbctNgV7xG9-OLK", 722 | "fillStyle": "hachure", 723 | "strokeWidth": 1, 724 | "strokeStyle": "solid", 725 | "roughness": 0, 726 | "opacity": 100, 727 | "angle": 0, 728 | "x": 860, 729 | "y": 520, 730 | "strokeColor": "#000000", 731 | "backgroundColor": "transparent", 732 | "width": 140, 733 | "height": 85, 734 | "seed": 2117228543, 735 | "groupIds": [], 736 | "strokeSharpness": "sharp", 737 | "boundElements": [ 738 | { 739 | "id": "lG-7UiokO-k2wza2oFDK7", 740 | "type": "text" 741 | }, 742 | { 743 | "id": "JsQee1CZsxMflDtnJYmoG", 744 | "type": "arrow" 745 | }, 746 | { 747 | "id": "UnGhNOcSF_FoSQi1oGdvf", 748 | "type": "arrow" 749 | }, 750 | { 751 | "id": "gRg2SFar3ISdP0r2NvDz2", 752 | "type": "arrow" 753 | }, 754 | { 755 | "type": "text", 756 | "id": "lG-7UiokO-k2wza2oFDK7" 757 | } 758 | ], 759 | "updated": 1640117193216 760 | }, 761 | { 762 | "type": "rectangle", 763 | "version": 307, 764 | "versionNonce": 1632554680, 765 | "isDeleted": false, 766 | "id": "UbxFLoEWt_kCcLLtlzF7g", 767 | "fillStyle": "hachure", 768 | "strokeWidth": 1, 769 | "strokeStyle": "solid", 770 | "roughness": 0, 771 | "opacity": 100, 772 | "angle": 0, 773 | "x": 1140, 774 | "y": 520, 775 | "strokeColor": "#000000", 776 | "backgroundColor": "transparent", 777 | "width": 140, 778 | "height": 85, 779 | "seed": 629013791, 780 | "groupIds": [], 781 | "strokeSharpness": "sharp", 782 | "boundElements": [ 783 | { 784 | "id": "Vp64flTlg6cJPCKSp94ZU", 785 | "type": "text" 786 | }, 787 | { 788 | "id": "JsQee1CZsxMflDtnJYmoG", 789 | "type": "arrow" 790 | }, 791 | { 792 | "id": "UnGhNOcSF_FoSQi1oGdvf", 793 | "type": "arrow" 794 | }, 795 | { 796 | "id": "gRg2SFar3ISdP0r2NvDz2", 797 | "type": "arrow" 798 | }, 799 | { 800 | "id": "Vp64flTlg6cJPCKSp94ZU", 801 | "type": "text" 802 | }, 803 | { 804 | "type": "text", 805 | "id": "Vp64flTlg6cJPCKSp94ZU" 806 | }, 807 | { 808 | "id": "FBgc4K4AZxRT4MM7JlBoo", 809 | "type": "arrow" 810 | } 811 | ], 812 | "updated": 1641594427015 813 | }, 814 | { 815 | "type": "text", 816 | "version": 53, 817 | "versionNonce": 1461102520, 818 | "isDeleted": false, 819 | "id": "w0N7f3ezaS8vifFH-8yLd", 820 | "fillStyle": "hachure", 821 | "strokeWidth": 1, 822 | "strokeStyle": "solid", 823 | "roughness": 0, 824 | "opacity": 100, 825 | "angle": 0, 826 | "x": 870, 827 | "y": 290, 828 | "strokeColor": "#000000", 829 | "backgroundColor": "transparent", 830 | "width": 80, 831 | "height": 25, 832 | "seed": 44154609, 833 | "groupIds": [], 834 | "strokeSharpness": "sharp", 835 | "boundElements": [], 836 | "updated": 1641594417571, 837 | "fontSize": 20, 838 | "fontFamily": 1, 839 | "text": "Role", 840 | "baseline": 18, 841 | "textAlign": "center", 842 | "verticalAlign": "middle", 843 | "containerId": "PgXUgmO92DhydHPO4c4RD", 844 | "originalText": "Role" 845 | }, 846 | { 847 | "type": "text", 848 | "version": 61, 849 | "versionNonce": 1568018257, 850 | "isDeleted": false, 851 | "id": "lG-7UiokO-k2wza2oFDK7", 852 | "fillStyle": "hachure", 853 | "strokeWidth": 1, 854 | "strokeStyle": "solid", 855 | "roughness": 0, 856 | "opacity": 100, 857 | "angle": 0, 858 | "x": 890, 859 | "y": 550, 860 | "strokeColor": "#000000", 861 | "backgroundColor": "transparent", 862 | "width": 80, 863 | "height": 25, 864 | "seed": 1992190833, 865 | "groupIds": [], 866 | "strokeSharpness": "sharp", 867 | "boundElements": [], 868 | "updated": 1640117193216, 869 | "fontSize": 20, 870 | "fontFamily": 1, 871 | "text": "Role", 872 | "baseline": 18, 873 | "textAlign": "center", 874 | "verticalAlign": "middle", 875 | "containerId": "YOvj5fIbctNgV7xG9-OLK", 876 | "originalText": "Role" 877 | }, 878 | { 879 | "type": "text", 880 | "version": 99, 881 | "versionNonce": 861624305, 882 | "isDeleted": false, 883 | "id": "Vp64flTlg6cJPCKSp94ZU", 884 | "fillStyle": "hachure", 885 | "strokeWidth": 1, 886 | "strokeStyle": "solid", 887 | "roughness": 0, 888 | "opacity": 100, 889 | "angle": 0, 890 | "x": 1150, 891 | "y": 550, 892 | "strokeColor": "#000000", 893 | "backgroundColor": "transparent", 894 | "width": 80, 895 | "height": 25, 896 | "seed": 2000889937, 897 | "groupIds": [], 898 | "strokeSharpness": "sharp", 899 | "boundElements": [], 900 | "updated": 1640117255687, 901 | "fontSize": 20, 902 | "fontFamily": 1, 903 | "text": "Role", 904 | "baseline": 18, 905 | "textAlign": "center", 906 | "verticalAlign": "middle", 907 | "containerId": "UbxFLoEWt_kCcLLtlzF7g", 908 | "originalText": "Role" 909 | }, 910 | { 911 | "type": "text", 912 | "version": 13, 913 | "versionNonce": 1188531025, 914 | "isDeleted": false, 915 | "id": "b-Zl82ctVYdfyi8YinFTl", 916 | "fillStyle": "hachure", 917 | "strokeWidth": 1, 918 | "strokeStyle": "solid", 919 | "roughness": 0, 920 | "opacity": 100, 921 | "angle": 0, 922 | "x": 880, 923 | "y": 440, 924 | "strokeColor": "#000000", 925 | "backgroundColor": "transparent", 926 | "width": 66, 927 | "height": 25, 928 | "seed": 1646705983, 929 | "groupIds": [], 930 | "strokeSharpness": "sharp", 931 | "boundElements": [], 932 | "updated": 1640117212833, 933 | "fontSize": 20, 934 | "fontFamily": 1, 935 | "text": "create", 936 | "baseline": 18, 937 | "textAlign": "left", 938 | "verticalAlign": "top", 939 | "containerId": null, 940 | "originalText": "create" 941 | }, 942 | { 943 | "type": "line", 944 | "version": 7, 945 | "versionNonce": 189323185, 946 | "isDeleted": false, 947 | "id": "IgG6BTDDsVRIxhiaS9y5l", 948 | "fillStyle": "hachure", 949 | "strokeWidth": 1, 950 | "strokeStyle": "solid", 951 | "roughness": 0, 952 | "opacity": 100, 953 | "angle": 0, 954 | "x": 940, 955 | "y": 720, 956 | "strokeColor": "#000000", 957 | "backgroundColor": "transparent", 958 | "width": 0, 959 | "height": 60, 960 | "seed": 67029521, 961 | "groupIds": [], 962 | "strokeSharpness": "round", 963 | "boundElements": [], 964 | "updated": 1640117225728, 965 | "startBinding": null, 966 | "endBinding": null, 967 | "lastCommittedPoint": null, 968 | "startArrowhead": null, 969 | "endArrowhead": null, 970 | "points": [ 971 | [ 972 | 0, 973 | 0 974 | ], 975 | [ 976 | 0, 977 | -60 978 | ] 979 | ] 980 | }, 981 | { 982 | "type": "line", 983 | "version": 23, 984 | "versionNonce": 1145228785, 985 | "isDeleted": false, 986 | "id": "D8KLYhSYDkbIURybKKZjO", 987 | "fillStyle": "hachure", 988 | "strokeWidth": 1, 989 | "strokeStyle": "solid", 990 | "roughness": 0, 991 | "opacity": 100, 992 | "angle": 0, 993 | "x": 1220, 994 | "y": 720, 995 | "strokeColor": "#000000", 996 | "backgroundColor": "transparent", 997 | "width": 0, 998 | "height": 60, 999 | "seed": 1175743359, 1000 | "groupIds": [], 1001 | "strokeSharpness": "round", 1002 | "boundElements": [], 1003 | "updated": 1640117321555, 1004 | "startBinding": null, 1005 | "endBinding": null, 1006 | "lastCommittedPoint": null, 1007 | "startArrowhead": null, 1008 | "endArrowhead": null, 1009 | "points": [ 1010 | [ 1011 | 0, 1012 | 0 1013 | ], 1014 | [ 1015 | 0, 1016 | -60 1017 | ] 1018 | ] 1019 | }, 1020 | { 1021 | "type": "text", 1022 | "version": 13, 1023 | "versionNonce": 1560631441, 1024 | "isDeleted": false, 1025 | "id": "iyUF9jwzV4n4AbARbeLJL", 1026 | "fillStyle": "hachure", 1027 | "strokeWidth": 1, 1028 | "strokeStyle": "solid", 1029 | "roughness": 0, 1030 | "opacity": 100, 1031 | "angle": 0, 1032 | "x": 920, 1033 | "y": 680, 1034 | "strokeColor": "#000000", 1035 | "backgroundColor": "transparent", 1036 | "width": 71, 1037 | "height": 25, 1038 | "seed": 190456831, 1039 | "groupIds": [], 1040 | "strokeSharpness": "sharp", 1041 | "boundElements": [], 1042 | "updated": 1640117232884, 1043 | "fontSize": 20, 1044 | "fontFamily": 1, 1045 | "text": "inherits", 1046 | "baseline": 18, 1047 | "textAlign": "left", 1048 | "verticalAlign": "top", 1049 | "containerId": null, 1050 | "originalText": "inherits" 1051 | }, 1052 | { 1053 | "type": "text", 1054 | "version": 29, 1055 | "versionNonce": 814613919, 1056 | "isDeleted": false, 1057 | "id": "4CADzREncA4NFdCeJEYRj", 1058 | "fillStyle": "hachure", 1059 | "strokeWidth": 1, 1060 | "strokeStyle": "solid", 1061 | "roughness": 0, 1062 | "opacity": 100, 1063 | "angle": 0, 1064 | "x": 1200, 1065 | "y": 680, 1066 | "strokeColor": "#000000", 1067 | "backgroundColor": "transparent", 1068 | "width": 71, 1069 | "height": 25, 1070 | "seed": 609832945, 1071 | "groupIds": [], 1072 | "strokeSharpness": "sharp", 1073 | "boundElements": [], 1074 | "updated": 1640117321555, 1075 | "fontSize": 20, 1076 | "fontFamily": 1, 1077 | "text": "inherits", 1078 | "baseline": 18, 1079 | "textAlign": "left", 1080 | "verticalAlign": "top", 1081 | "containerId": null, 1082 | "originalText": "inherits" 1083 | }, 1084 | { 1085 | "type": "arrow", 1086 | "version": 16, 1087 | "versionNonce": 347736415, 1088 | "isDeleted": false, 1089 | "id": "ycChXnhlXWkTK9vlPvaRf", 1090 | "fillStyle": "hachure", 1091 | "strokeWidth": 1, 1092 | "strokeStyle": "solid", 1093 | "roughness": 0, 1094 | "opacity": 100, 1095 | "angle": 0, 1096 | "x": 1220, 1097 | "y": 540, 1098 | "strokeColor": "#000000", 1099 | "backgroundColor": "transparent", 1100 | "width": 80, 1101 | "height": 200, 1102 | "seed": 630972689, 1103 | "groupIds": [], 1104 | "strokeSharpness": "round", 1105 | "boundElements": [], 1106 | "updated": 1640117266845, 1107 | "startBinding": null, 1108 | "endBinding": { 1109 | "elementId": "cAjc0FHCCImV0Pf_GVsyk", 1110 | "focus": -0.0967741935483871, 1111 | "gap": 15 1112 | }, 1113 | "lastCommittedPoint": null, 1114 | "startArrowhead": null, 1115 | "endArrowhead": "arrow", 1116 | "points": [ 1117 | [ 1118 | 0, 1119 | 0 1120 | ], 1121 | [ 1122 | 80, 1123 | -200 1124 | ] 1125 | ] 1126 | }, 1127 | { 1128 | "type": "text", 1129 | "version": 32, 1130 | "versionNonce": 414696415, 1131 | "isDeleted": false, 1132 | "id": "tnXbNn2wQEKOaK6DPrXzk", 1133 | "fillStyle": "hachure", 1134 | "strokeWidth": 1, 1135 | "strokeStyle": "solid", 1136 | "roughness": 0, 1137 | "opacity": 100, 1138 | "angle": 0, 1139 | "x": 840, 1140 | "y": 820, 1141 | "strokeColor": "#000000", 1142 | "backgroundColor": "transparent", 1143 | "width": 197, 1144 | "height": 25, 1145 | "seed": 874344895, 1146 | "groupIds": [], 1147 | "strokeSharpness": "sharp", 1148 | "boundElements": [], 1149 | "updated": 1640117295773, 1150 | "fontSize": 20, 1151 | "fontFamily": 1, 1152 | "text": "Application reporting", 1153 | "baseline": 18, 1154 | "textAlign": "left", 1155 | "verticalAlign": "top", 1156 | "containerId": null, 1157 | "originalText": "Application reporting" 1158 | }, 1159 | { 1160 | "type": "text", 1161 | "version": 50, 1162 | "versionNonce": 819162911, 1163 | "isDeleted": false, 1164 | "id": "nF6-ddPR7E8BljeYKWeyu", 1165 | "fillStyle": "hachure", 1166 | "strokeWidth": 1, 1167 | "strokeStyle": "solid", 1168 | "roughness": 0, 1169 | "opacity": 100, 1170 | "angle": 0, 1171 | "x": 1120, 1172 | "y": 820, 1173 | "strokeColor": "#000000", 1174 | "backgroundColor": "transparent", 1175 | "width": 188, 1176 | "height": 25, 1177 | "seed": 1424672177, 1178 | "groupIds": [], 1179 | "strokeSharpness": "sharp", 1180 | "boundElements": [], 1181 | "updated": 1640117330689, 1182 | "fontSize": 20, 1183 | "fontFamily": 1, 1184 | "text": "Application backend", 1185 | "baseline": 18, 1186 | "textAlign": "left", 1187 | "verticalAlign": "top", 1188 | "containerId": null, 1189 | "originalText": "Application backend" 1190 | }, 1191 | { 1192 | "type": "arrow", 1193 | "version": 25, 1194 | "versionNonce": 2113416383, 1195 | "isDeleted": false, 1196 | "id": "fIVQAtg5TlTuuwKzGkZ00", 1197 | "fillStyle": "hachure", 1198 | "strokeWidth": 1, 1199 | "strokeStyle": "solid", 1200 | "roughness": 0, 1201 | "opacity": 100, 1202 | "angle": 0, 1203 | "x": 980, 1204 | "y": 540, 1205 | "strokeColor": "#000000", 1206 | "backgroundColor": "transparent", 1207 | "width": 260, 1208 | "height": 200, 1209 | "seed": 334902079, 1210 | "groupIds": [], 1211 | "strokeSharpness": "round", 1212 | "boundElements": [], 1213 | "updated": 1640117352140, 1214 | "startBinding": null, 1215 | "endBinding": { 1216 | "elementId": "cAjc0FHCCImV0Pf_GVsyk", 1217 | "focus": 0.17190388170055454, 1218 | "gap": 15 1219 | }, 1220 | "lastCommittedPoint": null, 1221 | "startArrowhead": null, 1222 | "endArrowhead": "arrow", 1223 | "points": [ 1224 | [ 1225 | 0, 1226 | 0 1227 | ], 1228 | [ 1229 | 260, 1230 | -200 1231 | ] 1232 | ] 1233 | }, 1234 | { 1235 | "type": "text", 1236 | "version": 14, 1237 | "versionNonce": 820179839, 1238 | "isDeleted": false, 1239 | "id": "iL5hZRjYRSJrP32fSce4U", 1240 | "fillStyle": "hachure", 1241 | "strokeWidth": 1, 1242 | "strokeStyle": "solid", 1243 | "roughness": 0, 1244 | "opacity": 100, 1245 | "angle": 0, 1246 | "x": 1020, 1247 | "y": 460, 1248 | "strokeColor": "#000000", 1249 | "backgroundColor": "transparent", 1250 | "width": 60, 1251 | "height": 25, 1252 | "seed": 1318491217, 1253 | "groupIds": [], 1254 | "strokeSharpness": "sharp", 1255 | "boundElements": [], 1256 | "updated": 1640117375393, 1257 | "fontSize": 20, 1258 | "fontFamily": 1, 1259 | "text": "select", 1260 | "baseline": 18, 1261 | "textAlign": "left", 1262 | "verticalAlign": "top", 1263 | "containerId": null, 1264 | "originalText": "select" 1265 | }, 1266 | { 1267 | "id": "FBgc4K4AZxRT4MM7JlBoo", 1268 | "type": "arrow", 1269 | "x": 980, 1270 | "y": 320, 1271 | "width": 200, 1272 | "height": 180, 1273 | "angle": 0, 1274 | "strokeColor": "#000000", 1275 | "backgroundColor": "transparent", 1276 | "fillStyle": "hachure", 1277 | "strokeWidth": 1, 1278 | "strokeStyle": "solid", 1279 | "roughness": 1, 1280 | "opacity": 100, 1281 | "groupIds": [], 1282 | "strokeSharpness": "round", 1283 | "seed": 1664506312, 1284 | "version": 17, 1285 | "versionNonce": 795480520, 1286 | "isDeleted": false, 1287 | "boundElements": null, 1288 | "updated": 1641594427015, 1289 | "points": [ 1290 | [ 1291 | 0, 1292 | 0 1293 | ], 1294 | [ 1295 | 200, 1296 | 180 1297 | ] 1298 | ], 1299 | "lastCommittedPoint": null, 1300 | "startBinding": null, 1301 | "endBinding": { 1302 | "elementId": "UbxFLoEWt_kCcLLtlzF7g", 1303 | "focus": 0.3364928909952607, 1304 | "gap": 20 1305 | }, 1306 | "startArrowhead": null, 1307 | "endArrowhead": "arrow" 1308 | } 1309 | ], 1310 | "appState": { 1311 | "gridSize": 20, 1312 | "viewBackgroundColor": "#ffffff" 1313 | }, 1314 | "files": { 1315 | "d1564f04fc39917fb6ab5b1bbcc0930445457501": { 1316 | "mimeType": "image/png", 1317 | "id": "d1564f04fc39917fb6ab5b1bbcc0930445457501", 1318 | "dataURL": "data:image/png;base64,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", 1319 | "created": 1640116504462 1320 | } 1321 | } 1322 | } -------------------------------------------------------------------------------- /schemas/ELK1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jparnaudeau/terraform-postgresql-database-admin/ef34b2d22741907712c405a555013ff71da3fbab/schemas/ELK1.png -------------------------------------------------------------------------------- /schemas/FakeApplication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jparnaudeau/terraform-postgresql-database-admin/ef34b2d22741907712c405a555013ff71da3fbab/schemas/FakeApplication.png --------------------------------------------------------------------------------