diff --git a/README.md b/README.md index 9d97554..8714f26 100644 --- a/README.md +++ b/README.md @@ -60,40 +60,59 @@ docker compose exec vault bao status # Sealed должно быть f Секреты из `.env` превращаются в переменные, которые ждут инструменты (`TF_VAR_*` для Terraform, `AWS_*` для backend), в секции `[env]` файла `mise.toml`. -## Как добавить сервис +## Бакеты, базы и сервисы -1. Описать его в `config.yaml`: +В `config.yaml` отдельно описано, что существует, и отдельно — кто имеет к этому доступ: - ```yaml - services: - orders: - postgres: - username: orders - name: orders - pgvector: true # необязательно, по умолчанию false - buckets: - orders-files: - versioning: true # по умолчанию true - noncurrent_days: 30 # сколько хранить старые версии - ``` +```yaml +buckets: + orders-files: + versioning: true + noncurrent_days: 30 + reports: + versioning: false + expire_days: 90 - Блоки `postgres` и `buckets` необязательны: сервису можно дать только базу или только бакеты. +databases: + orders: + pgvector: true + analytics: {} -2. Применить по очереди, читая `plan` перед каждым `apply`: +services: + orders: + buckets: + orders-files: rw + reports: ro + databases: [orders, analytics] +``` - ```bash - mise run tf vault apply # политика и роль AppRole - mise run tf storage apply # бакеты и ключи S3 - mise run tf database apply # база, роль, пароли - ``` +| Раздел | Поле | Значение | +|---|---|---| +| `buckets` | `versioning` | хранить старые версии объектов, по умолчанию `true` | +| | `noncurrent_days` | сколько дней хранить старые версии, по умолчанию 30 | +| | `expire_days` | удалять объекты через столько дней; по умолчанию не удалять | +| `databases` | `pgvector` | установить расширение `vector`, по умолчанию `false` | +| `services` | `buckets` | бакет и доступ к нему: `rw` или `ro` | +| | `databases` | базы, в которых сервис работает | -3. Проверить: `mise run vault:app-check orders`. +Бакет или база без сервиса — это просто запись в `buckets` или `databases`. Один бакет и одну базу можно дать нескольким сервисам. Сервис — это учётная запись: для него создаются ключи S3, роли в Postgres, политика и роль AppRole в Vault. -Удаление сервиса — те же шаги в обратном порядке (`database`, `storage`, `vault`) после удаления блока из `config.yaml`. Бакет должен быть пустым. Переименование бакета или базы Terraform воспринимает как удаление старого и создание нового. +После правки применить по очереди, читая `plan` перед каждым `apply`: -Имена бакетов должны быть уникальны между сервисами; удобно начинать их с имени сервиса. +```bash +mise run tf vault apply # политики и роли AppRole +mise run tf storage apply # бакеты и ключи S3 +mise run tf database apply # базы, роли, пароли +mise run vault:app-check # проверить доступы +``` -Опечатка в имени необязательного поля (например, `noncurent_days`) ошибки не вызовет: лишнее поле молча отбрасывается, и берётся значение по умолчанию. После правки `config.yaml` смотрите в `plan`, что изменилось именно то, что вы хотели. +При удалении порядок обратный: `database`, `storage`, `vault`. Что стоит знать: + +- Удалить можно только пустой бакет. +- Переименование бакета или базы Terraform воспринимает как удаление старого и создание нового. +- Ссылку сервиса на несуществующий бакет или базу и недопустимое значение доступа ловит проверка на `plan`. +- Опечатка в имени необязательного поля (например, `noncurent_days`) ошибки не вызовет: лишнее поле молча отбрасывается, и берётся значение по умолчанию. Смотрите в `plan`, что изменилось именно то, что вы хотели. +- Пока живы временные роли, выданные Vault, отобрать у сервиса базу не получится: Postgres ответит `dependent privileges exist`. Нужно дождаться истечения их срока (до часа) или отозвать токены сервиса. ## Что получает сервис @@ -102,8 +121,10 @@ docker compose exec vault bao status # Sealed должно быть f | Путь в Vault | Содержимое | |---|---| | `secrets/<сервис>/s3` | `endpoint`, `buckets`, `access_key`, `secret_key` | -| `secrets/<сервис>/postgres` | постоянные `host`, `port`, `database`, `username`, `password`, `sslmode` | -| `database/creds/<сервис>` | временные `username` и `password`: Vault создаёт роль в Postgres на час и сам её удаляет | +| `secrets/<сервис>/postgres/<база>` | постоянные `host`, `port`, `database`, `username`, `password`, `sslmode` | +| `database/creds/<сервис>-<база>` | временные `username` и `password`: Vault создаёт роль в Postgres на час и сам её удаляет | + +В Postgres у сервиса отдельная роль на каждую базу, `<сервис>-<база>`. Владеет базой роль `<база>_owner` без права входа, а роли сервисов работают от её имени. Поэтому таблицы, созданные одним сервисом, доступны другому сервису той же базы. Какой пароль базы выбрать: постоянный проще и подходит по умолчанию; временный не нужно ротировать и он не лежит в KV, но сервис обязан продлевать токен Vault, иначе через час потеряет доступ к базе. @@ -123,7 +144,7 @@ curl -X POST -H "X-Vault-Token: $VAULT_TOKEN" \ В `examples/go-service/` лежит небольшой сервис, который показывает всю цепочку: при запуске он знает только адрес Vault, `role_id` и `secret_id`, а ключи S3 и пароль базы получает из Vault. ```bash -mise run example:up # первый сервис из config.yaml, постоянный пароль базы +mise run example:up # первый сервис с бакетом и базой, постоянный пароль mise run example:up <сервис> dynamic # временная роль в Postgres от Vault curl http://127.0.0.1:8090/ # состояние подключений mise run example:down @@ -132,7 +153,7 @@ mise run example:down Задача `example:up` действует как администратор: берёт `role_id`, выпускает одноразовый `secret_id`, собирает образ и запускает контейнер в сети основного Compose. Дальше работает только код из `main.go`: 1. вход через AppRole клиентом `github.com/hashicorp/vault/api`; -2. чтение `secrets/<сервис>/s3` и `secrets/<сервис>/postgres`; +2. чтение `secrets/<сервис>/s3` и `secrets/<сервис>/postgres/<база>`; 3. подключение к Postgres (`pgx`) и S3 (`minio-go`); 4. HTTP-обработчик, который показывает, под кем сервис работает в базе и сколько объектов видит в своих бакетах. @@ -158,8 +179,10 @@ infra/ ├── data/ # данные сервисов, не в git ├── modules/ │ ├── config/ # типы, проверки и значения по умолчанию для config.yaml -│ ├── app-storage/ # бакеты, lifecycle, пользователь и ключи одного сервиса -│ └── app-database/ # база, роль и права одного сервиса +│ ├── bucket/ # бакет, versioning и lifecycle +│ ├── bucket-access/ # пользователь, политика и ключи S3 одного сервиса +│ ├── database/ # база, её роль-владелец и права +│ └── database-access/ # роль сервиса в одной базе ├── vault/ # Terraform: mount, политики, AppRole ├── storage/ # Terraform: Silo и запись ключей в Vault └── database/ # Terraform: Postgres, запись паролей и временные роли @@ -180,7 +203,7 @@ infra/ Шаги проверены на чистой копии репозитория с пустым каталогом данных. -0. Скопировать репозиторий без `.env`, `data/`, `vault_keys.txt` и каталогов `.terraform/` (в git их и так нет). В `config.yaml` заменить под свой проект: логины администраторов, порты, если стандартные заняты, имена бакетов для state и бэкапов, раздел `services`. Больше ничего править не нужно. +0. Скопировать репозиторий без `.env`, `data/`, `vault_keys.txt` и каталогов `.terraform/` (в git их и так нет). В `config.yaml` заменить под свой проект: логины администраторов, порты, если стандартные заняты, имя бакета для state, разделы `buckets`, `databases` и `services`. Бакет из `infra.silo.backup_bucket` должен быть описан в `buckets`. Больше ничего править не нужно. 1. Скопировать `.env.example` в `.env` и заполнить три пароля; `VAULT_TOKEN` оставить пустым, его запишет шаг 6. Спецсимволы в паролях ломают адреса подключения, поэтому удобно генерировать их командой `openssl rand -hex 32`. Затем `mise trust` и `mise run up`. Поднимать именно этой задачей: она выдаёт Vault и pgAdmin права на их каталоги данных, без чего на чистой машине они не запускаются. 2. Инициализировать Vault, сохранить unseal-ключ и root-токен, распечатать: @@ -222,13 +245,16 @@ infra/ В коде нет комментариев, поэтому причины решений, которые не видны из самого кода, собраны здесь. -- **`depends_on` между грантами в `modules/app-database`.** Гранты `public` и `owner` меняют права одной и той же базы; без явного порядка Terraform выполнял бы их параллельно, и Postgres мог бы вернуть `tuple concurrently updated`. +- **`depends_on` между грантами в `modules/database`.** Гранты `public` и `owner` меняют права одной и той же базы; без явного порядка Terraform выполнял бы их параллельно, и Postgres мог бы вернуть `tuple concurrently updated`. - **Грант `owner`.** После отзыва прав у `public` владелец базы не может подключиться к ней без явного `CONNECT`. +- **`assume_role` в `modules/database-access`.** Роль сервиса при входе переключается на владельца базы, поэтому всё, что она создаёт, принадлежит владельцу, а не ей самой. Иначе второй сервис той же базы не смог бы изменить чужие таблицы. +- **Отдельная роль на пару сервис и база.** `assume_role` задаётся для роли целиком, а владельцы у баз разные. +- **`depends_on` в output `owner` модуля `database`.** Роль сервиса не создаётся, пока владельцу не выданы права на базу. - **`ignore_changes = [roles]` у роли `vault` в `database/main.tf`.** Членством управляет `postgresql_grant_role`; без этой строки два ресурса по очереди переписывают список ролей. - **`with_admin_option` там же.** Начиная с Postgres 16 роль с `CREATEROLE` добавляет участников только в те роли, где у неё есть `ADMIN OPTION`. -- **`ALTER ROLE ... SET role` в `creation_statements`.** Временная роль работает от имени роли сервиса, поэтому созданные ею объекты принадлежат сервису, а саму её можно удалить без ошибок. +- **`ALTER ROLE ... SET role` в `creation_statements`.** Тот же приём для временных ролей Vault: созданные ими объекты принадлежат владельцу базы, а саму роль можно удалить без ошибок. - **`delete_all_versions` у секретов.** Без него удалённый сервис оставлял бы в Vault восстановимые версии своих ключей. -- **`prevent_destroy` у бакета со state.** В нём лежит state той самой конфигурации, которая им управляет. +- **`prevent_destroy` у бакета со state.** В нём лежит state той самой конфигурации, которая им управляет. Из-за этого он описан отдельным ресурсом, а не в `buckets`: `prevent_destroy` нельзя включить по условию. - **Политика `terraform` в `vault/main.tf`.** Каждый путь взят из записи реальных запросов провайдера (`TF_LOG=DEBUG`) при добавлении и удалении сервиса. Создание и удаление mount, метода входа и самой политики в неё не входят намеренно. - **`host` и `internal_host` в `config.yaml`.** Первый — адрес с хоста, им пользуется Terraform; второй — адрес внутри сети Compose, он попадает в секреты сервисов и в настройки Vault. - **`include` в `docker-compose.yml`.** Только так Compose читает второй env-файл для подстановки переменных; сам он загружает один `.env`. diff --git a/config.yaml b/config.yaml index 7fc379b..000f2fd 100644 --- a/config.yaml +++ b/config.yaml @@ -6,6 +6,7 @@ infra: port: 5455 internal_host: postgres internal_port: 5432 + sslmode: disable pgadmin: image: dpage/pgadmin4:9.18.0 port: 5050 @@ -27,17 +28,26 @@ infra: mount: secrets database_mount: database +buckets: + app-files: + versioning: true + noncurrent_days: 30 + worker-tmp: + versioning: false + infra-backups: + versioning: false + expire_days: 30 + +databases: + app: + pgvector: true + services: app: - postgres: - username: app - name: app - pgvector: true buckets: - app-files: - versioning: true - noncurrent_days: 30 + app-files: rw + databases: [app] worker: buckets: - worker-tmp: - versioning: false + worker-tmp: rw + app-files: ro diff --git a/database/locals.tf b/database/locals.tf index a6af063..97aeef9 100644 --- a/database/locals.tf +++ b/database/locals.tf @@ -3,11 +3,9 @@ locals { } module "config" { - source = "../modules/config" - infra = local.config.infra - services = local.config.services -} - -locals { - databases = { for name, svc in module.config.services : name => svc.postgres if svc.postgres != null } + source = "../modules/config" + infra = local.config.infra + buckets = try(local.config.buckets, {}) + databases = try(local.config.databases, {}) + services = try(local.config.services, {}) } diff --git a/database/main.tf b/database/main.tf index 5980a61..7000b15 100644 --- a/database/main.tf +++ b/database/main.tf @@ -1,19 +1,31 @@ -module "app_database" { - source = "../modules/app-database" - for_each = local.databases - service = { name = each.key, database = each.value } +module "database" { + source = "../modules/database" + for_each = module.config.databases + name = each.key + owner = each.value.owner + pgvector = each.value.pgvector +} + +module "database_access" { + source = "../modules/database-access" + for_each = module.config.service_databases + role = each.value.role + owner = module.database[each.value.database].owner } resource "vault_kv_secret_v2" "postgres" { - for_each = local.databases + for_each = module.config.service_databases mount = module.config.vault.mount - name = "${each.key}/postgres" + name = "${each.value.service}/postgres/${each.value.database}" delete_all_versions = true - data_json = jsonencode(merge(module.app_database[each.key].credentials, { - host = module.config.postgres.internal_host - port = module.config.postgres.internal_port - sslmode = each.value.sslmode - })) + data_json = jsonencode({ + host = module.config.postgres.internal_host + port = module.config.postgres.internal_port + database = module.database[each.value.database].name + username = module.database_access[each.key].username + password = module.database_access[each.key].password + sslmode = module.config.postgres.sslmode + }) } resource "random_password" "vault" { @@ -33,12 +45,10 @@ resource "postgresql_role" "vault" { } resource "postgresql_grant_role" "vault" { - for_each = local.databases + for_each = module.database role = postgresql_role.vault.name - grant_role = each.value.username + grant_role = each.value.owner with_admin_option = true - - depends_on = [module.app_database] } resource "vault_database_secrets_mount" "postgres" { @@ -46,22 +56,22 @@ resource "vault_database_secrets_mount" "postgres" { postgresql { name = "postgres" - connection_url = "postgresql://{{username}}:{{password}}@${module.config.postgres.internal_host}:${module.config.postgres.internal_port}/postgres?sslmode=disable" + connection_url = "postgresql://{{username}}:{{password}}@${module.config.postgres.internal_host}:${module.config.postgres.internal_port}/postgres?sslmode=${module.config.postgres.sslmode}" username = postgresql_role.vault.name password = random_password.vault.result - allowed_roles = keys(local.databases) + allowed_roles = keys(module.config.service_databases) } } resource "vault_database_secret_backend_role" "service" { - for_each = local.databases + for_each = module.config.service_databases backend = vault_database_secrets_mount.postgres.path name = each.key db_name = vault_database_secrets_mount.postgres.postgresql[0].name creation_statements = [ - "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' IN ROLE \"${each.value.username}\";", - "ALTER ROLE \"{{name}}\" SET role = \"${each.value.username}\";", + "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' IN ROLE \"${each.value.owner}\";", + "ALTER ROLE \"{{name}}\" SET role = \"${each.value.owner}\";", ] revocation_statements = [ "DROP ROLE IF EXISTS \"{{name}}\";", diff --git a/database/provider.tf b/database/provider.tf index a7c1df3..2435856 100644 --- a/database/provider.tf +++ b/database/provider.tf @@ -8,6 +8,6 @@ provider "postgresql" { port = module.config.postgres.port username = module.config.postgres.admin_user password = var.postgres_password - sslmode = "disable" + sslmode = module.config.postgres.sslmode connect_timeout = 15 } diff --git a/examples/go-service/compose.yml b/examples/go-service/compose.yml index 80136b2..46667ff 100644 --- a/examples/go-service/compose.yml +++ b/examples/go-service/compose.yml @@ -10,6 +10,7 @@ services: VAULT_KV_MOUNT: ${VAULT_MOUNT:-secrets} VAULT_DB_MOUNT: ${VAULT_DATABASE_MOUNT:-database} SERVICE_NAME: ${SERVICE_NAME:-} + DATABASE_NAME: ${DATABASE_NAME:-} POSTGRES_CREDS: ${POSTGRES_CREDS:-static} ports: - "127.0.0.1:${EXAMPLE_PORT:-8090}:8080" diff --git a/examples/go-service/main.go b/examples/go-service/main.go index 739eace..6e7b6bd 100644 --- a/examples/go-service/main.go +++ b/examples/go-service/main.go @@ -21,6 +21,7 @@ import ( func main() { ctx := context.Background() service := mustEnv("SERVICE_NAME") + database := mustEnv("DATABASE_NAME") kvMount := mustEnv("VAULT_KV_MOUNT") client, err := vault.NewClient(vault.DefaultConfig()) @@ -34,12 +35,12 @@ func main() { kv := client.KVv2(kvMount) s3Secret, err := kv.Get(ctx, service+"/s3") check(err, "секрет s3") - pgSecret, err := kv.Get(ctx, service+"/postgres") + pgSecret, err := kv.Get(ctx, service+"/postgres/"+database) check(err, "секрет postgres") pgUser, pgPassword := str(pgSecret.Data, "username"), str(pgSecret.Data, "password") if os.Getenv("POSTGRES_CREDS") == "dynamic" { - creds, err := client.Logical().ReadWithContext(ctx, mustEnv("VAULT_DB_MOUNT")+"/creds/"+service) + creds, err := client.Logical().ReadWithContext(ctx, mustEnv("VAULT_DB_MOUNT")+"/creds/"+service+"-"+database) check(err, "временные учётные данные") pgUser, pgPassword = str(creds.Data, "username"), str(creds.Data, "password") log.Printf("postgres: временная роль %s на %dс", pgUser, creds.LeaseDuration) diff --git a/mise-tasks/example/up b/mise-tasks/example/up index 881eb33..4b9e1af 100755 --- a/mise-tasks/example/up +++ b/mise-tasks/example/up @@ -2,18 +2,21 @@ #MISE description="Запустить пример Go-сервиса: mise run example:up [сервис] [static|dynamic]" set -euo pipefail -roles=$(terraform -chdir=vault output -json approle_role_ids) -service=${1:-$(jq -r 'keys[0]' <<<"$roles")} +config=$(echo 'jsonencode(yamldecode(file("config.yaml")))' | terraform console | jq -r .) +service=${1:-$(jq -r '[.services // {} | to_entries[] | select((.value.buckets // {} | length) > 0 and (.value.databases // [] | length) > 0) | .key][0] // empty' <<<"$config")} +[[ -n "$service" ]] || { echo "в config.yaml нет сервиса с бакетом и базой" >&2; exit 1; } creds=${2:-static} +database=$(jq -r --arg s "$service" '.services[$s].databases[0] // empty' <<<"$config") +[[ -n "$database" ]] || { echo "у сервиса $service нет базы, пример рассчитан на сервис с базой и бакетом" >&2; exit 1; } compose=(docker compose -f examples/go-service/compose.yml) url=http://127.0.0.1:${EXAMPLE_PORT:-8090}/ -role_id=$(jq -er --arg s "$service" '.[$s]' <<<"$roles") || +role_id=$(terraform -chdir=vault output -json approle_role_ids | jq -er --arg s "$service" '.[$s]') || { echo "нет роли AppRole для сервиса $service" >&2; exit 1; } secret_id=$(curl -sS -X POST -H "X-Vault-Token: $VAULT_TOKEN" -d '{"ttl": "5m", "num_uses": 1}' \ "$VAULT_ADDR/v1/auth/approle/role/$service/secret-id" | jq -er .data.secret_id) -export SERVICE_NAME=$service VAULT_ROLE_ID=$role_id VAULT_SECRET_ID=$secret_id POSTGRES_CREDS=$creds +export SERVICE_NAME=$service DATABASE_NAME=$database VAULT_ROLE_ID=$role_id VAULT_SECRET_ID=$secret_id POSTGRES_CREDS=$creds INFRA_NETWORK=$(docker compose config --format json | jq -er .networks.infra.name) export INFRA_NETWORK "${compose[@]}" up -d --build --force-recreate --quiet-pull 2>&1 | tail -3 diff --git a/mise-tasks/vault/app-check b/mise-tasks/vault/app-check index 84aa330..dd5ff64 100755 --- a/mise-tasks/vault/app-check +++ b/mise-tasks/vault/app-check @@ -27,20 +27,40 @@ psql_as() { psql -h postgres -U "$1" -d "$3" -Atc "select session_user || ' работает как ' || current_user" 2>&1 | tail -1 || true } +mc_cfg=/tmp/app-check-$$ +mc_app() { docker compose exec -T object-storage mc --config-dir "$mc_cfg" "$@"; } + token="" revoke_token() { [[ -z "$token" ]] || vault_api POST auth/token/revoke-self "$token" >/dev/null 2>&1 || true token="" } -trap revoke_token EXIT +cleanup() { + revoke_token + docker compose exec -T object-storage rm -rf "$mc_cfg" >/dev/null 2>&1 || true +} +trap cleanup EXIT admin_token=$VAULT_TOKEN +CONFIG=$(echo 'jsonencode(yamldecode(file("config.yaml")))' | terraform console | jq -r .) ROLES=$(terraform -chdir=vault output -json approle_role_ids) -mapfile -t ALL < <(jq -r 'keys[]' <<<"$ROLES") +mapfile -t ALL < <(jq -r '.services // {} | keys[]' <<<"$CONFIG") +mapfile -t ALL_BUCKETS < <(jq -r '.buckets // {} | keys[]' <<<"$CONFIG") +mapfile -t ALL_DBS < <(jq -r '.databases // {} | keys[]' <<<"$CONFIG") if [[ $# -gt 0 ]]; then SERVICES=("$@"); else SERVICES=("${ALL[@]}"); fi +contains() { + local needle=$1 item + shift + for item in "$@"; do [[ "$item" == "$needle" ]] && return 0; done + return 1 +} + for svc in "${SERVICES[@]}"; do printf '\n################ %s\n' "$svc" + mapfile -t buckets < <(jq -r --arg s "$svc" '.services[$s].buckets // {} | keys[]' <<<"$CONFIG") + mapfile -t ro_buckets < <(jq -r --arg s "$svc" '.services[$s].buckets // {} | to_entries[] | select(.value == "ro") | .key' <<<"$CONFIG") + mapfile -t dbs < <(jq -r --arg s "$svc" '.services[$s].databases // [] | .[]' <<<"$CONFIG") role_id=$(jq -r --arg s "$svc" '.[$s] // empty' <<<"$ROLES") [[ -n "$role_id" ]] || fail "нет роли AppRole для $svc" @@ -53,83 +73,96 @@ for svc in "${SERVICES[@]}"; do token=$(body "$r" | jq -r .auth.client_token) ok "токен получен, политики: $(body "$r" | jq -r '.auth.policies | join(", ")'), ttl: $(body "$r" | jq -r .auth.lease_duration)s" - step "2. Ключи S3 из $KV/$svc/s3" + step "2. S3: $KV/$svc/s3" r=$(vault_api GET "$KV/data/$svc/s3" "$token") - if [[ $(code "$r") == 200 ]]; then + if [[ ${#buckets[@]} -eq 0 ]]; then + [[ $(code "$r") == 404 ]] || fail "у сервиса нет бакетов, но секрет есть" + ok "бакетов нет, секрета нет" + else + [[ $(code "$r") == 200 ]] || fail "секрет не читается: HTTP $(code "$r")" s3=$(body "$r" | jq .data.data) ok "поля: $(jq -r 'keys | join(", ")' <<<"$s3")" - cfg=/tmp/app-check-$$ - docker compose exec -T object-storage mc --config-dir "$cfg" alias set app http://localhost:9000 \ - "$(jq -r .access_key <<<"$s3")" "$(jq -r .secret_key <<<"$s3")" >/dev/null - IFS=, read -ra own <<<"$(jq -r .buckets <<<"$s3")" - for b in "${own[@]}"; do - docker compose exec -T object-storage mc --config-dir "$cfg" ls "app/$b" >/dev/null 2>&1 || fail "свой бакет $b недоступен" - ok "бакет $b доступен" + mc_app alias set app http://localhost:9000 "$(jq -r .access_key <<<"$s3")" "$(jq -r .secret_key <<<"$s3")" >/dev/null + for b in "${buckets[@]}"; do + mc_app ls "app/$b" >/dev/null 2>&1 || fail "свой бакет $b недоступен" + if contains "$b" "${ro_buckets[@]}"; then + out=$(echo check | mc_app pipe "app/$b/.app-check" 2>&1 || true) + [[ "$out" == *"Access Denied"* || "$out" == *"Insufficient permissions"* ]] || + fail "запись в бакет $b с доступом ro прошла: $out" + ok "бакет $b: чтение есть, запись запрещена" + else + ok "бакет $b доступен" + fi + done + for b in "${ALL_BUCKETS[@]}"; do + contains "$b" "${buckets[@]}" && continue + out=$(mc_app ls "app/$b" 2>&1 || true) + [[ "$out" == *"Access Denied"* ]] || fail "чужой бакет $b доступен: $out" + ok "чужой бакет $b: Access Denied" done - docker compose exec -T object-storage rm -rf "$cfg" - else - ok "секрета нет (у сервиса нет бакетов), HTTP $(code "$r")" fi - step "3. Постоянные учётные данные Postgres из $KV/$svc/postgres" - r=$(vault_api GET "$KV/data/$svc/postgres" "$token") - own_db="" - if [[ $(code "$r") == 200 ]]; then + step "3. Postgres" + dyn_users=() + if [[ ${#dbs[@]} -eq 0 ]]; then + ok "баз нет" + fi + for db in "${dbs[@]}"; do + r=$(vault_api GET "$KV/data/$svc/postgres/$db" "$token") + [[ $(code "$r") == 200 ]] || fail "секрет $KV/$svc/postgres/$db не читается: HTTP $(code "$r")" pg=$(body "$r" | jq .data.data) - own_db=$(jq -r .database <<<"$pg") - ok "поля: $(jq -r 'keys | join(", ")' <<<"$pg")" - out=$(psql_as "$(jq -r .username <<<"$pg")" "$(jq -r .password <<<"$pg")" "$own_db") - [[ "$out" == *"работает как"* ]] || fail "не подключился к $own_db: $out" - ok "$own_db: $out" - else - ok "секрета нет (у сервиса нет базы), HTTP $(code "$r")" - fi + out=$(psql_as "$(jq -r .username <<<"$pg")" "$(jq -r .password <<<"$pg")" "$db") + [[ "$out" == *"работает как ${db}_owner" ]] || fail "постоянная роль не подключилась к $db: $out" + ok "$db, постоянная роль: $out" - step "4. Временные учётные данные Postgres из $DB/creds/$svc" - if [[ -n "$own_db" ]]; then - r=$(vault_api GET "$DB/creds/$svc" "$token") - [[ $(code "$r") == 200 ]] || fail "Vault не выдал временную роль: $(body "$r")" + r=$(vault_api GET "$DB/creds/$svc-$db" "$token") + [[ $(code "$r") == 200 ]] || fail "Vault не выдал временную роль для $db: $(body "$r")" dyn_user=$(body "$r" | jq -r .data.username) dyn_pass=$(body "$r" | jq -r .data.password) - ok "выдана роль $dyn_user на $(body "$r" | jq -r .lease_duration)s" - out=$(psql_as "$dyn_user" "$dyn_pass" "$own_db") - [[ "$out" == *"работает как"* ]] || fail "временная роль не подключилась к $own_db: $out" - ok "$own_db: $out" - for other in $(docker compose exec -T postgres psql -U "$POSTGRES_USER" -d postgres -Atc \ - "select datname from pg_database where datdba <> 10 and datname <> '$own_db'"); do - out=$(psql_as "$dyn_user" "$dyn_pass" "$other") - [[ "$out" == *"CONNECT privilege"* ]] || fail "временная роль попала в чужую базу $other: $out" - ok "$other: отказ, нет права CONNECT" - done - else - ok "пропущено, у сервиса нет базы" - fi + dyn_users+=("$dyn_user") + out=$(psql_as "$dyn_user" "$dyn_pass" "$db") + [[ "$out" == *"работает как ${db}_owner" ]] || fail "временная роль не подключилась к $db: $out" + ok "$db, временная роль на $(body "$r" | jq -r .lease_duration)s: $out" - step "5. Чужие секреты" - for path in "$KV/data/infra/postgres" "$KV/data/infra/silo"; do - [[ $(code "$(vault_api GET "$path" "$token")") == 403 ]] || fail "сервис прочитал $path" - ok "$path: 403" + for other in "${ALL_DBS[@]}"; do + contains "$other" "${dbs[@]}" && continue + out=$(psql_as "$dyn_user" "$dyn_pass" "$other") + [[ "$out" == *"CONNECT privilege"* ]] || fail "роль попала в чужую базу $other: $out" + ok "чужая база $other: нет права CONNECT" + done + done + + step "4. Чужие секреты" + for db in "${ALL_DBS[@]}"; do + contains "$db" "${dbs[@]}" && continue + [[ $(code "$(vault_api GET "$DB/creds/$svc-$db" "$token")") == 403 ]] || fail "сервис получил роль для чужой базы $db" + ok "$DB/creds/$svc-$db: 403" done for other in "${ALL[@]}"; do [[ "$other" == "$svc" ]] && continue - for path in "$KV/data/$other/s3" "$KV/data/$other/postgres" "$DB/creds/$other"; do + paths=("$KV/data/$other/s3") + while read -r db; do + [[ -n "$db" ]] && paths+=("$KV/data/$other/postgres/$db" "$DB/creds/$other-$db") + done < <(jq -r --arg s "$other" '.services[$s].databases // [] | .[]' <<<"$CONFIG") + for path in "${paths[@]}"; do [[ $(code "$(vault_api GET "$path" "$token")") == 403 ]] || fail "сервис прочитал $path" ok "$path: 403" done done - step "6. Отзыв токена" + step "5. Отзыв токена" old_token=$token revoke_token [[ $(code "$(vault_api GET "$KV/data/$svc/s3" "$old_token")") == 403 ]] || fail "токен всё ещё работает" ok "токен больше не принимается" - if [[ -n "${dyn_user:-}" ]]; then + if [[ ${#dyn_users[@]} -gt 0 ]]; then sleep 2 - left=$(docker compose exec -T postgres psql -U "$POSTGRES_USER" -d postgres -Atc \ - "select count(*) from pg_roles where rolname = '$dyn_user'") - [[ "$left" == 0 ]] || fail "временная роль $dyn_user осталась в Postgres" - ok "временная роль $dyn_user удалена вместе с токеном" - dyn_user="" + for dyn_user in "${dyn_users[@]}"; do + left=$(docker compose exec -T postgres psql -U "$POSTGRES_USER" -d postgres -Atc \ + "select count(*) from pg_roles where rolname = '$dyn_user'") + [[ "$left" == 0 ]] || fail "временная роль $dyn_user осталась в Postgres" + done + ok "временные роли удалены вместе с токеном" fi done diff --git a/modules/app-database/locals.tf b/modules/app-database/locals.tf deleted file mode 100644 index e4d601e..0000000 --- a/modules/app-database/locals.tf +++ /dev/null @@ -1,5 +0,0 @@ -locals { - databases = var.service.database == null ? {} : { - (var.service.database.name) = var.service.database - } -} diff --git a/modules/app-database/main.tf b/modules/app-database/main.tf deleted file mode 100644 index 4fcb248..0000000 --- a/modules/app-database/main.tf +++ /dev/null @@ -1,42 +0,0 @@ -resource "random_password" "this" { - for_each = local.databases - length = 32 - special = false -} - -resource "postgresql_role" "this" { - for_each = local.databases - name = each.value.username - login = true - password = random_password.this[each.key].result -} - -resource "postgresql_database" "this" { - for_each = local.databases - name = each.key - owner = postgresql_role.this[each.key].name -} - -resource "postgresql_extension" "vector" { - for_each = { for key, value in local.databases : key => value if value.pgvector } - name = "vector" - database = postgresql_database.this[each.key].name -} - -resource "postgresql_grant" "public" { - for_each = postgresql_database.this - database = each.value.name - role = "public" - object_type = "database" - privileges = [] -} - -resource "postgresql_grant" "owner" { - for_each = postgresql_database.this - database = each.value.name - role = postgresql_role.this[each.key].name - object_type = "database" - privileges = ["CONNECT", "CREATE", "TEMPORARY"] - - depends_on = [postgresql_grant.public] -} diff --git a/modules/app-database/output.tf b/modules/app-database/output.tf deleted file mode 100644 index 5e61a19..0000000 --- a/modules/app-database/output.tf +++ /dev/null @@ -1,9 +0,0 @@ -output "credentials" { - value = one([for name, db in postgresql_database.this : { - database = db.name - username = postgresql_role.this[name].name - password = random_password.this[name].result - }]) - description = "credentials of the service database, null if the service has no database" - sensitive = true -} diff --git a/modules/app-database/variables.tf b/modules/app-database/variables.tf deleted file mode 100644 index 0eff0b6..0000000 --- a/modules/app-database/variables.tf +++ /dev/null @@ -1,10 +0,0 @@ -variable "service" { - type = object({ - name = string - database = optional(object({ - name = string - username = string - pgvector = optional(bool, false) - })) - }) -} diff --git a/modules/app-storage/main.tf b/modules/app-storage/main.tf deleted file mode 100644 index 1b427db..0000000 --- a/modules/app-storage/main.tf +++ /dev/null @@ -1,71 +0,0 @@ -resource "minio_s3_bucket" "this" { - for_each = var.service.buckets - bucket = each.key - acl = "private" -} - -resource "minio_s3_bucket_versioning" "this" { - for_each = { for name, b in var.service.buckets : name => b if b.versioning } - bucket = minio_s3_bucket.this[each.key].bucket - - versioning_configuration { - status = "Enabled" - } -} - -resource "minio_ilm_policy" "this" { - for_each = var.service.buckets - bucket = minio_s3_bucket.this[each.key].bucket - - rule { - id = "clean-rule-1" - status = "Enabled" - dynamic "noncurrent_expiration" { - for_each = each.value.versioning ? [1] : [] - content { - days = "${each.value.noncurrent_days}d" - } - } - expired_object_delete_marker = each.value.versioning - abort_incomplete_multipart_upload { - days_after_initiation = "3d" - } - } -} - -resource "minio_iam_user" "this" { - name = var.service.name -} - -resource "minio_iam_policy" "get_upd_del_policy" { - name = var.service.name - policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Effect = "Allow", - Action = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], - Resource = [ - for bucket in minio_s3_bucket.this : "arn:aws:s3:::${bucket.bucket}/*" - ] - }, - { - Effect = "Allow", - Action = ["s3:ListBucket"], - Resource = [ - for bucket in minio_s3_bucket.this : "arn:aws:s3:::${bucket.bucket}" - ] - }] - }) -} - -resource "minio_iam_user_policy_attachment" "this" { - user_name = minio_iam_user.this.name - policy_name = minio_iam_policy.get_upd_del_policy.name -} - -resource "minio_iam_service_account" "this" { - target_user = minio_iam_user.this.name - description = "keys of ${var.service.name}" - - depends_on = [minio_iam_user_policy_attachment.this] -} diff --git a/modules/app-storage/output.tf b/modules/app-storage/output.tf deleted file mode 100644 index a693ed6..0000000 --- a/modules/app-storage/output.tf +++ /dev/null @@ -1,15 +0,0 @@ -output "buckets" { - value = [for bucket in minio_s3_bucket.this : bucket.bucket] - description = "buckets of the service" -} - -output "access_key" { - value = minio_iam_service_account.this.access_key - description = "access key of the service" -} - -output "secret_key" { - value = minio_iam_service_account.this.secret_key - description = "secret key of the service" - sensitive = true -} diff --git a/modules/app-storage/variables.tf b/modules/app-storage/variables.tf deleted file mode 100644 index e7c1f38..0000000 --- a/modules/app-storage/variables.tf +++ /dev/null @@ -1,13 +0,0 @@ -variable "service" { - type = object({ - name = string - buckets = map(object({ - versioning = optional(bool, true) - noncurrent_days = optional(number, 30) - })) - }) - validation { - condition = length(var.service.buckets) > 0 - error_message = "У сервиса должен быть хотя бы один бакет: IAM-политика с пустым списком Resource невалидна." - } -} diff --git a/modules/bucket-access/main.tf b/modules/bucket-access/main.tf new file mode 100644 index 0000000..7af445a --- /dev/null +++ b/modules/bucket-access/main.tf @@ -0,0 +1,45 @@ +locals { + buckets = concat(var.read_write, var.read_only) + + statements = [ + { + Effect = "Allow" + Action = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"] + Resource = [for bucket in var.read_write : "arn:aws:s3:::${bucket}/*"] + }, + { + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = [for bucket in var.read_only : "arn:aws:s3:::${bucket}/*"] + }, + { + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = [for bucket in local.buckets : "arn:aws:s3:::${bucket}"] + }, + ] +} + +resource "minio_iam_user" "this" { + name = var.name +} + +resource "minio_iam_policy" "this" { + name = var.name + policy = jsonencode({ + Version = "2012-10-17" + Statement = [for statement in local.statements : statement if length(statement.Resource) > 0] + }) +} + +resource "minio_iam_user_policy_attachment" "this" { + user_name = minio_iam_user.this.name + policy_name = minio_iam_policy.this.name +} + +resource "minio_iam_service_account" "this" { + target_user = minio_iam_user.this.name + description = "keys of ${var.name}" + + depends_on = [minio_iam_user_policy_attachment.this] +} diff --git a/modules/bucket-access/output.tf b/modules/bucket-access/output.tf new file mode 100644 index 0000000..b24c20e --- /dev/null +++ b/modules/bucket-access/output.tf @@ -0,0 +1,12 @@ +output "buckets" { + value = local.buckets +} + +output "access_key" { + value = minio_iam_service_account.this.access_key +} + +output "secret_key" { + value = minio_iam_service_account.this.secret_key + sensitive = true +} diff --git a/modules/bucket-access/variables.tf b/modules/bucket-access/variables.tf new file mode 100644 index 0000000..6a4c74b --- /dev/null +++ b/modules/bucket-access/variables.tf @@ -0,0 +1,11 @@ +variable "name" { + type = string +} + +variable "read_write" { + type = list(string) +} + +variable "read_only" { + type = list(string) +} diff --git a/modules/app-storage/versions.tf b/modules/bucket-access/versions.tf similarity index 100% rename from modules/app-storage/versions.tf rename to modules/bucket-access/versions.tf diff --git a/modules/bucket/main.tf b/modules/bucket/main.tf new file mode 100644 index 0000000..92811bc --- /dev/null +++ b/modules/bucket/main.tf @@ -0,0 +1,33 @@ +resource "minio_s3_bucket" "this" { + bucket = var.name + acl = "private" +} + +resource "minio_s3_bucket_versioning" "this" { + count = var.versioning ? 1 : 0 + bucket = minio_s3_bucket.this.bucket + + versioning_configuration { + status = "Enabled" + } +} + +resource "minio_ilm_policy" "this" { + bucket = minio_s3_bucket.this.bucket + + rule { + id = "cleanup" + status = "Enabled" + expiration = var.expire_days == null ? null : "${var.expire_days}d" + dynamic "noncurrent_expiration" { + for_each = var.versioning ? [1] : [] + content { + days = "${var.noncurrent_days}d" + } + } + expired_object_delete_marker = var.versioning && var.expire_days == null + abort_incomplete_multipart_upload { + days_after_initiation = "3d" + } + } +} diff --git a/modules/bucket/output.tf b/modules/bucket/output.tf new file mode 100644 index 0000000..91906e9 --- /dev/null +++ b/modules/bucket/output.tf @@ -0,0 +1,3 @@ +output "name" { + value = minio_s3_bucket.this.bucket +} diff --git a/modules/bucket/variables.tf b/modules/bucket/variables.tf new file mode 100644 index 0000000..6d286aa --- /dev/null +++ b/modules/bucket/variables.tf @@ -0,0 +1,16 @@ +variable "name" { + type = string +} + +variable "versioning" { + type = bool +} + +variable "noncurrent_days" { + type = number +} + +variable "expire_days" { + type = number + default = null +} diff --git a/modules/bucket/versions.tf b/modules/bucket/versions.tf new file mode 100644 index 0000000..760cf23 --- /dev/null +++ b/modules/bucket/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + minio = { + source = "aminueza/minio" + version = ">= 3.44.0" + } + } +} diff --git a/modules/config/output.tf b/modules/config/output.tf index 026a951..9275252 100644 --- a/modules/config/output.tf +++ b/modules/config/output.tf @@ -2,10 +2,42 @@ output "infra" { value = var.infra } +output "buckets" { + value = var.buckets +} + +output "databases" { + value = { + for name, db in var.databases : name => merge(db, { owner = "${name}_owner" }) + } +} + output "services" { value = var.services } +output "service_buckets" { + value = { + for name, svc in var.services : name => { + read_write = [for bucket, access in svc.buckets : bucket if access == "rw"] + read_only = [for bucket, access in svc.buckets : bucket if access == "ro"] + } if length(svc.buckets) > 0 + } +} + +output "service_databases" { + value = merge([ + for name, svc in var.services : { + for db in svc.databases : "${name}-${db}" => { + service = name + database = db + role = "${name}-${db}" + owner = "${db}_owner" + } + } + ]...) +} + output "vault" { value = { address = "http://${var.infra.vault.host}:${var.infra.vault.port}" diff --git a/modules/config/variables.tf b/modules/config/variables.tf index ca9f599..da97b13 100644 --- a/modules/config/variables.tf +++ b/modules/config/variables.tf @@ -7,6 +7,7 @@ variable "infra" { port = number internal_host = string internal_port = number + sslmode = string }) pgadmin = object({ image = string @@ -34,27 +35,78 @@ variable "infra" { }) } +variable "buckets" { + type = map(object({ + versioning = optional(bool, true) + noncurrent_days = optional(number, 30) + expire_days = optional(number) + })) + default = {} + + validation { + condition = alltrue([ + for name in keys(var.buckets) : can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", name)) + ]) + error_message = "Имя бакета: 3–63 символа, строчные латинские буквы, цифры, точки и дефисы." + } + + validation { + condition = contains(keys(var.buckets), var.infra.silo.backup_bucket) + error_message = "Бакет из infra.silo.backup_bucket должен быть описан в buckets." + } + + validation { + condition = !contains(keys(var.buckets), var.infra.silo.state_bucket) + error_message = "Бакет из infra.silo.state_bucket создаётся отдельно, в buckets его описывать нельзя." + } +} + +variable "databases" { + type = map(object({ + pgvector = optional(bool, false) + })) + default = {} + + validation { + condition = alltrue([ + for name in keys(var.databases) : can(regex("^[a-z_][a-z0-9_]{0,55}$", name)) + ]) + error_message = "Имя базы: до 56 символов, строчные латинские буквы, цифры и подчёркивания, не с цифры." + } +} + variable "services" { type = map(object({ - postgres = optional(object({ - username = string - name = string - sslmode = optional(string, "disable") - pgvector = optional(bool, false) - })) - buckets = optional(map(object({ - versioning = optional(bool, true) - noncurrent_days = optional(number, 30) - })), {}) + buckets = optional(map(string), {}) + databases = optional(list(string), []) })) + default = {} validation { condition = alltrue(flatten([ - for svc in values(var.services) : [ - for name in keys(svc.buckets) : - can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", name)) - ] + for svc in values(var.services) : [for bucket in keys(svc.buckets) : contains(keys(var.buckets), bucket)] ])) - error_message = "Имя бакета: 3–63 символа, строчные латинские буквы, цифры, точки и дефисы." + error_message = "Сервис ссылается на бакет, которого нет в buckets." + } + + validation { + condition = alltrue(flatten([ + for svc in values(var.services) : [for access in values(svc.buckets) : contains(["rw", "ro"], access)] + ])) + error_message = "Доступ к бакету: rw или ro." + } + + validation { + condition = alltrue(flatten([ + for svc in values(var.services) : [for db in svc.databases : contains(keys(var.databases), db)] + ])) + error_message = "Сервис ссылается на базу, которой нет в databases." + } + + validation { + condition = alltrue(flatten([ + for name, svc in var.services : [for db in svc.databases : length("${name}-${db}") <= 63] + ])) + error_message = "Имя роли <сервис>-<база> длиннее 63 символов." } } diff --git a/modules/database-access/main.tf b/modules/database-access/main.tf new file mode 100644 index 0000000..544a5a8 --- /dev/null +++ b/modules/database-access/main.tf @@ -0,0 +1,12 @@ +resource "random_password" "this" { + length = 32 + special = false +} + +resource "postgresql_role" "this" { + name = var.role + login = true + password = random_password.this.result + roles = [var.owner] + assume_role = var.owner +} diff --git a/modules/database-access/output.tf b/modules/database-access/output.tf new file mode 100644 index 0000000..5c312cf --- /dev/null +++ b/modules/database-access/output.tf @@ -0,0 +1,8 @@ +output "username" { + value = postgresql_role.this.name +} + +output "password" { + value = random_password.this.result + sensitive = true +} diff --git a/modules/database-access/variables.tf b/modules/database-access/variables.tf new file mode 100644 index 0000000..d05f85f --- /dev/null +++ b/modules/database-access/variables.tf @@ -0,0 +1,7 @@ +variable "role" { + type = string +} + +variable "owner" { + type = string +} diff --git a/modules/app-database/versions.tf b/modules/database-access/versions.tf similarity index 100% rename from modules/app-database/versions.tf rename to modules/database-access/versions.tf diff --git a/modules/database/main.tf b/modules/database/main.tf new file mode 100644 index 0000000..2529f15 --- /dev/null +++ b/modules/database/main.tf @@ -0,0 +1,30 @@ +resource "postgresql_role" "owner" { + name = var.owner +} + +resource "postgresql_database" "this" { + name = var.name + owner = postgresql_role.owner.name +} + +resource "postgresql_extension" "vector" { + count = var.pgvector ? 1 : 0 + name = "vector" + database = postgresql_database.this.name +} + +resource "postgresql_grant" "public" { + database = postgresql_database.this.name + role = "public" + object_type = "database" + privileges = [] +} + +resource "postgresql_grant" "owner" { + database = postgresql_database.this.name + role = postgresql_role.owner.name + object_type = "database" + privileges = ["CONNECT", "CREATE", "TEMPORARY"] + + depends_on = [postgresql_grant.public] +} diff --git a/modules/database/output.tf b/modules/database/output.tf new file mode 100644 index 0000000..9bbb618 --- /dev/null +++ b/modules/database/output.tf @@ -0,0 +1,9 @@ +output "name" { + value = postgresql_database.this.name +} + +output "owner" { + value = postgresql_role.owner.name + + depends_on = [postgresql_grant.owner] +} diff --git a/modules/database/variables.tf b/modules/database/variables.tf new file mode 100644 index 0000000..7604f69 --- /dev/null +++ b/modules/database/variables.tf @@ -0,0 +1,11 @@ +variable "name" { + type = string +} + +variable "owner" { + type = string +} + +variable "pgvector" { + type = bool +} diff --git a/modules/database/versions.tf b/modules/database/versions.tf new file mode 100644 index 0000000..854e873 --- /dev/null +++ b/modules/database/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + version = ">= 1.27.0" + } + } +} diff --git a/storage/locals.tf b/storage/locals.tf index cfb1ee9..97aeef9 100644 --- a/storage/locals.tf +++ b/storage/locals.tf @@ -3,11 +3,9 @@ locals { } module "config" { - source = "../modules/config" - infra = local.config.infra - services = local.config.services -} - -locals { - services_with_buckets = { for name, svc in module.config.services : name => svc if length(svc.buckets) > 0 } + source = "../modules/config" + infra = local.config.infra + buckets = try(local.config.buckets, {}) + databases = try(local.config.databases, {}) + services = try(local.config.services, {}) } diff --git a/storage/main.tf b/storage/main.tf index 3945289..42b02e5 100644 --- a/storage/main.tf +++ b/storage/main.tf @@ -31,29 +31,25 @@ resource "minio_ilm_policy" "admin_policy" { } } -resource "minio_s3_bucket" "backups" { - bucket = module.config.silo.backup_bucket - acl = "private" +module "bucket" { + source = "../modules/bucket" + for_each = module.config.buckets + name = each.key + versioning = each.value.versioning + noncurrent_days = each.value.noncurrent_days + expire_days = each.value.expire_days } -resource "minio_ilm_policy" "backups" { - bucket = minio_s3_bucket.backups.bucket - - rule { - id = "expire-old-backups" - status = "Enabled" - expiration = "30d" - } -} - -module "app_storage" { - source = "../modules/app-storage" - for_each = local.services_with_buckets - service = { name = each.key, buckets = each.value.buckets } +module "bucket_access" { + source = "../modules/bucket-access" + for_each = module.config.service_buckets + name = each.key + read_write = [for bucket in each.value.read_write : module.bucket[bucket].name] + read_only = [for bucket in each.value.read_only : module.bucket[bucket].name] } resource "vault_kv_secret_v2" "s3" { - for_each = module.app_storage + for_each = module.bucket_access mount = module.config.vault.mount name = "${each.key}/s3" delete_all_versions = true diff --git a/vault/locals.tf b/vault/locals.tf index 2929ed4..97aeef9 100644 --- a/vault/locals.tf +++ b/vault/locals.tf @@ -3,7 +3,9 @@ locals { } module "config" { - source = "../modules/config" - infra = local.config.infra - services = local.config.services + source = "../modules/config" + infra = local.config.infra + buckets = try(local.config.buckets, {}) + databases = try(local.config.databases, {}) + services = try(local.config.services, {}) } diff --git a/vault/main.tf b/vault/main.tf index c63d0e3..80f2382 100644 --- a/vault/main.tf +++ b/vault/main.tf @@ -10,14 +10,16 @@ resource "vault_policy" "service" { for_each = module.config.services name = "service-${each.key}" policy = jsonencode({ - path = { - "${vault_mount.secret.path}/data/${each.key}/*" = { - capabilities = ["read"] - } - "${module.config.vault.database_mount}/creds/${each.key}" = { - capabilities = ["read"] - } - } + path = merge( + { + "${vault_mount.secret.path}/data/${each.key}/*" = { capabilities = ["read"] } + }, + { + for key, access in module.config.service_databases : + "${module.config.vault.database_mount}/creds/${key}" => { capabilities = ["read"] } + if access.service == each.key + }, + ) }) } @@ -58,10 +60,10 @@ resource "vault_policy" "terraform" { "auth/${local.ar}/role/+/role-id" = { capabilities = ["read"] } "auth/${local.ar}/role/+/secret-id" = { capabilities = ["update"] } - "${local.kv}/data/+/s3" = { capabilities = local.manage } - "${local.kv}/data/+/postgres" = { capabilities = local.manage } - "${local.kv}/metadata/+/s3" = { capabilities = ["read", "update", "delete"] } - "${local.kv}/metadata/+/postgres" = { capabilities = ["read", "update", "delete"] } + "${local.kv}/data/+/s3" = { capabilities = local.manage } + "${local.kv}/data/+/postgres/+" = { capabilities = local.manage } + "${local.kv}/metadata/+/s3" = { capabilities = ["read", "update", "delete"] } + "${local.kv}/metadata/+/postgres/+" = { capabilities = ["read", "update", "delete"] } "sys/mounts/${local.db}" = { capabilities = ["read"] } "sys/mounts/${local.db}/tune" = { capabilities = ["update"] }