Resources and access
terraform / check (push) Waiting to run

This commit is contained in:
stolzor committed 2026-10-11 21:04:27 +03:00
1 parent 64be99308b
commit 5cba142003
38 files changed
+549 -337

No files matched your search

+60 -34
View File
@@ -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:
```yaml
buckets:
orders-files:
versioning: true # по умолчанию true
noncurrent_days: 30 # сколько хранить старые версии
```
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`.
+19 -9
View File
@@ -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
services:
app:
postgres:
username: app
name: app
pgvector: true
buckets:
buckets:
app-files:
versioning: true
noncurrent_days: 30
worker:
buckets:
worker-tmp:
versioning: false
infra-backups:
versioning: false
expire_days: 30
databases:
app:
pgvector: true
services:
app:
buckets:
app-files: rw
databases: [app]
worker:
buckets:
worker-tmp: rw
app-files: ro
+3 -5
View File
@@ -5,9 +5,7 @@ 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 }
buckets = try(local.config.buckets, {})
databases = try(local.config.databases, {})
services = try(local.config.services, {})
}
+28 -18
View File
@@ -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, {
data_json = jsonencode({
host = module.config.postgres.internal_host
port = module.config.postgres.internal_port
sslmode = each.value.sslmode
}))
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}}\";",
+1 -1
View File
@@ -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
}
+1
View File
@@ -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"
+3 -2
View File
@@ -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)
+7 -4
View File
@@ -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
+84 -51
View File
@@ -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 недоступен"
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
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"
ok "временная роль $dyn_user удалена вместе с токеном"
dyn_user=""
done
ok "временные роли удалены вместе с токеном"
fi
done
-5
View File
@@ -1,5 +0,0 @@
locals {
databases = var.service.database == null ? {} : {
(var.service.database.name) = var.service.database
}
}
-42
View File
@@ -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]
}
-9
View File
@@ -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
}
-10
View File
@@ -1,10 +0,0 @@
variable "service" {
type = object({
name = string
database = optional(object({
name = string
username = string
pgvector = optional(bool, false)
}))
})
}
-71
View File
@@ -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]
}
-15
View File
@@ -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
}
-13
View File
@@ -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 невалидна."
}
}
+45
View File
@@ -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]
}
+12
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
variable "name" {
type = string
}
variable "read_write" {
type = list(string)
}
variable "read_only" {
type = list(string)
}
File renamed without changes.
+33
View File
@@ -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"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
output "name" {
value = minio_s3_bucket.this.bucket
}
+16
View File
@@ -0,0 +1,16 @@
variable "name" {
type = string
}
variable "versioning" {
type = bool
}
variable "noncurrent_days" {
type = number
}
variable "expire_days" {
type = number
default = null
}
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
minio = {
source = "aminueza/minio"
version = ">= 3.44.0"
}
}
}
+32
View File
@@ -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}"
+66 -14
View File
@@ -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 "services" {
variable "buckets" {
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)
})), {})
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({
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 символов."
}
}
+12
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
output "username" {
value = postgresql_role.this.name
}
output "password" {
value = random_password.this.result
sensitive = true
}
+7
View File
@@ -0,0 +1,7 @@
variable "role" {
type = string
}
variable "owner" {
type = string
}
File renamed without changes.
+30
View File
@@ -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]
}
+9
View File
@@ -0,0 +1,9 @@
output "name" {
value = postgresql_database.this.name
}
output "owner" {
value = postgresql_role.owner.name
depends_on = [postgresql_grant.owner]
}
+11
View File
@@ -0,0 +1,11 @@
variable "name" {
type = string
}
variable "owner" {
type = string
}
variable "pgvector" {
type = bool
}
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
postgresql = {
source = "cyrilgdn/postgresql"
version = ">= 1.27.0"
}
}
}
+3 -5
View File
@@ -5,9 +5,7 @@ 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 }
buckets = try(local.config.buckets, {})
databases = try(local.config.databases, {})
services = try(local.config.services, {})
}
+14 -18
View File
@@ -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
+3 -1
View File
@@ -5,5 +5,7 @@ locals {
module "config" {
source = "../modules/config"
infra = local.config.infra
services = local.config.services
buckets = try(local.config.buckets, {})
databases = try(local.config.databases, {})
services = try(local.config.services, {})
}
+12 -10
View File
@@ -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
},
)
})
}
@@ -59,9 +61,9 @@ resource "vault_policy" "terraform" {
"auth/${local.ar}/role/+/secret-id" = { capabilities = ["update"] }
"${local.kv}/data/+/s3" = { capabilities = local.manage }
"${local.kv}/data/+/postgres" = { 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}/metadata/+/postgres/+" = { capabilities = ["read", "update", "delete"] }
"sys/mounts/${local.db}" = { capabilities = ["read"] }
"sys/mounts/${local.db}/tune" = { capabilities = ["update"] }