From 30881230848067732f2be6881ed0332c45b87116 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 16:35:08 -0700 Subject: [PATCH 01/14] feat: declare role dependencies. base roles -> intermediate -> derived roles --- README.md | 22 ++-- main.tf | 68 ++++++++++-- outputs.tf | 18 +++ tests/locals.tftest.hcl | 240 ++++++++++++++++++++++++++++++++++++++++ tests/main.tftest.hcl | 8 +- 5 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 tests/locals.tftest.hcl diff --git a/README.md b/README.md index dfddf85..8afa6eb 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,8 @@ module "postgres_automation" { | [postgresql_grant.schema_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | | [postgresql_grant.sequence_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | | [postgresql_grant.table_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | -| [postgresql_role.role](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | +| [postgresql_role.base_role](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | +| [postgresql_role.dependent_role](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | | [random_password.user_password](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | ## Inputs @@ -193,14 +194,17 @@ module "postgres_automation" { ## Outputs -| Name | Description | -| ----------------------------------------------------------------------------------------- | ----------- | -| [database_access](#output_database_access) | n/a | -| [databases](#output_databases) | n/a | -| [default_privileges](#output_default_privileges) | n/a | -| [schema_access](#output_schema_access) | n/a | -| [sequence_access](#output_sequence_access) | n/a | -| [table_access](#output_table_access) | n/a | +| Name | Description | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| [base_roles](#output_base_roles) | Base roles (group roles, no dependencies on other custom roles) | +| [database_access](#output_database_access) | n/a | +| [databases](#output_databases) | n/a | +| [default_privileges](#output_default_privileges) | n/a | +| [dependent_roles](#output_dependent_roles) | Dependent roles (login roles that inherit from other custom roles) | +| [roles](#output_roles) | All created roles (both base and dependent) | +| [schema_access](#output_schema_access) | n/a | +| [sequence_access](#output_sequence_access) | n/a | +| [table_access](#output_table_access) | n/a | diff --git a/main.tf b/main.tf index 6b937db..e416f7d 100644 --- a/main.tf +++ b/main.tf @@ -31,6 +31,33 @@ locals { roles_map = { for role in local._roles_with_passwords : role.role.name => role } databases_map = { for database in var.databases : database.name => database } + + # Built-in PostgreSQL roles that don't need to be created by this module + builtin_roles = [ + "pg_monitor", + "pg_read_all_data", + "pg_write_all_data", + "pg_read_all_settings", + "pg_read_all_stats", + "pg_stat_scan_tables", "pg_signal_backend", "pg_read_server_files", + "pg_write_server_files", "pg_execute_server_program", "pg_checkpoint", "pg_maintain", + "pg_create_subscription", "pg_use_reserved_connections" + ] + + # All custom role names being created by this module + custom_role_names = [for role in local._roles_with_passwords : role.role.name] + + # Base roles: no `roles` attribute, or only referencing built-in PostgreSQL roles + base_roles_map = { + for role in local._roles_with_passwords : role.role.name => role + if try(role.role.roles, null) == null || alltrue([for r in coalesce(role.role.roles, []) : contains(local.builtin_roles, r)]) + } + + # Dependent roles: roles that reference other custom roles defined in this module + dependent_roles_map = { + for role in local._roles_with_passwords : role.role.name => role + if try(role.role.roles, null) != null && anytrue([for r in coalesce(role.role.roles, []) : contains(local.custom_role_names, r)]) + } } resource "random_password" "user_password" { @@ -51,10 +78,9 @@ resource "postgresql_database" "logical_dbs" { connection_limit = each.value.connection_limit } -# In Postgres 15, now new users cannot create tables or write data to Postgres public schema by default. You have to grant create privilege to the new user manually. -# https://www.postgresql.org/docs/current/ddl-priv.html#DDL-PRIV-CREATE -resource "postgresql_role" "role" { - for_each = local.roles_map +# Base roles: roles with no dependencies on other custom roles (created first) +resource "postgresql_role" "base_role" { + for_each = local.base_roles_map name = each.value.role.name superuser = each.value.role.superuser @@ -78,6 +104,32 @@ resource "postgresql_role" "role" { depends_on = [postgresql_database.logical_dbs] } +# Dependent roles: roles that inherit from other custom roles (created after base roles) +resource "postgresql_role" "dependent_role" { + for_each = local.dependent_roles_map + + name = each.value.role.name + superuser = each.value.role.superuser + create_database = each.value.role.create_database + create_role = each.value.role.create_role + inherit = each.value.role.inherit + login = each.value.role.login + replication = each.value.role.replication + bypass_row_level_security = each.value.role.bypass_row_level_security + connection_limit = each.value.role.connection_limit + encrypted_password = each.value.role.encrypted_password + password = each.value.role.password + roles = each.value.role.roles + search_path = each.value.role.search_path + valid_until = each.value.role.valid_until + skip_drop_role = each.value.role.skip_drop_role + skip_reassign_owned = each.value.role.skip_reassign_owned + statement_timeout = each.value.role.statement_timeout + assume_role = each.value.role.assume_role + + depends_on = [postgresql_database.logical_dbs, postgresql_role.base_role] +} + resource "postgresql_default_privileges" "privileges" { # Postgres documentation specific to default privileges # https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html @@ -91,7 +143,7 @@ resource "postgresql_default_privileges" "privileges" { object_type = each.value.object_type privileges = each.value.privileges - depends_on = [postgresql_database.logical_dbs, postgresql_role.role] + depends_on = [postgresql_database.logical_dbs, postgresql_role.base_role, postgresql_role.dependent_role] } resource "postgresql_grant" "database_access" { @@ -102,7 +154,7 @@ resource "postgresql_grant" "database_access" { object_type = each.value.object_type privileges = each.value.privileges - depends_on = [postgresql_database.logical_dbs, postgresql_role.role] + depends_on = [postgresql_database.logical_dbs, postgresql_role.base_role, postgresql_role.dependent_role] } resource "postgresql_grant" "schema_access" { @@ -114,7 +166,7 @@ resource "postgresql_grant" "schema_access" { object_type = each.value.object_type privileges = each.value.privileges - depends_on = [postgresql_database.logical_dbs, postgresql_role.role] + depends_on = [postgresql_database.logical_dbs, postgresql_role.base_role, postgresql_role.dependent_role] } resource "postgresql_grant" "table_access" { @@ -139,6 +191,6 @@ resource "postgresql_grant" "sequence_access" { object_type = each.value.object_type privileges = each.value.privileges - depends_on = [postgresql_database.logical_dbs, postgresql_role.role] + depends_on = [postgresql_database.logical_dbs, postgresql_role.base_role, postgresql_role.dependent_role] } diff --git a/outputs.tf b/outputs.tf index f253b4b..768209c 100644 --- a/outputs.tf +++ b/outputs.tf @@ -2,6 +2,24 @@ output "databases" { value = postgresql_database.logical_dbs } +output "roles" { + description = "All created roles (both base and dependent)" + value = merge(postgresql_role.base_role, postgresql_role.dependent_role) + sensitive = true +} + +output "base_roles" { + description = "Base roles (group roles, no dependencies on other custom roles)" + value = postgresql_role.base_role + sensitive = true +} + +output "dependent_roles" { + description = "Dependent roles (login roles that inherit from other custom roles)" + value = postgresql_role.dependent_role + sensitive = true +} + output "database_access" { value = postgresql_grant.database_access } diff --git a/tests/locals.tftest.hcl b/tests/locals.tftest.hcl new file mode 100644 index 0000000..e13b17a --- /dev/null +++ b/tests/locals.tftest.hcl @@ -0,0 +1,240 @@ +mock_provider "postgresql" { + alias = "mock" +} + +# ----------------------------------------------------------------------------- +# Test: Role with no `roles` attribute should be classified as base role +# ----------------------------------------------------------------------------- + +run "role_without_roles_attribute_is_base" { + command = plan + + providers = { + postgresql = postgresql.mock + } + + variables { + databases = [] + roles = [{ + role = { + name = "standalone_user" + } + }] + } + + assert { + condition = contains(keys(local.base_roles_map), "standalone_user") + error_message = "Role without 'roles' attribute should be in base_roles_map" + } + + assert { + condition = !contains(keys(local.dependent_roles_map), "standalone_user") + error_message = "Role without 'roles' attribute should NOT be in dependent_roles_map" + } +} + +# ----------------------------------------------------------------------------- +# Test: Role with only built-in PostgreSQL roles should be classified as base +# ----------------------------------------------------------------------------- + +run "role_with_only_builtin_roles_is_base" { + command = plan + + providers = { + postgresql = postgresql.mock + } + + variables { + databases = [] + roles = [{ + role = { + name = "monitoring_user" + roles = ["pg_monitor", "pg_read_all_stats"] + } + }] + } + + assert { + condition = contains(keys(local.base_roles_map), "monitoring_user") + error_message = "Role with only built-in roles should be in base_roles_map" + } + + assert { + condition = !contains(keys(local.dependent_roles_map), "monitoring_user") + error_message = "Role with only built-in roles should NOT be in dependent_roles_map" + } +} + +# ----------------------------------------------------------------------------- +# Test: Role referencing a custom role should be classified as dependent +# ----------------------------------------------------------------------------- + +run "role_referencing_custom_role_is_dependent" { + command = plan + + providers = { + postgresql = postgresql.mock + } + + variables { + databases = [] + roles = [ + { + role = { + name = "base_role" + } + }, + { + role = { + name = "child_role" + roles = ["base_role"] + } + } + ] + } + + assert { + condition = contains(keys(local.base_roles_map), "base_role") + error_message = "Parent role should be in base_roles_map" + } + + assert { + condition = contains(keys(local.dependent_roles_map), "child_role") + error_message = "Role referencing custom role should be in dependent_roles_map" + } + + assert { + condition = !contains(keys(local.base_roles_map), "child_role") + error_message = "Role referencing custom role should NOT be in base_roles_map" + } +} + +# ----------------------------------------------------------------------------- +# Test: Role with both built-in AND custom roles should be classified as dependent +# ----------------------------------------------------------------------------- + +run "role_with_builtin_and_custom_roles_is_dependent" { + command = plan + + providers = { + postgresql = postgresql.mock + } + + variables { + databases = [] + roles = [ + { + role = { + name = "app_role" + } + }, + { + role = { + name = "admin_role" + roles = ["pg_monitor", "app_role"] + } + } + ] + } + + assert { + condition = contains(keys(local.base_roles_map), "app_role") + error_message = "Role without dependencies should be in base_roles_map" + } + + assert { + condition = contains(keys(local.dependent_roles_map), "admin_role") + error_message = "Role with mixed built-in and custom roles should be in dependent_roles_map" + } + + assert { + condition = !contains(keys(local.base_roles_map), "admin_role") + error_message = "Role with mixed built-in and custom roles should NOT be in base_roles_map" + } +} + +# ----------------------------------------------------------------------------- +# Test: Verify builtin_roles list contains expected PostgreSQL roles +# ----------------------------------------------------------------------------- + +run "builtin_roles_list_is_populated" { + command = plan + + providers = { + postgresql = postgresql.mock + } + + variables { + databases = [] + roles = [] + } + + assert { + condition = contains(local.builtin_roles, "pg_monitor") + error_message = "builtin_roles should contain pg_monitor" + } + + assert { + condition = contains(local.builtin_roles, "pg_read_all_data") + error_message = "builtin_roles should contain pg_read_all_data" + } + + assert { + condition = contains(local.builtin_roles, "pg_write_all_data") + error_message = "builtin_roles should contain pg_write_all_data" + } +} + +# ----------------------------------------------------------------------------- +# Test: custom_role_names contains all defined role names +# ----------------------------------------------------------------------------- + +run "custom_role_names_contains_all_roles" { + command = plan + + providers = { + postgresql = postgresql.mock + } + + variables { + databases = [] + roles = [ + { + role = { + name = "role_a" + } + }, + { + role = { + name = "role_b" + } + }, + { + role = { + name = "role_c" + roles = ["role_a"] + } + } + ] + } + + assert { + condition = length(local.custom_role_names) == 3 + error_message = "custom_role_names should contain all 3 roles" + } + + assert { + condition = contains(local.custom_role_names, "role_a") + error_message = "custom_role_names should contain role_a" + } + + assert { + condition = contains(local.custom_role_names, "role_b") + error_message = "custom_role_names should contain role_b" + } + + assert { + condition = contains(local.custom_role_names, "role_c") + error_message = "custom_role_names should contain role_c" + } +} diff --git a/tests/main.tftest.hcl b/tests/main.tftest.hcl index cd234e4..006ff40 100644 --- a/tests/main.tftest.hcl +++ b/tests/main.tftest.hcl @@ -186,9 +186,9 @@ run "validate_roles_with_password" { } assert { - condition = postgresql_role.role["app_user"].password == "app_user_password" + condition = postgresql_role.base_role["app_user"].password == "app_user_password" error_message = "Role should have correct password" - } + } } @@ -200,12 +200,12 @@ run "validate_roles_with_random_password" { } assert { - condition = length(postgresql_role.role["app_user2"].password) == 33 + condition = length(postgresql_role.base_role["app_user2"].password) == 33 error_message = "Role should have random password" } assert { - condition = alltrue([for c in ["!", "#", "$", "%", "^", "&", "*", "(", ")", "<", ">", "-", "_"] : length(split(c, postgresql_role.role["app_user2"].password)) == 1]) + condition = alltrue([for c in ["!", "#", "$", "%", "^", "&", "*", "(", ")", "<", ">", "-", "_"] : length(split(c, postgresql_role.base_role["app_user2"].password)) == 1]) error_message = "Password contains forbidden special characters" } } From f7d7dc9708ee4ff152241b2523b1f3aad1a4b512 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 16:35:35 -0700 Subject: [PATCH 02/14] feat: add example that uses role dependencies --- examples/llm_chat_app/.terraform.lock.hcl | 41 ++ examples/llm_chat_app/1_apply_terraform.sh | 18 + .../llm_chat_app/2_create_test_objects.sh | 54 ++ .../llm_chat_app/3_run_verification_tests.sh | 90 +++ examples/llm_chat_app/4_cleanup.sh | 71 +++ examples/llm_chat_app/README.md | 247 ++++++++ examples/llm_chat_app/RUN_ALL_TESTS.sh | 35 ++ examples/llm_chat_app/TEST_INSTRUCTIONS.md | 141 +++++ examples/llm_chat_app/fixtures.auto.tfvars | 172 ++++++ examples/llm_chat_app/main.tf | 566 ++++++++++++++++++ examples/llm_chat_app/outputs.tf | 21 + examples/llm_chat_app/providers.tf | 11 + examples/llm_chat_app/run_tests.sh | 151 +++++ examples/llm_chat_app/variables.tf | 111 ++++ examples/llm_chat_app/versions.tf | 15 + 15 files changed, 1744 insertions(+) create mode 100644 examples/llm_chat_app/.terraform.lock.hcl create mode 100755 examples/llm_chat_app/1_apply_terraform.sh create mode 100755 examples/llm_chat_app/2_create_test_objects.sh create mode 100755 examples/llm_chat_app/3_run_verification_tests.sh create mode 100755 examples/llm_chat_app/4_cleanup.sh create mode 100644 examples/llm_chat_app/README.md create mode 100755 examples/llm_chat_app/RUN_ALL_TESTS.sh create mode 100644 examples/llm_chat_app/TEST_INSTRUCTIONS.md create mode 100644 examples/llm_chat_app/fixtures.auto.tfvars create mode 100644 examples/llm_chat_app/main.tf create mode 100644 examples/llm_chat_app/outputs.tf create mode 100644 examples/llm_chat_app/providers.tf create mode 100644 examples/llm_chat_app/run_tests.sh create mode 100644 examples/llm_chat_app/variables.tf create mode 100644 examples/llm_chat_app/versions.tf diff --git a/examples/llm_chat_app/.terraform.lock.hcl b/examples/llm_chat_app/.terraform.lock.hcl new file mode 100644 index 0000000..87e937d --- /dev/null +++ b/examples/llm_chat_app/.terraform.lock.hcl @@ -0,0 +1,41 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/cyrilgdn/postgresql" { + version = "1.26.0" + constraints = ">= 1.0.0, ~> 1.0" + hashes = [ + "h1:8bXFg6KkLzUAd44WUnqSxVY0pqXALT14h59OlYq3UTY=", + "zh:0f2ec2bb24f8bb9eb232f1650d6459a2bac732bf91bbc08b27ae5519bee89486", + "zh:11dafcec9c7e6e2c8b6303d90c6061973db26f6f84adc2be02fe66e9b1b11561", + "zh:13a67dc639ee053cbecc6ab28fd5bfca4780e680bd12491f1bdf0f8243fd364a", + "zh:56337a42348bb9ab31837caa89d89f7a3ee0528b5a6d04a6a93a8ea155eb7f4d", + "zh:590e80218e70e8081a11cf1f5df16014426d6ba8c2552713cc61f56041c7457b", + "zh:5e4b12dd1874bab454720a50c600d1df359dc71f91f4a0194cf8eb335e27dfe7", + "zh:6af55f892e7f463c75a62215dc74790ae9a71d7d23c74c6ddb40af258528fa46", + "zh:78f6739ca865622981c28fa6628128be4651bd4629450a9ba5b1945d64b66da7", + "zh:8ed469b0d9074eba59216e57794a03fe27b45586b03d24946d130949ae92093f", + "zh:a261aaee5675986711cf9f963d5d9ca5ec1d62aa8c31866da54c6670d803b8a3", + "zh:a64b52597738ff1bac41127141c48800f1575eaa66a67cecdc9b0b16728dae0e", + "zh:ae5e821f5d5510bc2cba2aefcf6c0e62af9e28b7a25e0e8dcd039e04172594d0", + "zh:cfae79ed700febe8fb29fd1c5d0a6ace0a0103bef8ec37bb653dc23afc960b33", + "zh:d9a69d5475982a00d4e9e07f56987c821782595bac29e9084237285d36fa88e8", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.8.1" + constraints = ">= 3.0.0, ~> 3.0" + hashes = [ + "h1:LsYuJLZcYl1RiH7Hd3w90Ra5+k5cNqfdRUQXItkTI8Y=", + "zh:25c458c7c676f15705e872202dad7dcd0982e4a48e7ea1800afa5fc64e77f4c8", + "zh:2edeaf6f1b20435b2f81855ad98a2e70956d473be9e52a5fdf57ccd0098ba476", + "zh:44becb9d5f75d55e36dfed0c5beabaf4c92e0a2bc61a3814d698271c646d48e7", + "zh:7699032612c3b16cc69928add8973de47b10ce81b1141f30644a0e8a895b5cd3", + "zh:86d07aa98d17703de9fbf402c89590dc1e01dbe5671dd6bc5e487eb8fe87eee0", + "zh:8c411c77b8390a49a8a1bc9f176529e6b32369dd33a723606c8533e5ca4d68c1", + "zh:a5ecc8255a612652a56b28149994985e2c4dc046e5d34d416d47fa7767f5c28f", + "zh:aea3fe1a5669b932eda9c5c72e5f327db8da707fe514aaca0d0ef60cb24892f9", + "zh:f56e26e6977f755d7ae56fa6320af96ecf4bb09580d47cb481efbf27f1c5afff", + ] +} diff --git a/examples/llm_chat_app/1_apply_terraform.sh b/examples/llm_chat_app/1_apply_terraform.sh new file mode 100755 index 0000000..8b8f633 --- /dev/null +++ b/examples/llm_chat_app/1_apply_terraform.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Task 1: Apply Terraform Configuration + +set -e + +echo "==============================================" +echo "Applying Terraform Configuration" +echo "==============================================" +echo "" + +cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app + +tofu apply -auto-approve + +echo "" +echo "==============================================" +echo "Terraform Apply Completed Successfully!" +echo "==============================================" diff --git a/examples/llm_chat_app/2_create_test_objects.sh b/examples/llm_chat_app/2_create_test_objects.sh new file mode 100755 index 0000000..6068b09 --- /dev/null +++ b/examples/llm_chat_app/2_create_test_objects.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Create test objects for verification + +set -e + +export PGHOST=localhost +export PGPORT=5432 +export PGDATABASE=llm_service + +echo "==============================================" +echo "Creating Test Objects" +echo "==============================================" +echo "" + +PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql <<'EOF' +-- Create test table in app schema +CREATE TABLE IF NOT EXISTS app.test_users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL +); +INSERT INTO app.test_users (name) VALUES ('test') ON CONFLICT DO NOTHING; + +-- Create test view in app schema +CREATE OR REPLACE VIEW app.test_users_view AS SELECT * FROM app.test_users; + +-- Create test function in app schema +CREATE OR REPLACE FUNCTION app.test_func() RETURNS integer +LANGUAGE sql SECURITY INVOKER +AS $$ SELECT 1; $$; + +-- Create test table in ref_data schemas +CREATE TABLE IF NOT EXISTS ref_data_pipeline_abc.test_ref ( + id SERIAL PRIMARY KEY, + value TEXT +); +INSERT INTO ref_data_pipeline_abc.test_ref (value) VALUES ('abc') ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS ref_data_pipeline_xyz.test_ref ( + id SERIAL PRIMARY KEY, + value TEXT +); +INSERT INTO ref_data_pipeline_xyz.test_ref (value) VALUES ('xyz') ON CONFLICT DO NOTHING; + +-- Create views in ref_data schemas +CREATE OR REPLACE VIEW ref_data_pipeline_abc.test_ref_view AS SELECT * FROM ref_data_pipeline_abc.test_ref; +CREATE OR REPLACE VIEW ref_data_pipeline_xyz.test_ref_view AS SELECT * FROM ref_data_pipeline_xyz.test_ref; + +SELECT 'Test objects created successfully!' AS result; +EOF + +echo "" +echo "==============================================" +echo "Test Objects Created Successfully!" +echo "==============================================" diff --git a/examples/llm_chat_app/3_run_verification_tests.sh b/examples/llm_chat_app/3_run_verification_tests.sh new file mode 100755 index 0000000..1e15fdb --- /dev/null +++ b/examples/llm_chat_app/3_run_verification_tests.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Run all verification tests + +set -e + +export PGHOST=localhost +export PGPORT=5432 +export PGDATABASE=llm_service + +echo "==============================================" +echo "Running Verification Tests" +echo "==============================================" +echo "" + +# Test 2: Migration Role DDL Access +echo "--- Test 2: Migration Role DDL Access ---" +PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql -c " +CREATE TABLE app.migration_test (id int); +ALTER TABLE app.migration_test ADD COLUMN name text; +DROP TABLE app.migration_test; +SELECT 'TEST 2 PASSED: Migration role has DDL access' AS result; +" +echo "" + +# Test 3: FastAPI RW Role +echo "--- Test 3: FastAPI RW Role - DML on app schema ---" +PGUSER=role_service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c " +SELECT * FROM app.test_users; +INSERT INTO app.test_users (name) VALUES ('fastapi_test'); +DELETE FROM app.test_users WHERE name = 'fastapi_test'; +SELECT 'TEST 3 PASSED: FastAPI RW has app DML' AS result; +" +echo "" + +# Test 4: FastAPI RO Role +echo "--- Test 4: FastAPI RO Role - SELECT only ---" +PGUSER=role_service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c " +SELECT * FROM app.test_users; +SELECT 'TEST 4 PASSED: FastAPI RO has SELECT' AS result; +" +echo "" + +# Test 5: Pipeline RW Role +echo "--- Test 5: Pipeline RW Role - All schemas access ---" +PGUSER=role_service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw psql -c " +SELECT * FROM app.test_users; +SELECT * FROM ref_data_pipeline_abc.test_ref; +SELECT * FROM ref_data_pipeline_xyz.test_ref; +SELECT 'TEST 5 PASSED: Pipeline RW has all schemas access' AS result; +" +echo "" + +# Test 6: Pipeline RO Role +echo "--- Test 6: Pipeline RO Role - Read access to all schemas ---" +PGUSER=role_service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro psql -c " +SELECT * FROM app.test_users; +SELECT * FROM ref_data_pipeline_abc.test_ref; +SELECT * FROM ref_data_pipeline_xyz.test_ref; +SELECT 'TEST 6 PASSED: Pipeline RO has SELECT on all schemas' AS result; +" +echo "" + +# Test 7: Connection Limits +echo "--- Test 7: Connection Limits ---" +PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql -c " +SELECT rolname, rolconnlimit +FROM pg_roles +WHERE rolname LIKE 'role_service_%' +ORDER BY rolname; +" +echo "" + +# Test 8: Role Inheritance +echo "--- Test 8: Role Inheritance ---" +PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql -c " +SELECT + r.rolname AS role, + ARRAY_AGG(m.rolname) AS member_of +FROM pg_roles r +LEFT JOIN pg_auth_members am ON r.oid = am.member +LEFT JOIN pg_roles m ON am.roleid = m.oid +WHERE r.rolname LIKE 'role_service_%' +GROUP BY r.rolname +ORDER BY r.rolname; +" +echo "" + +echo "==============================================" +echo "All Tests Completed!" +echo "==============================================" diff --git a/examples/llm_chat_app/4_cleanup.sh b/examples/llm_chat_app/4_cleanup.sh new file mode 100755 index 0000000..5d77302 --- /dev/null +++ b/examples/llm_chat_app/4_cleanup.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Cleanup: Delete all resources created by the example (but not admin_user) + +set -e + +export PGHOST=localhost +export PGPORT=5432 +export PGDATABASE=postgres # Connect to postgres db for cleanup + +echo "==============================================" +echo "Cleaning Up Example Resources" +echo "==============================================" +echo "" + +# Use admin_user to perform cleanup +export PGUSER=admin_user +export PGPASSWORD=insecure-pass-for-demo-admin-user + +echo "Step 1: Terminating connections to llm_service database..." +psql -c " +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname = 'llm_service' AND pid <> pg_backend_pid(); +" 2>/dev/null || true + +echo "" +echo "Step 2: Dropping database llm_service..." +psql -c "DROP DATABASE IF EXISTS llm_service;" + +echo "" +echo "Step 3: Dropping login roles..." +# Drop login roles first (they depend on group roles) +psql <<'EOF' +DROP ROLE IF EXISTS role_service_migrator; +DROP ROLE IF EXISTS role_service_fastapi_rw; +DROP ROLE IF EXISTS role_service_fastapi_ro; +DROP ROLE IF EXISTS role_service_pipeline_rw; +DROP ROLE IF EXISTS role_service_pipeline_ro; +EOF + +echo "" +echo "Step 4: Dropping group roles..." +# Drop group roles (no dependencies) +psql <<'EOF' +DROP ROLE IF EXISTS role_service_migration; +DROP ROLE IF EXISTS role_service_rw; +DROP ROLE IF EXISTS role_service_ro; +EOF + +echo "" +echo "Step 5: Dropping cluster-wide roles..." +psql <<'EOF' +DROP ROLE IF EXISTS role_pg_cluster_admin; +DROP ROLE IF EXISTS role_pg_monitoring; +EOF + +echo "" +echo "Step 6: Verifying cleanup..." +psql -c " +SELECT rolname FROM pg_roles +WHERE rolname LIKE 'role_service_%' OR rolname LIKE 'role_pg_%' +ORDER BY rolname; +" + +echo "" +echo "==============================================" +echo "Cleanup Completed Successfully!" +echo "==============================================" +echo "" +echo "Note: admin_user was preserved." +echo "To re-run the example, start with: ./1_apply_terraform.sh" diff --git a/examples/llm_chat_app/README.md b/examples/llm_chat_app/README.md new file mode 100644 index 0000000..d822402 --- /dev/null +++ b/examples/llm_chat_app/README.md @@ -0,0 +1,247 @@ +# Example: LLM Chat App Setup + +This example shows how to create a comprehensive role-based access control (RBAC) setup for an LLM pattern reviewer service with multiple schemas and permission boundaries. + +See [docs/llm_app_example.md](../../docs/llm_app_example.md) for the full design document. + +## Prerequisites + +1. **PostgreSQL running locally** (e.g., via Homebrew: `brew services start postgresql@17`) + +2. **Create an admin user** with CREATEROLE privilege: + ```bash + psql postgres -c "CREATE ROLE admin_user LOGIN CREATEROLE PASSWORD 'insecure-pass-for-demo-admin-user';" + ``` + +## Usage + +```bash +cd examples/llm_chat_app + +# Initialize Terraform +terraform init + +# Preview the changes +terraform plan + +# Apply the configuration +terraform apply +``` + +## Verify the Setup + +After `terraform apply` succeeds, run these tests to verify the RBAC configuration. + +### Set up environment + +```bash +export PGHOST=localhost +export PGPORT=5432 +export PGDATABASE=llm_service +``` + +### Quick smoke test + +```bash +# Migration role can connect and create tables +PGPASSWORD=demo-password-migrator psql -U service_migrator -c " + CREATE TABLE app.smoke_test (id serial); + DROP TABLE app.smoke_test; + SELECT 'Migration role works!' AS result; +" + +# FastAPI RW can connect +PGPASSWORD=demo-password-fastapi-rw psql -U service_fastapi_rw -c "SELECT 1 AS connected;" + +# Verify connection limits +PGPASSWORD=demo-password-migrator psql -U service_migrator -c " + SELECT rolname, rolconnlimit + FROM pg_roles + WHERE rolname LIKE 'role_service_%' OR rolname LIKE 'service_%' + ORDER BY rolname; +" +``` + +### Full test suite + +Run the included test scripts to verify all permissions: + +```bash +# Run all tests at once +./RUN_ALL_TESTS.sh + +# Or run steps individually: +./1_apply_terraform.sh # Apply the Terraform configuration +./2_create_test_objects.sh # Create test tables/views/functions +./3_run_verification_tests.sh # Verify RBAC permissions +./4_cleanup.sh # Clean up test objects and destroy infrastructure +``` + +See [docs/llm_app_example.md](../../docs/llm_app_example.md) for the full design document and detailed test plan. + +## Roles and Permissions + +Group roles (no login) use the `role_` prefix. Login roles do not have the prefix. + +| Role | Purpose | Login | Connection Limit | +| ------------------------ | -------------------------------------------------- | ----- | ---------------- | +| `role_pg_cluster_admin` | Creates users/roles across the cluster (Terraform) | Yes | - | +| `role_pg_monitoring` | System stats access (Datadog, Grafana) | Yes | - | +| `role_service_migration` | Group role that owns database, schemas, all DDL | No | - | +| `service_migrator` | Login role for CI/CD migrations | Yes | 5 | +| `role_service_rw` | Group role for read/write on `app` schema | No | - | +| `role_service_ro` | Group role for read-only on `app` schema | No | - | +| `service_fastapi_rw` | FastAPI backend with write access | Yes | 30 | +| `service_fastapi_ro` | FastAPI backend with read-only access | Yes | 30 | +| `service_pipeline_rw` | Data pipeline with write access to all schemas | Yes | 10 | +| `service_pipeline_ro` | Data pipeline with read-only access to all schemas | Yes | 10 | + +## Schema Access Matrix + +| | app | ref_data_abc | ref_data_xyz | +| ------------------- | --- | ------------ | ------------ | +| service_migrator | DDL | DDL | DDL | +| service_fastapi_rw | RW | - | - | +| service_fastapi_ro | RO | - | - | +| service_pipeline_rw | RW | RW | RW | +| service_pipeline_ro | RO | RO | RO | + +**Legend:** DDL = CREATE/ALTER/DROP, RW = CRUD, RO = SELECT only + +## Role Hierarchy Diagram + +```mermaid +flowchart TB + subgraph cluster["Cluster-Wide Roles"] + admin["role_pg_cluster_admin
login • creates roles"] + monitoring["role_pg_monitoring
login • pg_monitor member"] + end + + subgraph service["Service-Scoped Roles (llm_service)"] + subgraph groups["Group Roles (no login)"] + migration["role_service_migration
DDL on all schemas"] + rw["role_service_rw
DML on app schema"] + ro["role_service_ro
SELECT on app schema"] + end + + subgraph logins["Login Roles"] + migrator["service_migrator
conn_limit=5"] + fastapi_rw["service_fastapi_rw
conn_limit=30"] + fastapi_ro["service_fastapi_ro
conn_limit=30"] + pipeline_rw["service_pipeline_rw
conn_limit=10"] + pipeline_ro["service_pipeline_ro
conn_limit=10"] + end + + subgraph devs["Developer Accounts"] + senior["Senior Developers"] + junior["Junior/Mid Developers"] + end + end + + %% Inheritance arrows + migrator -->|inherits| migration + + fastapi_rw -->|inherits| rw + pipeline_rw -->|inherits| rw + senior -->|inherits| rw + + fastapi_ro -->|inherits| ro + pipeline_ro -->|inherits| ro + junior -->|inherits| ro + + %% Styling + classDef clusterRole fill:#e1f5fe,stroke:#01579b + classDef groupRole fill:#fff3e0,stroke:#e65100 + classDef loginRole fill:#e8f5e9,stroke:#2e7d32 + classDef devRole fill:#f3e5f5,stroke:#7b1fa2 + classDef migrationGroup fill:#fce4ec,stroke:#c2185b + + class admin,monitoring clusterRole + class rw,ro groupRole + class migration migrationGroup + class migrator,fastapi_rw,fastapi_ro,pipeline_rw,pipeline_ro loginRole + class senior,junior devRole +``` + +## Schema Access Diagram + +```mermaid +flowchart TB + subgraph ddl_access["DDL Access (Schema Owner)"] + migration["migration"] + end + + subgraph app_only["App Schema Only"] + fastapi_rw["fastapi_rw"] + fastapi_ro["fastapi_ro"] + end + + subgraph all_schemas["All Schemas Access"] + pipeline_rw["pipeline_rw"] + pipeline_ro["pipeline_ro"] + end + + subgraph schemas["Schemas"] + direction LR + app["app"] + ref_abc["ref_data_pipeline_abc"] + ref_xyz["ref_data_pipeline_xyz"] + end + + %% DDL access - straight down to all schemas + migration -->|DDL| app + migration -->|DDL| ref_abc + migration -->|DDL| ref_xyz + + %% App-only roles - single path to app schema + fastapi_rw -->|RW| app + fastapi_ro -->|RO| app + + %% Pipeline roles - access all schemas + pipeline_rw -->|RW| app + pipeline_rw -->|RW| ref_abc + pipeline_rw -->|RW| ref_xyz + + pipeline_ro -->|RO| app + pipeline_ro -->|RO| ref_abc + pipeline_ro -->|RO| ref_xyz + + %% Styling + classDef schemaNode fill:#e3f2fd,stroke:#1565c0 + classDef ddlRole fill:#fce4ec,stroke:#c2185b + classDef rwRole fill:#e8f5e9,stroke:#2e7d32 + classDef roRole fill:#fff8e1,stroke:#f57f17 + + class app,ref_abc,ref_xyz schemaNode + class migration ddlRole + class fastapi_rw,pipeline_rw rwRole + class fastapi_ro,pipeline_ro roRole +``` + +**Key insight:** FastAPI roles are isolated to the `app` schema, while pipeline roles have access to all schemas for cross-schema data operations. + +## Cleanup + +To tear down the example infrastructure: + +```bash +./4_cleanup.sh +``` + +This drops test objects and runs `terraform destroy` to remove all created roles, grants, and the database. + +## Note about authentication on Mac + +Some default `pg_hba.conf` Postgres settings will allow you to log into the Postgres server without ensuring a valid password. We ran into this when we installed `postgresql@17` via [Homebrew](https://brew.sh/). + +If passwords aren't being checked, your `pg_hba.conf` is set to `trust` mode. To enable password verification, change `trust` to `md5` in your `pg_hba.conf`: + +```bash +# Find your pg_hba.conf location +psql postgres -c "SHOW hba_file;" + +# Edit it to use md5 instead of trust for non-admin users +# Then reload: brew services restart postgresql@17 +``` + +See [examples/web_app/README.md](../web_app/README.md) for detailed `pg_hba.conf` examples. diff --git a/examples/llm_chat_app/RUN_ALL_TESTS.sh b/examples/llm_chat_app/RUN_ALL_TESTS.sh new file mode 100755 index 0000000..00f898f --- /dev/null +++ b/examples/llm_chat_app/RUN_ALL_TESTS.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Master script to run all tasks in sequence + +set -e + +SCRIPT_DIR="/Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app" + +echo "========================================" +echo "LLM Chat App - Complete Test Suite" +echo "========================================" +echo "" + +# Make scripts executable +chmod +x "${SCRIPT_DIR}/1_apply_terraform.sh" +chmod +x "${SCRIPT_DIR}/2_create_test_objects.sh" +chmod +x "${SCRIPT_DIR}/3_run_verification_tests.sh" + +# Step 1: Apply Terraform +echo "STEP 1/3: Applying Terraform Configuration..." +"${SCRIPT_DIR}/1_apply_terraform.sh" +echo "" + +# Step 2: Create Test Objects +echo "STEP 2/3: Creating Test Objects..." +"${SCRIPT_DIR}/2_create_test_objects.sh" +echo "" + +# Step 3: Run Verification Tests +echo "STEP 3/3: Running Verification Tests..." +"${SCRIPT_DIR}/3_run_verification_tests.sh" +echo "" + +echo "========================================" +echo "ALL TASKS COMPLETED SUCCESSFULLY!" +echo "========================================" diff --git a/examples/llm_chat_app/TEST_INSTRUCTIONS.md b/examples/llm_chat_app/TEST_INSTRUCTIONS.md new file mode 100644 index 0000000..3adb732 --- /dev/null +++ b/examples/llm_chat_app/TEST_INSTRUCTIONS.md @@ -0,0 +1,141 @@ +# LLM Chat App - Test Execution Instructions + +This directory contains scripts to apply the Terraform configuration and run verification tests for the LLM Chat App example. + +## Prerequisites + +1. PostgreSQL instance running on `localhost:5432` +2. Admin user created: + ```sql + CREATE ROLE admin_user LOGIN CREATEROLE PASSWORD 'insecure-pass-for-demo-admin-user'; + ``` +3. OpenTofu (tofu) installed +4. psql client installed + +## Quick Start - Run Everything + +To run all tasks in sequence: + +```bash +cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app +chmod +x RUN_ALL_TESTS.sh +./RUN_ALL_TESTS.sh +``` + +This will: + +1. Apply the Terraform configuration (creates roles, database, schemas, grants) +2. Create test objects (tables, views, functions) +3. Run all verification tests + +## Individual Steps + +If you prefer to run steps individually: + +### Step 1: Apply Terraform Configuration + +```bash +chmod +x 1_apply_terraform.sh +./1_apply_terraform.sh +``` + +This runs `tofu apply -auto-approve` to create: + +- Database: `llm_service` +- Roles: migration, group roles (rw/ro), login roles (fastapi_rw, fastapi_ro, pipeline_rw, pipeline_ro) +- Schemas: `app`, `ref_data_pipeline_abc`, `ref_data_pipeline_xyz` +- Grants and default privileges + +### Step 2: Create Test Objects + +```bash +chmod +x 2_create_test_objects.sh +./2_create_test_objects.sh +``` + +This creates: + +- Test tables in `app` and `ref_data_*` schemas +- Test views +- Test function + +### Step 3: Run Verification Tests + +```bash +chmod +x 3_run_verification_tests.sh +./3_run_verification_tests.sh +``` + +This runs the following tests: + +1. **Test 2: Migration Role DDL Access** - Verifies migration role can create/alter/drop objects +2. **Test 3: FastAPI RW Role** - Verifies DML on app schema and no DDL +3. **Test 4: FastAPI RO Role** - Verifies SELECT only +4. **Test 5: Pipeline RW Role** - Verifies access to all schemas +5. **Test 6: Pipeline RO Role** - Verifies read access to all schemas +6. **Test 7: Connection Limits** - Verifies connection limits are set correctly +7. **Test 8: Role Inheritance** - Verifies role memberships + +## Expected Test Results + +All tests should output: + +- `TEST X PASSED: [description]` + +If any test fails, you'll see an error message indicating the permission issue. + +## Test Roles and Credentials + +| Role | Password | Access Level | +| -------------------------- | --------------------------- | ------------------------- | +| `role_service_migration` | `demo-password-migration` | DDL + DML on all schemas | +| `role_service_fastapi_rw` | `demo-password-fastapi-rw` | DML on app schema only | +| `role_service_fastapi_ro` | `demo-password-fastapi-ro` | SELECT on app schema only | +| `role_service_pipeline_rw` | `demo-password-pipeline-rw` | DML on all schemas | +| `role_service_pipeline_ro` | `demo-password-pipeline-ro` | SELECT on all schemas | + +## Manual Test Commands + +You can also run individual tests manually. See `docs/llm_app_example.md` for the complete test plan with all commands. + +Example: + +```bash +export PGHOST=localhost +export PGPORT=5432 +export PGDATABASE=llm_service + +PGUSER=role_service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "SELECT * FROM app.test_users;" +``` + +## Cleanup + +To destroy all resources: + +```bash +cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app +tofu destroy -auto-approve +``` + +## Troubleshooting + +### Connection Refused + +- Ensure PostgreSQL is running on localhost:5432 +- Check pg_hba.conf allows password authentication + +### Permission Denied + +- Ensure `admin_user` role exists and has CREATEROLE privilege +- Check the password in `fixtures.auto.tfvars` matches your setup + +### Role Already Exists + +- If re-running, you may need to destroy first: `tofu destroy -auto-approve` +- Or manually drop roles: `DROP ROLE IF EXISTS role_service_* CASCADE;` + +## Architecture + +For detailed information about the role architecture, schemas, and design decisions, see: + +- `/Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/docs/llm_app_example.md` diff --git a/examples/llm_chat_app/fixtures.auto.tfvars b/examples/llm_chat_app/fixtures.auto.tfvars new file mode 100644 index 0000000..3c27b16 --- /dev/null +++ b/examples/llm_chat_app/fixtures.auto.tfvars @@ -0,0 +1,172 @@ +# llm_chat_app/fixtures.auto.tfvars + +# PostgreSQL connection settings +# postgres shell commands to create this user: +# CREATE ROLE admin_user LOGIN CREATEROLE PASSWORD 'insecure-pass-for-demo-admin-user'; +# GRANT pg_monitor TO admin_user WITH ADMIN OPTION; +db_username = "admin_user" +db_password = "insecure-pass-for-demo-admin-user" +db_scheme = "postgres" +db_hostname = "localhost" +db_port = 5432 +db_superuser = false +db_sslmode = "disable" + +# Database configuration +databases = [ + { + name = "llm_service" + connection_limit = 100 + } +] + +# Role configuration +roles = [ + # ======================================== + # Cluster-wide roles + # ======================================== + + # Cluster admin role - manages users/roles across all databases + { + role = { + name = "role_pg_cluster_admin" + login = true + inherit = true + create_role = true + create_database = false + password = "demo-password-cluster-admin" + } + }, + + # Monitoring role - read-only access to system statistics + { + role = { + name = "role_pg_monitoring" + login = true + inherit = true + roles = ["pg_monitor"] + password = "demo-password-monitoring" + } + }, + + # ======================================== + # Service-scoped roles (llm_service) + # ======================================== + + # Migration group role - owns database, schemas, and all objects (no login) + { + role = { + name = "role_service_migration" + login = false # group role, no login + inherit = true + create_role = false + create_database = false + } + database_grants = { + role = "role_service_migration" + database = "llm_service" + object_type = "database" + privileges = ["CREATE", "CONNECT", "TEMPORARY"] + } + }, + + # Migration login role - inherits from migration group + { + role = { + name = "service_migrator" + login = true + inherit = true + roles = ["role_service_migration"] + connection_limit = 5 + password = "demo-password-migrator" + } + }, + + # ======================================== + # Group roles (no login) + # ======================================== + + # RW group role - read/write permissions on app schema + # Note: Schema-specific grants are in main.tf (depends on schema creation) + { + role = { + name = "role_service_rw" + login = false + inherit = true + } + database_grants = { + role = "role_service_rw" + database = "llm_service" + object_type = "database" + privileges = ["CONNECT"] + } + }, + + # RO group role - read-only permissions on app schema + # Note: Schema-specific grants are in main.tf (depends on schema creation) + { + role = { + name = "role_service_ro" + login = false + inherit = true + } + database_grants = { + role = "role_service_ro" + database = "llm_service" + object_type = "database" + privileges = ["CONNECT"] + } + }, + + # ======================================== + # Login roles (Application Processes) + # ======================================== + + # FastAPI backend - read/write + { + role = { + name = "service_fastapi_rw" + login = true + inherit = true + roles = ["role_service_rw"] + connection_limit = 30 + password = "demo-password-fastapi-rw" + } + }, + + # FastAPI backend - read-only + { + role = { + name = "service_fastapi_ro" + login = true + inherit = true + roles = ["role_service_ro"] + connection_limit = 30 + password = "demo-password-fastapi-ro" + } + }, + + # Data pipeline - read/write (inherits app access, ref_data grants in main.tf) + { + role = { + name = "service_pipeline_rw" + login = true + inherit = true + roles = ["role_service_rw"] + connection_limit = 10 + password = "demo-password-pipeline-rw" + } + }, + + # Data pipeline - read-only (inherits app access, ref_data grants in main.tf) + { + role = { + name = "service_pipeline_ro" + login = true + inherit = true + roles = ["role_service_ro"] + connection_limit = 10 + password = "demo-password-pipeline-ro" + } + } +] diff --git a/examples/llm_chat_app/main.tf b/examples/llm_chat_app/main.tf new file mode 100644 index 0000000..5743d71 --- /dev/null +++ b/examples/llm_chat_app/main.tf @@ -0,0 +1,566 @@ +# llm_chat_app/main.tf + +module "postgres_automation" { + source = "../../" + + databases = var.databases + roles = var.roles +} + +# Create schemas with migration role as owner +resource "postgresql_schema" "app" { + name = "app" + database = "llm_service" + owner = "role_service_migration" + + depends_on = [module.postgres_automation] +} + +resource "postgresql_schema" "ref_data_pipeline_abc" { + name = "ref_data_pipeline_abc" + database = "llm_service" + owner = "role_service_migration" + + depends_on = [module.postgres_automation] +} + +resource "postgresql_schema" "ref_data_pipeline_xyz" { + name = "ref_data_pipeline_xyz" + database = "llm_service" + owner = "role_service_migration" + + depends_on = [module.postgres_automation] +} + +# ======================================== +# RW Group Role - App Schema Grants +# ======================================== +# These grants must be in main.tf because they depend on schema creation + +resource "postgresql_grant" "rw_app_schema" { + database = "llm_service" + role = "role_service_rw" + schema = "app" + object_type = "schema" + privileges = ["USAGE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_grant" "rw_app_tables" { + database = "llm_service" + role = "role_service_rw" + schema = "app" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_grant" "rw_app_sequences" { + database = "llm_service" + role = "role_service_rw" + schema = "app" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_default_privileges" "rw_app_tables" { + database = "llm_service" + role = "role_service_rw" + schema = "app" + owner = "role_service_migration" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_default_privileges" "rw_app_sequences" { + database = "llm_service" + role = "role_service_rw" + schema = "app" + owner = "role_service_migration" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_default_privileges" "rw_app_functions" { + database = "llm_service" + role = "role_service_rw" + schema = "app" + owner = "role_service_migration" + object_type = "function" + privileges = ["EXECUTE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +# ======================================== +# RO Group Role - App Schema Grants +# ======================================== + +resource "postgresql_grant" "ro_app_schema" { + database = "llm_service" + role = "role_service_ro" + schema = "app" + object_type = "schema" + privileges = ["USAGE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_grant" "ro_app_tables" { + database = "llm_service" + role = "role_service_ro" + schema = "app" + object_type = "table" + privileges = ["SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_grant" "ro_app_sequences" { + database = "llm_service" + role = "role_service_ro" + schema = "app" + object_type = "sequence" + privileges = ["USAGE", "SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_default_privileges" "ro_app_tables" { + database = "llm_service" + role = "role_service_ro" + schema = "app" + owner = "role_service_migration" + object_type = "table" + privileges = ["SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_default_privileges" "ro_app_sequences" { + database = "llm_service" + role = "role_service_ro" + schema = "app" + owner = "role_service_migration" + object_type = "sequence" + privileges = ["USAGE", "SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_default_privileges" "ro_app_functions" { + database = "llm_service" + role = "role_service_ro" + schema = "app" + owner = "role_service_migration" + object_type = "function" + privileges = ["EXECUTE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +# ======================================== +# Revoke PUBLIC Privileges +# ======================================== + +# Revoke PUBLIC privileges on database +# This prevents any authenticated cluster user from connecting +resource "postgresql_grant" "revoke_public_connect" { + database = "llm_service" + role = "public" + object_type = "database" + privileges = [] + + depends_on = [module.postgres_automation] +} + +# Revoke PUBLIC privileges on public schema +resource "postgresql_grant" "revoke_public_schema" { + database = "llm_service" + role = "public" + schema = "public" + object_type = "schema" + privileges = [] + + depends_on = [module.postgres_automation] +} + +# ======================================== +# Migration Role - Additional Schema Grants +# ======================================== +# Note: Migration role owns all schemas (set via postgresql_schema.owner above) +# These grants provide the necessary privileges for DDL operations + +# App schema grants for migration role +resource "postgresql_grant" "migration_app_schema" { + database = "llm_service" + role = "role_service_migration" + schema = "app" + object_type = "schema" + privileges = ["USAGE", "CREATE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_grant" "migration_app_tables" { + database = "llm_service" + role = "role_service_migration" + schema = "app" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +resource "postgresql_grant" "migration_app_sequences" { + database = "llm_service" + role = "role_service_migration" + schema = "app" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.app] +} + +# ref_data_pipeline_abc schema grants for migration role +resource "postgresql_grant" "migration_abc_schema" { + database = "llm_service" + role = "role_service_migration" + schema = "ref_data_pipeline_abc" + object_type = "schema" + privileges = ["USAGE", "CREATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "migration_abc_tables" { + database = "llm_service" + role = "role_service_migration" + schema = "ref_data_pipeline_abc" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "migration_abc_sequences" { + database = "llm_service" + role = "role_service_migration" + schema = "ref_data_pipeline_abc" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +# ref_data_pipeline_xyz schema grants for migration role +resource "postgresql_grant" "migration_xyz_schema" { + database = "llm_service" + role = "role_service_migration" + schema = "ref_data_pipeline_xyz" + object_type = "schema" + privileges = ["USAGE", "CREATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_grant" "migration_xyz_tables" { + database = "llm_service" + role = "role_service_migration" + schema = "ref_data_pipeline_xyz" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_grant" "migration_xyz_sequences" { + database = "llm_service" + role = "role_service_migration" + schema = "ref_data_pipeline_xyz" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +# ======================================== +# Pipeline RW Login Role - ref_data Schema Grants +# ======================================== +# Note: service_pipeline_rw inherits app schema access from role_service_rw +# These grants provide additional access to ref_data schemas + +resource "postgresql_grant" "pipeline_rw_abc_schema" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_abc" + object_type = "schema" + privileges = ["USAGE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "pipeline_rw_abc_tables" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_abc" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "pipeline_rw_abc_sequences" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_abc" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "pipeline_rw_xyz_schema" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_xyz" + object_type = "schema" + privileges = ["USAGE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_grant" "pipeline_rw_xyz_tables" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_xyz" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_grant" "pipeline_rw_xyz_sequences" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_xyz" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +# ======================================== +# Pipeline RO Login Role - ref_data Schema Grants +# ======================================== +# Note: service_pipeline_ro inherits app schema access from role_service_ro +# These grants provide additional access to ref_data schemas + +resource "postgresql_grant" "pipeline_ro_abc_schema" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_abc" + object_type = "schema" + privileges = ["USAGE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "pipeline_ro_abc_tables" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_abc" + object_type = "table" + privileges = ["SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "pipeline_ro_abc_sequences" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_abc" + object_type = "sequence" + privileges = ["USAGE", "SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_grant" "pipeline_ro_xyz_schema" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_xyz" + object_type = "schema" + privileges = ["USAGE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_grant" "pipeline_ro_xyz_tables" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_xyz" + object_type = "table" + privileges = ["SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_grant" "pipeline_ro_xyz_sequences" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_xyz" + object_type = "sequence" + privileges = ["USAGE", "SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +# ======================================== +# Default Privileges for ref_data Schemas +# ======================================== +# These ensure new objects created by migration role automatically grant access to pipeline roles + +# ref_data_pipeline_abc - pipeline_rw +resource "postgresql_default_privileges" "pipeline_rw_abc_tables" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_abc" + owner = "role_service_migration" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_default_privileges" "pipeline_rw_abc_sequences" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_abc" + owner = "role_service_migration" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_default_privileges" "pipeline_rw_abc_functions" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_abc" + owner = "role_service_migration" + object_type = "function" + privileges = ["EXECUTE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +# ref_data_pipeline_abc - pipeline_ro +resource "postgresql_default_privileges" "pipeline_ro_abc_tables" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_abc" + owner = "role_service_migration" + object_type = "table" + privileges = ["SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_default_privileges" "pipeline_ro_abc_sequences" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_abc" + owner = "role_service_migration" + object_type = "sequence" + privileges = ["USAGE", "SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +resource "postgresql_default_privileges" "pipeline_ro_abc_functions" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_abc" + owner = "role_service_migration" + object_type = "function" + privileges = ["EXECUTE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] +} + +# ref_data_pipeline_xyz - pipeline_rw +resource "postgresql_default_privileges" "pipeline_rw_xyz_tables" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_xyz" + owner = "role_service_migration" + object_type = "table" + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_default_privileges" "pipeline_rw_xyz_sequences" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_xyz" + owner = "role_service_migration" + object_type = "sequence" + privileges = ["USAGE", "SELECT", "UPDATE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_default_privileges" "pipeline_rw_xyz_functions" { + database = "llm_service" + role = "service_pipeline_rw" + schema = "ref_data_pipeline_xyz" + owner = "role_service_migration" + object_type = "function" + privileges = ["EXECUTE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +# ref_data_pipeline_xyz - pipeline_ro +resource "postgresql_default_privileges" "pipeline_ro_xyz_tables" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_xyz" + owner = "role_service_migration" + object_type = "table" + privileges = ["SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_default_privileges" "pipeline_ro_xyz_sequences" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_xyz" + owner = "role_service_migration" + object_type = "sequence" + privileges = ["USAGE", "SELECT"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} + +resource "postgresql_default_privileges" "pipeline_ro_xyz_functions" { + database = "llm_service" + role = "service_pipeline_ro" + schema = "ref_data_pipeline_xyz" + owner = "role_service_migration" + object_type = "function" + privileges = ["EXECUTE"] + + depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] +} diff --git a/examples/llm_chat_app/outputs.tf b/examples/llm_chat_app/outputs.tf new file mode 100644 index 0000000..7a90aef --- /dev/null +++ b/examples/llm_chat_app/outputs.tf @@ -0,0 +1,21 @@ +# llm_chat_app/outputs.tf + +output "databases" { + value = module.postgres_automation.databases +} + +output "database_access" { + value = module.postgres_automation.database_access +} + +output "default_privileges" { + value = module.postgres_automation.default_privileges +} + +output "schema_access" { + value = module.postgres_automation.schema_access +} + +output "table_access" { + value = module.postgres_automation.table_access +} diff --git a/examples/llm_chat_app/providers.tf b/examples/llm_chat_app/providers.tf new file mode 100644 index 0000000..2f1c460 --- /dev/null +++ b/examples/llm_chat_app/providers.tf @@ -0,0 +1,11 @@ +# llm_chat_app/providers.tf + +provider "postgresql" { + scheme = var.db_scheme + host = var.db_hostname + username = var.db_username + port = var.db_port + password = var.db_password + superuser = var.db_superuser + sslmode = var.db_sslmode +} diff --git a/examples/llm_chat_app/run_tests.sh b/examples/llm_chat_app/run_tests.sh new file mode 100644 index 0000000..af242f4 --- /dev/null +++ b/examples/llm_chat_app/run_tests.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# LLM Chat App - Apply Terraform and Run Verification Tests + +set -e # Exit on error + +echo "==============================================" +echo "Task 1: Applying Terraform Configuration" +echo "==============================================" +echo "" + +cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app + +# Run terraform apply +tofu apply -auto-approve + +echo "" +echo "==============================================" +echo "Terraform Apply Completed Successfully" +echo "==============================================" +echo "" +echo "==============================================" +echo "Task 2: Running Verification Tests" +echo "==============================================" +echo "" + +# Set connection variables +export PGHOST=localhost +export PGPORT=5432 +export PGDATABASE=llm_service + +echo "--- Prerequisites: Creating Test Objects ---" +echo "" + +PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql <<'EOF' +-- Create test table in app schema +CREATE TABLE IF NOT EXISTS app.test_users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL +); +INSERT INTO app.test_users (name) VALUES ('test') ON CONFLICT DO NOTHING; + +-- Create test view in app schema +CREATE OR REPLACE VIEW app.test_users_view AS SELECT * FROM app.test_users; + +-- Create test function in app schema +CREATE OR REPLACE FUNCTION app.test_func() RETURNS integer +LANGUAGE sql SECURITY INVOKER +AS $$ SELECT 1; $$; + +-- Create test table in ref_data schemas +CREATE TABLE IF NOT EXISTS ref_data_pipeline_abc.test_ref ( + id SERIAL PRIMARY KEY, + value TEXT +); +INSERT INTO ref_data_pipeline_abc.test_ref (value) VALUES ('abc') ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS ref_data_pipeline_xyz.test_ref ( + id SERIAL PRIMARY KEY, + value TEXT +); +INSERT INTO ref_data_pipeline_xyz.test_ref (value) VALUES ('xyz') ON CONFLICT DO NOTHING; + +-- Create views in ref_data schemas +CREATE OR REPLACE VIEW ref_data_pipeline_abc.test_ref_view AS SELECT * FROM ref_data_pipeline_abc.test_ref; +CREATE OR REPLACE VIEW ref_data_pipeline_xyz.test_ref_view AS SELECT * FROM ref_data_pipeline_xyz.test_ref; +EOF + +echo "" +echo "Test objects created successfully!" +echo "" +echo "==============================================" +echo "Running Verification Tests" +echo "==============================================" +echo "" + +# Test 2: Migration Role DDL Access +echo "--- Test 2: Migration Role DDL Access ---" +PGUSER=role_service_migration PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +CREATE TABLE app.migration_test (id int); +ALTER TABLE app.migration_test ADD COLUMN name text; +DROP TABLE app.migration_test; +SELECT 'TEST 2 PASSED: Migration role has DDL access' AS result; +" +echo "" + +# Test 3: FastAPI RW Role +echo "--- Test 3: FastAPI RW Role - DML on app schema ---" +PGUSER=role_service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +SELECT * FROM app.test_users; +INSERT INTO app.test_users (name) VALUES ('fastapi_test'); +DELETE FROM app.test_users WHERE name = 'fastapi_test'; +SELECT 'TEST 3 PASSED: FastAPI RW has app DML' AS result; +" +echo "" + +# Test 4: FastAPI RO Role +echo "--- Test 4: FastAPI RO Role - SELECT only ---" +PGUSER=role_service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +SELECT * FROM app.test_users; +SELECT 'TEST 4 PASSED: FastAPI RO has SELECT' AS result; +" +echo "" + +# Test 5: Pipeline RW Role +echo "--- Test 5: Pipeline RW Role - All schemas access ---" +PGUSER=role_service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +SELECT * FROM app.test_users; +SELECT * FROM ref_data_pipeline_abc.test_ref; +SELECT * FROM ref_data_pipeline_xyz.test_ref; +SELECT 'TEST 5 PASSED: Pipeline RW has all schemas access' AS result; +" +echo "" + +# Test 6: Pipeline RO Role +echo "--- Test 6: Pipeline RO Role - Read access to all schemas ---" +PGUSER=role_service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +SELECT * FROM app.test_users; +SELECT * FROM ref_data_pipeline_abc.test_ref; +SELECT * FROM ref_data_pipeline_xyz.test_ref; +SELECT 'TEST 6 PASSED: Pipeline RO has SELECT on all schemas' AS result; +" +echo "" + +# Test 7: Connection Limits +echo "--- Test 7: Connection Limits ---" +PGUSER=role_service_migration PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +SELECT rolname, rolconnlimit +FROM pg_roles +WHERE rolname LIKE 'role_service_%' +ORDER BY rolname; +" +echo "" + +# Test 8: Role Inheritance +echo "--- Test 8: Role Inheritance ---" +PGUSER=role_service_migration PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +SELECT + r.rolname AS role, + ARRAY_AGG(m.rolname) AS member_of +FROM pg_roles r +LEFT JOIN pg_auth_members am ON r.oid = am.member +LEFT JOIN pg_roles m ON am.roleid = m.oid +WHERE r.rolname LIKE 'role_service_%' +GROUP BY r.rolname +ORDER BY r.rolname; +" +echo "" + +echo "==============================================" +echo "All Tests Completed!" +echo "==============================================" diff --git a/examples/llm_chat_app/variables.tf b/examples/llm_chat_app/variables.tf new file mode 100644 index 0000000..160fa35 --- /dev/null +++ b/examples/llm_chat_app/variables.tf @@ -0,0 +1,111 @@ +# llm_chat_app/variables.tf + +variable "db_hostname" { + type = string + description = "The hostname of the database instance." +} + +variable "db_username" { + type = string + description = "The username of the database instance." +} + +variable "db_password" { + type = string + description = "The password of the database instance." + sensitive = true +} + +variable "db_port" { + type = number + description = "The port of the database instance." +} + +variable "db_scheme" { + type = string + description = "The scheme of the database instance." +} + +variable "db_superuser" { + type = bool + description = "Whether the database instance is a superuser." +} + +variable "db_sslmode" { + type = string + description = "The SSL mode of the database instance." +} + +variable "databases" { + type = list(object({ + name = string + connection_limit = number + })) + default = [] +} + + +variable "roles" { + type = list(object({ + role = object({ + # See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role + name = string + superuser = optional(bool) + create_database = optional(bool) + create_role = optional(bool) + inherit = optional(bool) + login = optional(bool) + replication = optional(bool) + bypass_row_level_security = optional(bool) + connection_limit = optional(number) + encrypted_password = optional(bool) + password = optional(string) + roles = optional(list(string)) + search_path = optional(list(string)) + valid_until = optional(string) + skip_drop_role = optional(bool) + skip_reassign_owned = optional(bool) + statement_timeout = optional(number) + assume_role = optional(string) + }) + default_privileges = optional(list(object({ + role = string + database = string + schema = string + owner = string + object_type = string + privileges = list(string) + }))) + database_grants = optional(object({ + role = string + database = string + object_type = string + privileges = list(string) + })) + schema_grants = optional(object({ + role = string + database = string + schema = string + object_type = string + privileges = list(string) + })) + table_grants = optional(object({ + role = string + database = string + schema = string + object_type = string + objects = list(string) + privileges = list(string) + })) + sequence_grants = optional(object({ + role = string + database = string + schema = string + object_type = string + objects = list(string) + privileges = list(string) + })) + })) + default = [] + description = "List of static postgres roles to create and related permissions. These are for applications that use static credentials and don't use IAM DB Auth. See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role" +} diff --git a/examples/llm_chat_app/versions.tf b/examples/llm_chat_app/versions.tf new file mode 100644 index 0000000..d0e16f3 --- /dev/null +++ b/examples/llm_chat_app/versions.tf @@ -0,0 +1,15 @@ +# llm_chat_app/versions.tf + +terraform { + required_version = "~> 1.0" + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + version = "~> 1.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} From f9d82ed976d9b4385578f12405ef9d8cd773c3c5 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 16:38:05 -0700 Subject: [PATCH 03/14] linting --- main.tf | 2 -- 1 file changed, 2 deletions(-) diff --git a/main.tf b/main.tf index e416f7d..91aeb3e 100644 --- a/main.tf +++ b/main.tf @@ -28,8 +28,6 @@ locals { _table_grants = [for role in local._roles_with_passwords : role.table_grants if try(role.table_grants, null) != null] table_grants_map = { for grant in local._table_grants : format("%s-%s-%s", grant.role, grant.schema, grant.database) => grant } - roles_map = { for role in local._roles_with_passwords : role.role.name => role } - databases_map = { for database in var.databases : database.name => database } # Built-in PostgreSQL roles that don't need to be created by this module From 51916eaf1f4a008432bd4ae8f3c052fc2f69d335 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 16:48:14 -0700 Subject: [PATCH 04/14] docs: add some missing pieces --- examples/llm_chat_app/README.md | 14 ++++++++++---- examples/llm_chat_app/TEST_INSTRUCTIONS.md | 8 -------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/examples/llm_chat_app/README.md b/examples/llm_chat_app/README.md index d822402..812e7de 100644 --- a/examples/llm_chat_app/README.md +++ b/examples/llm_chat_app/README.md @@ -2,8 +2,6 @@ This example shows how to create a comprehensive role-based access control (RBAC) setup for an LLM pattern reviewer service with multiple schemas and permission boundaries. -See [docs/llm_app_example.md](../../docs/llm_app_example.md) for the full design document. - ## Prerequisites 1. **PostgreSQL running locally** (e.g., via Homebrew: `brew services start postgresql@17`) @@ -77,8 +75,6 @@ Run the included test scripts to verify all permissions: ./4_cleanup.sh # Clean up test objects and destroy infrastructure ``` -See [docs/llm_app_example.md](../../docs/llm_app_example.md) for the full design document and detailed test plan. - ## Roles and Permissions Group roles (no login) use the `role_` prefix. Login roles do not have the prefix. @@ -96,6 +92,16 @@ Group roles (no login) use the `role_` prefix. Login roles do not have the prefi | `service_pipeline_rw` | Data pipeline with write access to all schemas | Yes | 10 | | `service_pipeline_ro` | Data pipeline with read-only access to all schemas | Yes | 10 | +## Schemas + +| Schema | Purpose | +| -------------- | ------------------------------------------------------------------ | +| `app` | LLM Chat Application tables (users, conversations, messages, etc.) | +| `ref_data_abc` | Data pipeline for document corpus ingestion (supports RAG) | +| `ref_data_xyz` | Data pipeline for document corpus processing (supports RAG) | + +The `ref_data_*` schemas are managed by separate data pipelines that populate the document corpus used by the Chat Application's RAG (Retrieval-Augmented Generation) system. + ## Schema Access Matrix | | app | ref_data_abc | ref_data_xyz | diff --git a/examples/llm_chat_app/TEST_INSTRUCTIONS.md b/examples/llm_chat_app/TEST_INSTRUCTIONS.md index 3adb732..48dcd97 100644 --- a/examples/llm_chat_app/TEST_INSTRUCTIONS.md +++ b/examples/llm_chat_app/TEST_INSTRUCTIONS.md @@ -96,8 +96,6 @@ If any test fails, you'll see an error message indicating the permission issue. ## Manual Test Commands -You can also run individual tests manually. See `docs/llm_app_example.md` for the complete test plan with all commands. - Example: ```bash @@ -133,9 +131,3 @@ tofu destroy -auto-approve - If re-running, you may need to destroy first: `tofu destroy -auto-approve` - Or manually drop roles: `DROP ROLE IF EXISTS role_service_* CASCADE;` - -## Architecture - -For detailed information about the role architecture, schemas, and design decisions, see: - -- `/Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/docs/llm_app_example.md` From d885cb56a3d522f05d4d588375bb2d87984616b8 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 17:16:44 -0700 Subject: [PATCH 05/14] fix: remove machine specific paths --- examples/llm_chat_app/1_apply_terraform.sh | 2 +- examples/llm_chat_app/RUN_ALL_TESTS.sh | 2 +- examples/llm_chat_app/TEST_INSTRUCTIONS.md | 4 ++-- examples/llm_chat_app/run_tests.sh | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/llm_chat_app/1_apply_terraform.sh b/examples/llm_chat_app/1_apply_terraform.sh index 8b8f633..eac2c69 100755 --- a/examples/llm_chat_app/1_apply_terraform.sh +++ b/examples/llm_chat_app/1_apply_terraform.sh @@ -8,7 +8,7 @@ echo "Applying Terraform Configuration" echo "==============================================" echo "" -cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app +cd "$(dirname "${BASH_SOURCE[0]}")" tofu apply -auto-approve diff --git a/examples/llm_chat_app/RUN_ALL_TESTS.sh b/examples/llm_chat_app/RUN_ALL_TESTS.sh index 00f898f..29542e3 100755 --- a/examples/llm_chat_app/RUN_ALL_TESTS.sh +++ b/examples/llm_chat_app/RUN_ALL_TESTS.sh @@ -3,7 +3,7 @@ set -e -SCRIPT_DIR="/Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "========================================" echo "LLM Chat App - Complete Test Suite" diff --git a/examples/llm_chat_app/TEST_INSTRUCTIONS.md b/examples/llm_chat_app/TEST_INSTRUCTIONS.md index 48dcd97..911ddae 100644 --- a/examples/llm_chat_app/TEST_INSTRUCTIONS.md +++ b/examples/llm_chat_app/TEST_INSTRUCTIONS.md @@ -17,7 +17,7 @@ This directory contains scripts to apply the Terraform configuration and run ver To run all tasks in sequence: ```bash -cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app +cd examples/llm_chat_app chmod +x RUN_ALL_TESTS.sh ./RUN_ALL_TESTS.sh ``` @@ -111,7 +111,7 @@ PGUSER=role_service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "SELE To destroy all resources: ```bash -cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app +cd examples/llm_chat_app tofu destroy -auto-approve ``` diff --git a/examples/llm_chat_app/run_tests.sh b/examples/llm_chat_app/run_tests.sh index af242f4..3ec8c61 100644 --- a/examples/llm_chat_app/run_tests.sh +++ b/examples/llm_chat_app/run_tests.sh @@ -8,7 +8,7 @@ echo "Task 1: Applying Terraform Configuration" echo "==============================================" echo "" -cd /Users/weston/clients/masterpoint/terraform-postgres-config-dbs-users-roles/examples/llm_chat_app +cd "$(dirname "${BASH_SOURCE[0]}")" # Run terraform apply tofu apply -auto-approve From 1c1d6a55a90deb8071d6349ab8a1e18af80a27ea Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 17:16:57 -0700 Subject: [PATCH 06/14] Update examples/llm_chat_app/TEST_INSTRUCTIONS.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- examples/llm_chat_app/TEST_INSTRUCTIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/llm_chat_app/TEST_INSTRUCTIONS.md b/examples/llm_chat_app/TEST_INSTRUCTIONS.md index 911ddae..3f1edcb 100644 --- a/examples/llm_chat_app/TEST_INSTRUCTIONS.md +++ b/examples/llm_chat_app/TEST_INSTRUCTIONS.md @@ -130,4 +130,4 @@ tofu destroy -auto-approve ### Role Already Exists - If re-running, you may need to destroy first: `tofu destroy -auto-approve` -- Or manually drop roles: `DROP ROLE IF EXISTS role_service_* CASCADE;` +- Or manually drop roles using `4_cleanup.sh` script, or run individual DROP statements From 53d3cd7a37fb487fed5f10e1151f756f52ba29e1 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 17:25:02 -0700 Subject: [PATCH 07/14] fix(docs): make sure we're using roles with login to run things --- examples/llm_chat_app/2_create_test_objects.sh | 2 +- .../llm_chat_app/3_run_verification_tests.sh | 14 +++++++------- examples/llm_chat_app/TEST_INSTRUCTIONS.md | 16 ++++++++-------- examples/llm_chat_app/run_tests.sh | 16 ++++++++-------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/examples/llm_chat_app/2_create_test_objects.sh b/examples/llm_chat_app/2_create_test_objects.sh index 6068b09..de4880f 100755 --- a/examples/llm_chat_app/2_create_test_objects.sh +++ b/examples/llm_chat_app/2_create_test_objects.sh @@ -12,7 +12,7 @@ echo "Creating Test Objects" echo "==============================================" echo "" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql <<'EOF' +PGUSER=service_migrator PGPASSWORD=demo-password-migration psql <<'EOF' -- Create test table in app schema CREATE TABLE IF NOT EXISTS app.test_users ( id SERIAL PRIMARY KEY, diff --git a/examples/llm_chat_app/3_run_verification_tests.sh b/examples/llm_chat_app/3_run_verification_tests.sh index 1e15fdb..e06e972 100755 --- a/examples/llm_chat_app/3_run_verification_tests.sh +++ b/examples/llm_chat_app/3_run_verification_tests.sh @@ -14,7 +14,7 @@ echo "" # Test 2: Migration Role DDL Access echo "--- Test 2: Migration Role DDL Access ---" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration psql -c " CREATE TABLE app.migration_test (id int); ALTER TABLE app.migration_test ADD COLUMN name text; DROP TABLE app.migration_test; @@ -24,7 +24,7 @@ echo "" # Test 3: FastAPI RW Role echo "--- Test 3: FastAPI RW Role - DML on app schema ---" -PGUSER=role_service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c " +PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c " SELECT * FROM app.test_users; INSERT INTO app.test_users (name) VALUES ('fastapi_test'); DELETE FROM app.test_users WHERE name = 'fastapi_test'; @@ -34,7 +34,7 @@ echo "" # Test 4: FastAPI RO Role echo "--- Test 4: FastAPI RO Role - SELECT only ---" -PGUSER=role_service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c " +PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c " SELECT * FROM app.test_users; SELECT 'TEST 4 PASSED: FastAPI RO has SELECT' AS result; " @@ -42,7 +42,7 @@ echo "" # Test 5: Pipeline RW Role echo "--- Test 5: Pipeline RW Role - All schemas access ---" -PGUSER=role_service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw psql -c " +PGUSER=service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; @@ -52,7 +52,7 @@ echo "" # Test 6: Pipeline RO Role echo "--- Test 6: Pipeline RO Role - Read access to all schemas ---" -PGUSER=role_service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro psql -c " +PGUSER=service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; @@ -62,7 +62,7 @@ echo "" # Test 7: Connection Limits echo "--- Test 7: Connection Limits ---" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration psql -c " SELECT rolname, rolconnlimit FROM pg_roles WHERE rolname LIKE 'role_service_%' @@ -72,7 +72,7 @@ echo "" # Test 8: Role Inheritance echo "--- Test 8: Role Inheritance ---" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration psql -c " SELECT r.rolname AS role, ARRAY_AGG(m.rolname) AS member_of diff --git a/examples/llm_chat_app/TEST_INSTRUCTIONS.md b/examples/llm_chat_app/TEST_INSTRUCTIONS.md index 3f1edcb..74708b4 100644 --- a/examples/llm_chat_app/TEST_INSTRUCTIONS.md +++ b/examples/llm_chat_app/TEST_INSTRUCTIONS.md @@ -86,13 +86,13 @@ If any test fails, you'll see an error message indicating the permission issue. ## Test Roles and Credentials -| Role | Password | Access Level | -| -------------------------- | --------------------------- | ------------------------- | -| `role_service_migration` | `demo-password-migration` | DDL + DML on all schemas | -| `role_service_fastapi_rw` | `demo-password-fastapi-rw` | DML on app schema only | -| `role_service_fastapi_ro` | `demo-password-fastapi-ro` | SELECT on app schema only | -| `role_service_pipeline_rw` | `demo-password-pipeline-rw` | DML on all schemas | -| `role_service_pipeline_ro` | `demo-password-pipeline-ro` | SELECT on all schemas | +| Role | Password | Access Level | +| --------------------- | --------------------------- | ------------------------- | +| `service_migrator` | `demo-password-migration` | DDL + DML on all schemas | +| `service_fastapi_rw` | `demo-password-fastapi-rw` | DML on app schema only | +| `service_fastapi_ro` | `demo-password-fastapi-ro` | SELECT on app schema only | +| `service_pipeline_rw` | `demo-password-pipeline-rw` | DML on all schemas | +| `service_pipeline_ro` | `demo-password-pipeline-ro` | SELECT on all schemas | ## Manual Test Commands @@ -103,7 +103,7 @@ export PGHOST=localhost export PGPORT=5432 export PGDATABASE=llm_service -PGUSER=role_service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "SELECT * FROM app.test_users;" +PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "SELECT * FROM app.test_users;" ``` ## Cleanup diff --git a/examples/llm_chat_app/run_tests.sh b/examples/llm_chat_app/run_tests.sh index 3ec8c61..30f64f7 100644 --- a/examples/llm_chat_app/run_tests.sh +++ b/examples/llm_chat_app/run_tests.sh @@ -31,7 +31,7 @@ export PGDATABASE=llm_service echo "--- Prerequisites: Creating Test Objects ---" echo "" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration psql <<'EOF' +PGUSER=service_migrator PGPASSWORD=demo-password-migration psql <<'EOF' -- Create test table in app schema CREATE TABLE IF NOT EXISTS app.test_users ( id SERIAL PRIMARY KEY, @@ -75,7 +75,7 @@ echo "" # Test 2: Migration Role DDL Access echo "--- Test 2: Migration Role DDL Access ---" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " CREATE TABLE app.migration_test (id int); ALTER TABLE app.migration_test ADD COLUMN name text; DROP TABLE app.migration_test; @@ -85,7 +85,7 @@ echo "" # Test 3: FastAPI RW Role echo "--- Test 3: FastAPI RW Role - DML on app schema ---" -PGUSER=role_service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " SELECT * FROM app.test_users; INSERT INTO app.test_users (name) VALUES ('fastapi_test'); DELETE FROM app.test_users WHERE name = 'fastapi_test'; @@ -95,7 +95,7 @@ echo "" # Test 4: FastAPI RO Role echo "--- Test 4: FastAPI RO Role - SELECT only ---" -PGUSER=role_service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " SELECT * FROM app.test_users; SELECT 'TEST 4 PASSED: FastAPI RO has SELECT' AS result; " @@ -103,7 +103,7 @@ echo "" # Test 5: Pipeline RW Role echo "--- Test 5: Pipeline RW Role - All schemas access ---" -PGUSER=role_service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; @@ -113,7 +113,7 @@ echo "" # Test 6: Pipeline RO Role echo "--- Test 6: Pipeline RO Role - Read access to all schemas ---" -PGUSER=role_service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; @@ -123,7 +123,7 @@ echo "" # Test 7: Connection Limits echo "--- Test 7: Connection Limits ---" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " SELECT rolname, rolconnlimit FROM pg_roles WHERE rolname LIKE 'role_service_%' @@ -133,7 +133,7 @@ echo "" # Test 8: Role Inheritance echo "--- Test 8: Role Inheritance ---" -PGUSER=role_service_migration PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " SELECT r.rolname AS role, ARRAY_AGG(m.rolname) AS member_of From dc8d9d55a92fd444c92fc37c485fe366a6620945 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 17:31:39 -0700 Subject: [PATCH 08/14] fix: resolve password and role issues --- examples/llm_chat_app/2_create_test_objects.sh | 6 +++++- examples/llm_chat_app/3_run_verification_tests.sh | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/llm_chat_app/2_create_test_objects.sh b/examples/llm_chat_app/2_create_test_objects.sh index de4880f..5d20203 100755 --- a/examples/llm_chat_app/2_create_test_objects.sh +++ b/examples/llm_chat_app/2_create_test_objects.sh @@ -12,7 +12,11 @@ echo "Creating Test Objects" echo "==============================================" echo "" -PGUSER=service_migrator PGPASSWORD=demo-password-migration psql <<'EOF' +PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql <<'EOF' +-- Switch to group role so objects are owned by role_service_migration +-- This ensures default privileges apply correctly +SET ROLE role_service_migration; + -- Create test table in app schema CREATE TABLE IF NOT EXISTS app.test_users ( id SERIAL PRIMARY KEY, diff --git a/examples/llm_chat_app/3_run_verification_tests.sh b/examples/llm_chat_app/3_run_verification_tests.sh index e06e972..565b9f2 100755 --- a/examples/llm_chat_app/3_run_verification_tests.sh +++ b/examples/llm_chat_app/3_run_verification_tests.sh @@ -14,7 +14,7 @@ echo "" # Test 2: Migration Role DDL Access echo "--- Test 2: Migration Role DDL Access ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migration psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -c " CREATE TABLE app.migration_test (id int); ALTER TABLE app.migration_test ADD COLUMN name text; DROP TABLE app.migration_test; @@ -62,7 +62,7 @@ echo "" # Test 7: Connection Limits echo "--- Test 7: Connection Limits ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migration psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -c " SELECT rolname, rolconnlimit FROM pg_roles WHERE rolname LIKE 'role_service_%' @@ -72,7 +72,7 @@ echo "" # Test 8: Role Inheritance echo "--- Test 8: Role Inheritance ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migration psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -c " SELECT r.rolname AS role, ARRAY_AGG(m.rolname) AS member_of From be28166fc60c105b648d31a05990f6f406b6ef86 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Thu, 29 Jan 2026 17:39:29 -0700 Subject: [PATCH 09/14] fix(tests): ensure truncate off RW, on migrator --- examples/llm_chat_app/3_run_verification_tests.sh | 15 ++++++++++++++- examples/llm_chat_app/main.tf | 4 ++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/examples/llm_chat_app/3_run_verification_tests.sh b/examples/llm_chat_app/3_run_verification_tests.sh index 565b9f2..52dc327 100755 --- a/examples/llm_chat_app/3_run_verification_tests.sh +++ b/examples/llm_chat_app/3_run_verification_tests.sh @@ -15,10 +15,13 @@ echo "" # Test 2: Migration Role DDL Access echo "--- Test 2: Migration Role DDL Access ---" PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -c " +SET ROLE role_service_migration; CREATE TABLE app.migration_test (id int); ALTER TABLE app.migration_test ADD COLUMN name text; +INSERT INTO app.migration_test (id) VALUES (1); +TRUNCATE app.migration_test; DROP TABLE app.migration_test; -SELECT 'TEST 2 PASSED: Migration role has DDL access' AS result; +SELECT 'TEST 2 PASSED: Migration role has DDL access (including TRUNCATE)' AS result; " echo "" @@ -32,6 +35,16 @@ SELECT 'TEST 3 PASSED: FastAPI RW has app DML' AS result; " echo "" +# Test 3b: FastAPI RW Role - Verify TRUNCATE is denied +echo "--- Test 3b: FastAPI RW Role - Verify TRUNCATE denied ---" +if PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "TRUNCATE app.test_users;" 2>&1 | grep -q "permission denied"; then + echo "TEST 3b PASSED: FastAPI RW correctly denied TRUNCATE" +else + echo "TEST 3b FAILED: FastAPI RW should not have TRUNCATE permission" + exit 1 +fi +echo "" + # Test 4: FastAPI RO Role echo "--- Test 4: FastAPI RO Role - SELECT only ---" PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c " diff --git a/examples/llm_chat_app/main.tf b/examples/llm_chat_app/main.tf index 5743d71..3c2799e 100644 --- a/examples/llm_chat_app/main.tf +++ b/examples/llm_chat_app/main.tf @@ -52,7 +52,7 @@ resource "postgresql_grant" "rw_app_tables" { role = "role_service_rw" schema = "app" object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] depends_on = [module.postgres_automation, postgresql_schema.app] } @@ -73,7 +73,7 @@ resource "postgresql_default_privileges" "rw_app_tables" { schema = "app" owner = "role_service_migration" object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] + privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] depends_on = [module.postgres_automation, postgresql_schema.app] } From 2de512c65f8050f5d6682a591044cc552c98fa04 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Tue, 31 Mar 2026 17:32:39 -0600 Subject: [PATCH 10/14] feat: simplify the example setup --- README.md | 44 +- .../llm_chat_app/3_run_verification_tests.sh | 180 ++++-- examples/llm_chat_app/4_cleanup.sh | 33 +- examples/llm_chat_app/README.md | 49 ++ examples/llm_chat_app/fixtures.auto.tfvars | 119 +++- examples/llm_chat_app/main.tf | 543 +----------------- examples/llm_chat_app/variables.tf | 16 +- main.tf | 11 +- tests/main.tftest.hcl | 13 +- variables.tf | 16 +- 10 files changed, 382 insertions(+), 642 deletions(-) diff --git a/README.md b/README.md index 8afa6eb..fe9197b 100644 --- a/README.md +++ b/README.md @@ -169,28 +169,28 @@ module "postgres_automation" { ## Inputs -| Name | Description | Type | Default | Required | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | -| [additional_tag_map](#input_additional_tag_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | -| [attributes](#input_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | -| [context](#input_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional_tag_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | -| [databases](#input_databases) | The logical database to create and configure |
list(object({
name = string
connection_limit = optional(number)
}))
| `[]` | no | -| [delimiter](#input_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | -| [descriptor_formats](#input_descriptor_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | -| [enabled](#input_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | -| [environment](#input_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | -| [id_length_limit](#input_id_length_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | -| [label_key_case](#input_label_key_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | -| [label_order](#input_label_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | -| [label_value_case](#input_label_value_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | -| [labels_as_tags](#input_labels_as_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | -| [name](#input_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | -| [namespace](#input_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `string` | `null` | no | -| [regex_replace_chars](#input_regex_replace_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | -| [roles](#input_roles) | List of static postgres roles to create and related permissions. These are for applications that use static credentials and don't use IAM DB Auth. See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role |
list(object({
role = object({
# See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role
name = string
superuser = optional(bool)
create_database = optional(bool)
create_role = optional(bool)
inherit = optional(bool)
login = optional(bool)
replication = optional(bool)
bypass_row_level_security = optional(bool)
connection_limit = optional(number)
encrypted_password = optional(bool)
password = optional(string)
roles = optional(list(string))
search_path = optional(list(string))
valid_until = optional(string)
skip_drop_role = optional(bool)
skip_reassign_owned = optional(bool)
statement_timeout = optional(number)
assume_role = optional(string)
})
default_privileges = optional(list(object({
role = string
database = string
schema = string
owner = string
object_type = string
privileges = list(string)
})))
database_grants = optional(object({
role = string
database = string
object_type = string
privileges = list(string)
}))
schema_grants = optional(object({
role = string
database = string
schema = string
object_type = string
privileges = list(string)
}))
sequence_grants = optional(object({
role = string
database = string
schema = string
object_type = string
objects = list(string)
privileges = list(string)
}))
table_grants = optional(object({
role = string
database = string
schema = string
object_type = string
objects = list(string)
privileges = list(string)
}))
}))
| `[]` | no | -| [stage](#input_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | -| [tags](#input_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [tenant](#input_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | +| Name | Description | Type | Default | Required | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | +| [additional_tag_map](#input_additional_tag_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [attributes](#input_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [context](#input_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional_tag_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | +| [databases](#input_databases) | The logical database to create and configure |
list(object({
name = string
connection_limit = optional(number)
}))
| `[]` | no | +| [delimiter](#input_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [descriptor_formats](#input_descriptor_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | +| [enabled](#input_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [environment](#input_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | +| [id_length_limit](#input_id_length_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [label_key_case](#input_label_key_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label_order](#input_label_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | +| [label_value_case](#input_label_value_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | +| [labels_as_tags](#input_labels_as_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | +| [name](#input_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | +| [namespace](#input_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [regex_replace_chars](#input_regex_replace_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [roles](#input_roles) | List of static postgres roles to create and related permissions. These are for applications that use static credentials and don't use IAM DB Auth. See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role |
list(object({
role = object({
# See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role
name = string
superuser = optional(bool)
create_database = optional(bool)
create_role = optional(bool)
inherit = optional(bool)
login = optional(bool)
replication = optional(bool)
bypass_row_level_security = optional(bool)
connection_limit = optional(number)
encrypted_password = optional(bool)
password = optional(string)
roles = optional(list(string))
search_path = optional(list(string))
valid_until = optional(string)
skip_drop_role = optional(bool)
skip_reassign_owned = optional(bool)
statement_timeout = optional(number)
assume_role = optional(string)
})
default_privileges = optional(list(object({
role = string
database = string
schema = string
owner = string
object_type = string
privileges = list(string)
})))
database_grants = optional(object({
role = string
database = string
object_type = string
privileges = list(string)
}))
schema_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
privileges = list(string)
})))
sequence_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
objects = optional(list(string))
privileges = list(string)
})))
table_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
objects = optional(list(string))
privileges = list(string)
})))
}))
| `[]` | no | +| [stage](#input_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | +| [tags](#input_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [tenant](#input_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | ## Outputs diff --git a/examples/llm_chat_app/3_run_verification_tests.sh b/examples/llm_chat_app/3_run_verification_tests.sh index 52dc327..0970c8d 100755 --- a/examples/llm_chat_app/3_run_verification_tests.sh +++ b/examples/llm_chat_app/3_run_verification_tests.sh @@ -12,90 +12,200 @@ echo "Running Verification Tests" echo "==============================================" echo "" -# Test 2: Migration Role DDL Access -echo "--- Test 2: Migration Role DDL Access ---" +# Test 1: Migration Role DDL Access +# Runs as the login role directly (no SET ROLE) to verify that inherit=true +# causes service_migrator to automatically inherit DDL privileges from role_service_migration. +echo "--- Test 1: Migration Role DDL Access (via inheritance, no SET ROLE) ---" PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -c " -SET ROLE role_service_migration; CREATE TABLE app.migration_test (id int); ALTER TABLE app.migration_test ADD COLUMN name text; INSERT INTO app.migration_test (id) VALUES (1); TRUNCATE app.migration_test; DROP TABLE app.migration_test; -SELECT 'TEST 2 PASSED: Migration role has DDL access (including TRUNCATE)' AS result; +SELECT 'TEST 1 PASSED: service_migrator inherits DDL access (including TRUNCATE) from role_service_migration' AS result; " echo "" -# Test 3: FastAPI RW Role -echo "--- Test 3: FastAPI RW Role - DML on app schema ---" +# Test 2: FastAPI RW Role - DML on app schema +echo "--- Test 2: FastAPI RW Role - DML on app schema ---" PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c " SELECT * FROM app.test_users; INSERT INTO app.test_users (name) VALUES ('fastapi_test'); -DELETE FROM app.test_users WHERE name = 'fastapi_test'; -SELECT 'TEST 3 PASSED: FastAPI RW has app DML' AS result; +UPDATE app.test_users SET name = 'fastapi_test_updated' WHERE name = 'fastapi_test'; +DELETE FROM app.test_users WHERE name = 'fastapi_test_updated'; +SELECT 'TEST 2 PASSED: FastAPI RW has app DML (SELECT/INSERT/UPDATE/DELETE)' AS result; " echo "" -# Test 3b: FastAPI RW Role - Verify TRUNCATE is denied -echo "--- Test 3b: FastAPI RW Role - Verify TRUNCATE denied ---" +# Test 2b: FastAPI RW Role - Verify TRUNCATE is denied +echo "--- Test 2b: FastAPI RW Role - Verify TRUNCATE denied ---" if PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "TRUNCATE app.test_users;" 2>&1 | grep -q "permission denied"; then - echo "TEST 3b PASSED: FastAPI RW correctly denied TRUNCATE" + echo "TEST 2b PASSED: FastAPI RW correctly denied TRUNCATE" else - echo "TEST 3b FAILED: FastAPI RW should not have TRUNCATE permission" + echo "TEST 2b FAILED: FastAPI RW should not have TRUNCATE permission" exit 1 fi echo "" -# Test 4: FastAPI RO Role -echo "--- Test 4: FastAPI RO Role - SELECT only ---" +# Test 3: FastAPI RO Role - SELECT only +echo "--- Test 3: FastAPI RO Role - SELECT only ---" PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c " SELECT * FROM app.test_users; -SELECT 'TEST 4 PASSED: FastAPI RO has SELECT' AS result; +SELECT 'TEST 3 PASSED: FastAPI RO has SELECT' AS result; " echo "" -# Test 5: Pipeline RW Role -echo "--- Test 5: Pipeline RW Role - All schemas access ---" +# Test 3b: FastAPI RO Role - Verify INSERT denied +echo "--- Test 3b: FastAPI RO Role - Verify INSERT denied ---" +if PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c "INSERT INTO app.test_users (name) VALUES ('ro_should_fail');" 2>&1 | grep -q "permission denied"; then + echo "TEST 3b PASSED: FastAPI RO correctly denied INSERT" +else + echo "TEST 3b FAILED: FastAPI RO should not have INSERT permission" + exit 1 +fi +echo "" + +# Test 3c: FastAPI RO Role - Verify UPDATE denied +echo "--- Test 3c: FastAPI RO Role - Verify UPDATE denied ---" +if PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c "UPDATE app.test_users SET name = 'ro_should_fail' WHERE id = 1;" 2>&1 | grep -q "permission denied"; then + echo "TEST 3c PASSED: FastAPI RO correctly denied UPDATE" +else + echo "TEST 3c FAILED: FastAPI RO should not have UPDATE permission" + exit 1 +fi +echo "" + +# Test 3d: FastAPI RO Role - Verify DELETE denied +echo "--- Test 3d: FastAPI RO Role - Verify DELETE denied ---" +if PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c "DELETE FROM app.test_users WHERE id = 1;" 2>&1 | grep -q "permission denied"; then + echo "TEST 3d PASSED: FastAPI RO correctly denied DELETE" +else + echo "TEST 3d FAILED: FastAPI RO should not have DELETE permission" + exit 1 +fi +echo "" + +# Test 4: Pipeline RW Role - Full DML + TRUNCATE on all schemas +echo "--- Test 4: Pipeline RW Role - Full DML + TRUNCATE on all schemas ---" PGUSER=service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; -SELECT 'TEST 5 PASSED: Pipeline RW has all schemas access' AS result; +INSERT INTO ref_data_pipeline_abc.test_ref (value) VALUES ('pipeline_test'); +UPDATE ref_data_pipeline_abc.test_ref SET value = 'pipeline_test_updated' WHERE value = 'pipeline_test'; +DELETE FROM ref_data_pipeline_abc.test_ref WHERE value = 'pipeline_test_updated'; +INSERT INTO ref_data_pipeline_xyz.test_ref (value) VALUES ('pipeline_test'); +TRUNCATE ref_data_pipeline_xyz.test_ref; +SELECT 'TEST 4 PASSED: Pipeline RW has full DML + TRUNCATE on ref_data schemas' AS result; " echo "" -# Test 6: Pipeline RO Role -echo "--- Test 6: Pipeline RO Role - Read access to all schemas ---" +# Test 4b: Pipeline RW Role - Verify TRUNCATE denied on app schema +echo "--- Test 4b: Pipeline RW Role - Verify TRUNCATE denied on app schema ---" +if PGUSER=service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw psql -c "TRUNCATE app.test_users;" 2>&1 | grep -q "permission denied"; then + echo "TEST 4b PASSED: Pipeline RW correctly denied TRUNCATE on app schema" +else + echo "TEST 4b FAILED: Pipeline RW should not have TRUNCATE on app schema" + exit 1 +fi +echo "" + +# Test 5: Pipeline RO Role - SELECT only on all schemas +echo "--- Test 5: Pipeline RO Role - SELECT only on all schemas ---" PGUSER=service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; -SELECT 'TEST 6 PASSED: Pipeline RO has SELECT on all schemas' AS result; +SELECT 'TEST 5 PASSED: Pipeline RO has SELECT on all schemas' AS result; " echo "" +# Test 5b: Pipeline RO Role - Verify INSERT denied on ref_data schema +echo "--- Test 5b: Pipeline RO Role - Verify INSERT denied on ref_data schema ---" +if PGUSER=service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro psql -c "INSERT INTO ref_data_pipeline_abc.test_ref (value) VALUES ('ro_should_fail');" 2>&1 | grep -q "permission denied"; then + echo "TEST 5b PASSED: Pipeline RO correctly denied INSERT on ref_data schema" +else + echo "TEST 5b FAILED: Pipeline RO should not have INSERT on ref_data schema" + exit 1 +fi +echo "" + +# Test 6: Cross-schema denial - fastapi roles cannot access ref_data schemas +echo "--- Test 6: Cross-schema denial - FastAPI roles cannot access ref_data schemas ---" +if PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "SELECT * FROM ref_data_pipeline_abc.test_ref;" 2>&1 | grep -q "permission denied"; then + echo "TEST 6 PASSED: FastAPI RW correctly denied access to ref_data schema" +else + echo "TEST 6 FAILED: FastAPI RW should not have access to ref_data schema" + exit 1 +fi +echo "" + +if PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro psql -c "SELECT * FROM ref_data_pipeline_abc.test_ref;" 2>&1 | grep -q "permission denied"; then + echo "TEST 6b PASSED: FastAPI RO correctly denied access to ref_data schema" +else + echo "TEST 6b FAILED: FastAPI RO should not have access to ref_data schema" + exit 1 +fi +echo "" + # Test 7: Connection Limits echo "--- Test 7: Connection Limits ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -c " -SELECT rolname, rolconnlimit + +check_connlimit() { + local role="$1" + local expected="$2" + local actual + + actual=$(PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -tA -c " +SELECT rolconnlimit FROM pg_roles -WHERE rolname LIKE 'role_service_%' -ORDER BY rolname; -" +WHERE rolname = '${role}'; +" | tr -d '[:space:]') + + if [ "$actual" = "$expected" ]; then + echo "TEST 7 PASSED: ${role} has connection limit ${expected}" + else + echo "TEST 7 FAILED: ${role} should have connection limit ${expected}, got ${actual}" + exit 1 + fi +} + +check_connlimit "service_migrator" "5" +check_connlimit "service_fastapi_rw" "30" +check_connlimit "service_fastapi_ro" "30" +check_connlimit "service_pipeline_rw" "10" +check_connlimit "service_pipeline_ro" "10" echo "" -# Test 8: Role Inheritance +# Test 8: Role Inheritance - all login and group roles echo "--- Test 8: Role Inheritance ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -c " -SELECT - r.rolname AS role, - ARRAY_AGG(m.rolname) AS member_of + +check_membership() { + local role="$1" + local expected="$2" + local actual + + actual=$(PGUSER=service_migrator PGPASSWORD=demo-password-migrator psql -tA -c " +SELECT COALESCE(m.rolname, '') FROM pg_roles r LEFT JOIN pg_auth_members am ON r.oid = am.member LEFT JOIN pg_roles m ON am.roleid = m.oid -WHERE r.rolname LIKE 'role_service_%' -GROUP BY r.rolname -ORDER BY r.rolname; -" +WHERE r.rolname = '${role}'; +" | tr -d '[:space:]') + + if [ "$actual" = "$expected" ]; then + echo "TEST 8 PASSED: ${role} inherits from ${expected}" + else + echo "TEST 8 FAILED: ${role} should inherit from ${expected}, got ${actual}" + exit 1 + fi +} + +check_membership "service_migrator" "role_service_migration" +check_membership "service_fastapi_rw" "role_service_rw" +check_membership "service_fastapi_ro" "role_service_ro" +check_membership "service_pipeline_rw" "role_service_rw" +check_membership "service_pipeline_ro" "role_service_ro" echo "" echo "==============================================" diff --git a/examples/llm_chat_app/4_cleanup.sh b/examples/llm_chat_app/4_cleanup.sh index 5d77302..c601dc9 100755 --- a/examples/llm_chat_app/4_cleanup.sh +++ b/examples/llm_chat_app/4_cleanup.sh @@ -24,41 +24,30 @@ WHERE datname = 'llm_service' AND pid <> pg_backend_pid(); " 2>/dev/null || true echo "" -echo "Step 2: Dropping database llm_service..." -psql -c "DROP DATABASE IF EXISTS llm_service;" +echo "Step 2: Destroying Terraform-managed resources..." +cd "$(dirname "${BASH_SOURCE[0]}")" +tofu destroy -auto-approve echo "" -echo "Step 3: Dropping login roles..." -# Drop login roles first (they depend on group roles) -psql <<'EOF' -DROP ROLE IF EXISTS role_service_migrator; -DROP ROLE IF EXISTS role_service_fastapi_rw; -DROP ROLE IF EXISTS role_service_fastapi_ro; -DROP ROLE IF EXISTS role_service_pipeline_rw; -DROP ROLE IF EXISTS role_service_pipeline_ro; -EOF - -echo "" -echo "Step 4: Dropping group roles..." -# Drop group roles (no dependencies) +echo "Step 3: Dropping any remaining roles not managed by Terraform (safety net)..." psql <<'EOF' +DROP ROLE IF EXISTS service_migrator; +DROP ROLE IF EXISTS service_fastapi_rw; +DROP ROLE IF EXISTS service_fastapi_ro; +DROP ROLE IF EXISTS service_pipeline_rw; +DROP ROLE IF EXISTS service_pipeline_ro; DROP ROLE IF EXISTS role_service_migration; DROP ROLE IF EXISTS role_service_rw; DROP ROLE IF EXISTS role_service_ro; -EOF - -echo "" -echo "Step 5: Dropping cluster-wide roles..." -psql <<'EOF' DROP ROLE IF EXISTS role_pg_cluster_admin; DROP ROLE IF EXISTS role_pg_monitoring; EOF echo "" -echo "Step 6: Verifying cleanup..." +echo "Step 4: Verifying cleanup..." psql -c " SELECT rolname FROM pg_roles -WHERE rolname LIKE 'role_service_%' OR rolname LIKE 'role_pg_%' +WHERE rolname LIKE 'role_service_%' OR rolname LIKE 'role_pg_%' OR rolname LIKE 'service_%' ORDER BY rolname; " diff --git a/examples/llm_chat_app/README.md b/examples/llm_chat_app/README.md index 812e7de..e9fcccf 100644 --- a/examples/llm_chat_app/README.md +++ b/examples/llm_chat_app/README.md @@ -102,6 +102,55 @@ Group roles (no login) use the `role_` prefix. Login roles do not have the prefi The `ref_data_*` schemas are managed by separate data pipelines that populate the document corpus used by the Chat Application's RAG (Retrieval-Augmented Generation) system. +## Grant Configuration + +This example demonstrates two approaches for configuring database grants: + +### 1. Inline Grants (in `roles` variable) + +Use for grants that don't depend on schemas created by Terraform: + +```hcl +roles = [ + { + role = { + name = "role_service_migration" + login = false + } + database_grants = { + role = "role_service_migration" + database = "llm_service" + object_type = "database" + privileges = ["CREATE", "CONNECT", "TEMPORARY"] + } + } +] +``` + +### 2. Separate Grant Variables (in `fixtures.auto.tfvars`) + +Use for grants on schemas created by Terraform. This data-driven approach replaces hundreds of lines of hardcoded resources: + +```hcl +schema_grants = [ + { role = "role_service_rw", database = "llm_service", schema = "app", privileges = ["USAGE"] }, + { role = "role_service_ro", database = "llm_service", schema = "app", privileges = ["USAGE"] }, +] + +table_grants = [ + { role = "role_service_rw", database = "llm_service", schema = "app", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, + { role = "role_service_ro", database = "llm_service", schema = "app", privileges = ["SELECT"] }, +] + +default_privileges = [ + { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, +] +``` + +**Supported grant types:** `schema_grants`, `table_grants`, `sequence_grants`, `default_privileges` + +The `objects` field in table/sequence grants is optional - omit it to grant on all objects in the schema. + ## Schema Access Matrix | | app | ref_data_abc | ref_data_xyz | diff --git a/examples/llm_chat_app/fixtures.auto.tfvars b/examples/llm_chat_app/fixtures.auto.tfvars index 3c27b16..307bbfa 100644 --- a/examples/llm_chat_app/fixtures.auto.tfvars +++ b/examples/llm_chat_app/fixtures.auto.tfvars @@ -22,11 +22,11 @@ databases = [ # Role configuration roles = [ + # ======================================== # Cluster-wide roles # ======================================== - # Cluster admin role - manages users/roles across all databases { role = { name = "role_pg_cluster_admin" @@ -38,7 +38,6 @@ roles = [ } }, - # Monitoring role - read-only access to system statistics { role = { name = "role_pg_monitoring" @@ -50,17 +49,14 @@ roles = [ }, # ======================================== - # Service-scoped roles (llm_service) + # Migration group role - owns all schemas and DDL # ======================================== - # Migration group role - owns database, schemas, and all objects (no login) { role = { - name = "role_service_migration" - login = false # group role, no login - inherit = true - create_role = false - create_database = false + name = "role_service_migration" + login = false + inherit = true } database_grants = { role = "role_service_migration" @@ -68,6 +64,21 @@ roles = [ object_type = "database" privileges = ["CREATE", "CONNECT", "TEMPORARY"] } + schema_grants = [ + { role = "role_service_migration", database = "llm_service", schema = "app", object_type = "schema", privileges = ["USAGE", "CREATE"] }, + { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE", "CREATE"] }, + { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE", "CREATE"] }, + ] + table_grants = [ + { role = "role_service_migration", database = "llm_service", schema = "app", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, + { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, + { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, + ] + sequence_grants = [ + { role = "role_service_migration", database = "llm_service", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + ] }, # Migration login role - inherits from migration group @@ -83,11 +94,9 @@ roles = [ }, # ======================================== - # Group roles (no login) + # RW group role - read/write on app schema # ======================================== - # RW group role - read/write permissions on app schema - # Note: Schema-specific grants are in main.tf (depends on schema creation) { role = { name = "role_service_rw" @@ -100,10 +109,26 @@ roles = [ object_type = "database" privileges = ["CONNECT"] } + schema_grants = [ + { role = "role_service_rw", database = "llm_service", schema = "app", object_type = "schema", privileges = ["USAGE"] }, + ] + table_grants = [ + { role = "role_service_rw", database = "llm_service", schema = "app", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, + ] + sequence_grants = [ + { role = "role_service_rw", database = "llm_service", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + ] + default_privileges = [ + { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, + { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + ] }, - # RO group role - read-only permissions on app schema - # Note: Schema-specific grants are in main.tf (depends on schema creation) + # ======================================== + # RO group role - read-only on app schema + # ======================================== + { role = { name = "role_service_ro" @@ -116,13 +141,26 @@ roles = [ object_type = "database" privileges = ["CONNECT"] } + schema_grants = [ + { role = "role_service_ro", database = "llm_service", schema = "app", object_type = "schema", privileges = ["USAGE"] }, + ] + table_grants = [ + { role = "role_service_ro", database = "llm_service", schema = "app", object_type = "table", privileges = ["SELECT"] }, + ] + sequence_grants = [ + { role = "role_service_ro", database = "llm_service", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + ] + default_privileges = [ + { role = "role_service_ro", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, + { role = "role_service_ro", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "role_service_ro", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + ] }, # ======================================== - # Login roles (Application Processes) + # Login roles - app schema (inherit from rw/ro) # ======================================== - # FastAPI backend - read/write { role = { name = "service_fastapi_rw" @@ -134,7 +172,6 @@ roles = [ } }, - # FastAPI backend - read-only { role = { name = "service_fastapi_ro" @@ -146,7 +183,10 @@ roles = [ } }, - # Data pipeline - read/write (inherits app access, ref_data grants in main.tf) + # ======================================== + # Pipeline login roles - ref_data schemas + inherited app access + # ======================================== + { role = { name = "service_pipeline_rw" @@ -156,9 +196,28 @@ roles = [ connection_limit = 10 password = "demo-password-pipeline-rw" } + schema_grants = [ + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE"] }, + ] + table_grants = [ + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + ] + sequence_grants = [ + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + ] + default_privileges = [ + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + ] }, - # Data pipeline - read-only (inherits app access, ref_data grants in main.tf) { role = { name = "service_pipeline_ro" @@ -168,5 +227,25 @@ roles = [ connection_limit = 10 password = "demo-password-pipeline-ro" } - } + schema_grants = [ + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE"] }, + ] + table_grants = [ + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT"] }, + ] + sequence_grants = [ + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + ] + default_privileges = [ + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + ] + }, ] diff --git a/examples/llm_chat_app/main.tf b/examples/llm_chat_app/main.tf index 3c2799e..8f86af2 100644 --- a/examples/llm_chat_app/main.tf +++ b/examples/llm_chat_app/main.tf @@ -1,178 +1,64 @@ # llm_chat_app/main.tf -module "postgres_automation" { - source = "../../" +# ======================================== +# Database (managed here so schemas can depend on it before the module runs) +# ======================================== - databases = var.databases - roles = var.roles +resource "postgresql_database" "databases" { + for_each = { for db in var.databases : db.name => db } + name = each.value.name + connection_limit = each.value.connection_limit } -# Create schemas with migration role as owner +# ======================================== +# Schemas (created after database, before module) +# ======================================== + resource "postgresql_schema" "app" { name = "app" database = "llm_service" - owner = "role_service_migration" - depends_on = [module.postgres_automation] + depends_on = [postgresql_database.databases] } resource "postgresql_schema" "ref_data_pipeline_abc" { name = "ref_data_pipeline_abc" database = "llm_service" - owner = "role_service_migration" - depends_on = [module.postgres_automation] + depends_on = [postgresql_database.databases] } resource "postgresql_schema" "ref_data_pipeline_xyz" { name = "ref_data_pipeline_xyz" database = "llm_service" - owner = "role_service_migration" - - depends_on = [module.postgres_automation] -} - -# ======================================== -# RW Group Role - App Schema Grants -# ======================================== -# These grants must be in main.tf because they depend on schema creation - -resource "postgresql_grant" "rw_app_schema" { - database = "llm_service" - role = "role_service_rw" - schema = "app" - object_type = "schema" - privileges = ["USAGE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_grant" "rw_app_tables" { - database = "llm_service" - role = "role_service_rw" - schema = "app" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_grant" "rw_app_sequences" { - database = "llm_service" - role = "role_service_rw" - schema = "app" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_default_privileges" "rw_app_tables" { - database = "llm_service" - role = "role_service_rw" - schema = "app" - owner = "role_service_migration" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_default_privileges" "rw_app_sequences" { - database = "llm_service" - role = "role_service_rw" - schema = "app" - owner = "role_service_migration" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_default_privileges" "rw_app_functions" { - database = "llm_service" - role = "role_service_rw" - schema = "app" - owner = "role_service_migration" - object_type = "function" - privileges = ["EXECUTE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] + depends_on = [postgresql_database.databases] } # ======================================== -# RO Group Role - App Schema Grants +# Main module - creates roles and all grants +# databases = [] since the DB is managed above +# depends_on ensures schemas exist before inline schema/table/sequence grants run # ======================================== -resource "postgresql_grant" "ro_app_schema" { - database = "llm_service" - role = "role_service_ro" - schema = "app" - object_type = "schema" - privileges = ["USAGE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_grant" "ro_app_tables" { - database = "llm_service" - role = "role_service_ro" - schema = "app" - object_type = "table" - privileges = ["SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_grant" "ro_app_sequences" { - database = "llm_service" - role = "role_service_ro" - schema = "app" - object_type = "sequence" - privileges = ["USAGE", "SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_default_privileges" "ro_app_tables" { - database = "llm_service" - role = "role_service_ro" - schema = "app" - owner = "role_service_migration" - object_type = "table" - privileges = ["SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_default_privileges" "ro_app_sequences" { - database = "llm_service" - role = "role_service_ro" - schema = "app" - owner = "role_service_migration" - object_type = "sequence" - privileges = ["USAGE", "SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} +module "postgres_automation" { + source = "../../" -resource "postgresql_default_privileges" "ro_app_functions" { - database = "llm_service" - role = "role_service_ro" - schema = "app" - owner = "role_service_migration" - object_type = "function" - privileges = ["EXECUTE"] + databases = [] + roles = var.roles - depends_on = [module.postgres_automation, postgresql_schema.app] + depends_on = [ + postgresql_database.databases, + postgresql_schema.app, + postgresql_schema.ref_data_pipeline_abc, + postgresql_schema.ref_data_pipeline_xyz, + ] } # ======================================== -# Revoke PUBLIC Privileges +# Security: Revoke PUBLIC privileges # ======================================== -# Revoke PUBLIC privileges on database -# This prevents any authenticated cluster user from connecting resource "postgresql_grant" "revoke_public_connect" { database = "llm_service" role = "public" @@ -182,7 +68,6 @@ resource "postgresql_grant" "revoke_public_connect" { depends_on = [module.postgres_automation] } -# Revoke PUBLIC privileges on public schema resource "postgresql_grant" "revoke_public_schema" { database = "llm_service" role = "public" @@ -192,375 +77,3 @@ resource "postgresql_grant" "revoke_public_schema" { depends_on = [module.postgres_automation] } - -# ======================================== -# Migration Role - Additional Schema Grants -# ======================================== -# Note: Migration role owns all schemas (set via postgresql_schema.owner above) -# These grants provide the necessary privileges for DDL operations - -# App schema grants for migration role -resource "postgresql_grant" "migration_app_schema" { - database = "llm_service" - role = "role_service_migration" - schema = "app" - object_type = "schema" - privileges = ["USAGE", "CREATE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_grant" "migration_app_tables" { - database = "llm_service" - role = "role_service_migration" - schema = "app" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -resource "postgresql_grant" "migration_app_sequences" { - database = "llm_service" - role = "role_service_migration" - schema = "app" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.app] -} - -# ref_data_pipeline_abc schema grants for migration role -resource "postgresql_grant" "migration_abc_schema" { - database = "llm_service" - role = "role_service_migration" - schema = "ref_data_pipeline_abc" - object_type = "schema" - privileges = ["USAGE", "CREATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "migration_abc_tables" { - database = "llm_service" - role = "role_service_migration" - schema = "ref_data_pipeline_abc" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "migration_abc_sequences" { - database = "llm_service" - role = "role_service_migration" - schema = "ref_data_pipeline_abc" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -# ref_data_pipeline_xyz schema grants for migration role -resource "postgresql_grant" "migration_xyz_schema" { - database = "llm_service" - role = "role_service_migration" - schema = "ref_data_pipeline_xyz" - object_type = "schema" - privileges = ["USAGE", "CREATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_grant" "migration_xyz_tables" { - database = "llm_service" - role = "role_service_migration" - schema = "ref_data_pipeline_xyz" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_grant" "migration_xyz_sequences" { - database = "llm_service" - role = "role_service_migration" - schema = "ref_data_pipeline_xyz" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -# ======================================== -# Pipeline RW Login Role - ref_data Schema Grants -# ======================================== -# Note: service_pipeline_rw inherits app schema access from role_service_rw -# These grants provide additional access to ref_data schemas - -resource "postgresql_grant" "pipeline_rw_abc_schema" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_abc" - object_type = "schema" - privileges = ["USAGE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "pipeline_rw_abc_tables" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_abc" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "pipeline_rw_abc_sequences" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_abc" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "pipeline_rw_xyz_schema" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_xyz" - object_type = "schema" - privileges = ["USAGE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_grant" "pipeline_rw_xyz_tables" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_xyz" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_grant" "pipeline_rw_xyz_sequences" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_xyz" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -# ======================================== -# Pipeline RO Login Role - ref_data Schema Grants -# ======================================== -# Note: service_pipeline_ro inherits app schema access from role_service_ro -# These grants provide additional access to ref_data schemas - -resource "postgresql_grant" "pipeline_ro_abc_schema" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_abc" - object_type = "schema" - privileges = ["USAGE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "pipeline_ro_abc_tables" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_abc" - object_type = "table" - privileges = ["SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "pipeline_ro_abc_sequences" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_abc" - object_type = "sequence" - privileges = ["USAGE", "SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_grant" "pipeline_ro_xyz_schema" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_xyz" - object_type = "schema" - privileges = ["USAGE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_grant" "pipeline_ro_xyz_tables" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_xyz" - object_type = "table" - privileges = ["SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_grant" "pipeline_ro_xyz_sequences" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_xyz" - object_type = "sequence" - privileges = ["USAGE", "SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -# ======================================== -# Default Privileges for ref_data Schemas -# ======================================== -# These ensure new objects created by migration role automatically grant access to pipeline roles - -# ref_data_pipeline_abc - pipeline_rw -resource "postgresql_default_privileges" "pipeline_rw_abc_tables" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_abc" - owner = "role_service_migration" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_default_privileges" "pipeline_rw_abc_sequences" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_abc" - owner = "role_service_migration" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_default_privileges" "pipeline_rw_abc_functions" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_abc" - owner = "role_service_migration" - object_type = "function" - privileges = ["EXECUTE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -# ref_data_pipeline_abc - pipeline_ro -resource "postgresql_default_privileges" "pipeline_ro_abc_tables" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_abc" - owner = "role_service_migration" - object_type = "table" - privileges = ["SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_default_privileges" "pipeline_ro_abc_sequences" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_abc" - owner = "role_service_migration" - object_type = "sequence" - privileges = ["USAGE", "SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -resource "postgresql_default_privileges" "pipeline_ro_abc_functions" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_abc" - owner = "role_service_migration" - object_type = "function" - privileges = ["EXECUTE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_abc] -} - -# ref_data_pipeline_xyz - pipeline_rw -resource "postgresql_default_privileges" "pipeline_rw_xyz_tables" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_xyz" - owner = "role_service_migration" - object_type = "table" - privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_default_privileges" "pipeline_rw_xyz_sequences" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_xyz" - owner = "role_service_migration" - object_type = "sequence" - privileges = ["USAGE", "SELECT", "UPDATE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_default_privileges" "pipeline_rw_xyz_functions" { - database = "llm_service" - role = "service_pipeline_rw" - schema = "ref_data_pipeline_xyz" - owner = "role_service_migration" - object_type = "function" - privileges = ["EXECUTE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -# ref_data_pipeline_xyz - pipeline_ro -resource "postgresql_default_privileges" "pipeline_ro_xyz_tables" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_xyz" - owner = "role_service_migration" - object_type = "table" - privileges = ["SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_default_privileges" "pipeline_ro_xyz_sequences" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_xyz" - owner = "role_service_migration" - object_type = "sequence" - privileges = ["USAGE", "SELECT"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} - -resource "postgresql_default_privileges" "pipeline_ro_xyz_functions" { - database = "llm_service" - role = "service_pipeline_ro" - schema = "ref_data_pipeline_xyz" - owner = "role_service_migration" - object_type = "function" - privileges = ["EXECUTE"] - - depends_on = [module.postgres_automation, postgresql_schema.ref_data_pipeline_xyz] -} diff --git a/examples/llm_chat_app/variables.tf b/examples/llm_chat_app/variables.tf index 160fa35..d01d471 100644 --- a/examples/llm_chat_app/variables.tf +++ b/examples/llm_chat_app/variables.tf @@ -82,29 +82,29 @@ variable "roles" { object_type = string privileges = list(string) })) - schema_grants = optional(object({ + schema_grants = optional(list(object({ role = string database = string schema = string object_type = string privileges = list(string) - })) - table_grants = optional(object({ + }))) + table_grants = optional(list(object({ role = string database = string schema = string object_type = string - objects = list(string) + objects = optional(list(string)) privileges = list(string) - })) - sequence_grants = optional(object({ + }))) + sequence_grants = optional(list(object({ role = string database = string schema = string object_type = string - objects = list(string) + objects = optional(list(string)) privileges = list(string) - })) + }))) })) default = [] description = "List of static postgres roles to create and related permissions. These are for applications that use static credentials and don't use IAM DB Auth. See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role" diff --git a/main.tf b/main.tf index 91aeb3e..87f3c71 100644 --- a/main.tf +++ b/main.tf @@ -19,13 +19,13 @@ locals { _database_grants = [for role in local._roles_with_passwords : role.database_grants if try(role.database_grants, null) != null] database_grants_map = { for grant in local._database_grants : format("%s-%s", grant.role, grant.database) => grant } - _schema_grants = [for role in local._roles_with_passwords : role.schema_grants if try(role.schema_grants, null) != null] + _schema_grants = flatten([for role in local._roles_with_passwords : coalesce(role.schema_grants, [])]) schema_grants_map = { for grant in local._schema_grants : format("%s-%s-%s", grant.role, grant.schema, grant.database) => grant } - _sequence_grants = [for role in local._roles_with_passwords : role.sequence_grants if try(role.sequence_grants, null) != null] + _sequence_grants = flatten([for role in local._roles_with_passwords : coalesce(role.sequence_grants, [])]) sequence_grants_map = { for grant in local._sequence_grants : format("%s-%s-%s", grant.role, grant.schema, grant.database) => grant } - _table_grants = [for role in local._roles_with_passwords : role.table_grants if try(role.table_grants, null) != null] + _table_grants = flatten([for role in local._roles_with_passwords : coalesce(role.table_grants, [])]) table_grants_map = { for grant in local._table_grants : format("%s-%s-%s", grant.role, grant.schema, grant.database) => grant } databases_map = { for database in var.databases : database.name => database } @@ -175,9 +175,9 @@ resource "postgresql_grant" "table_access" { schema = each.value.schema object_type = each.value.object_type privileges = each.value.privileges - objects = each.value.objects + objects = try(each.value.objects, null) - depends_on = [postgresql_database.logical_dbs] + depends_on = [postgresql_database.logical_dbs, postgresql_role.base_role, postgresql_role.dependent_role] } resource "postgresql_grant" "sequence_access" { @@ -188,6 +188,7 @@ resource "postgresql_grant" "sequence_access" { schema = each.value.schema object_type = each.value.object_type privileges = each.value.privileges + objects = try(each.value.objects, null) depends_on = [postgresql_database.logical_dbs, postgresql_role.base_role, postgresql_role.dependent_role] } diff --git a/tests/main.tftest.hcl b/tests/main.tftest.hcl index 006ff40..f225a03 100644 --- a/tests/main.tftest.hcl +++ b/tests/main.tftest.hcl @@ -49,30 +49,29 @@ variables { object_type = "database" privileges = ["CONNECT"] } - schema_grants = { + schema_grants = [{ role = "app_user" database = "app2" schema = "public" object_type = "schema" - objects = ["public"] privileges = ["USAGE"] - } - sequence_grants = { + }] + sequence_grants = [{ role = "app_user" database = "app2" schema = "public" object_type = "sequence" objects = [] # all sequences privileges = ["USAGE", "SELECT"] - } - table_grants = { + }] + table_grants = [{ role = "app_user" database = "app2" schema = "public" object_type = "table" objects = [] # all tables privileges = ["SELECT"] - } + }] }, { role = { name = "app_user2" diff --git a/variables.tf b/variables.tf index 3820b6b..2f8ec46 100644 --- a/variables.tf +++ b/variables.tf @@ -44,29 +44,29 @@ variable "roles" { object_type = string privileges = list(string) })) - schema_grants = optional(object({ + schema_grants = optional(list(object({ role = string database = string schema = string object_type = string privileges = list(string) - })) - sequence_grants = optional(object({ + }))) + sequence_grants = optional(list(object({ role = string database = string schema = string object_type = string - objects = list(string) + objects = optional(list(string)) privileges = list(string) - })) - table_grants = optional(object({ + }))) + table_grants = optional(list(object({ role = string database = string schema = string object_type = string - objects = list(string) + objects = optional(list(string)) privileges = list(string) - })) + }))) })) default = [] description = "List of static postgres roles to create and related permissions. These are for applications that use static credentials and don't use IAM DB Auth. See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role" From 146779da915002af96e26edcc17b216745ce580e Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Tue, 31 Mar 2026 17:34:34 -0600 Subject: [PATCH 11/14] linting --- README.md | 112 +++++++++++++++++++++++++++--------------------------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index d138347..fe9197b 100644 --- a/README.md +++ b/README.md @@ -131,79 +131,81 @@ module "postgres_automation" { ``` + ## Requirements -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.0 | -| [postgresql](#requirement\_postgresql) | >= 1 | -| [random](#requirement\_random) | >= 3 | +| Name | Version | +| --------------------------------------------------------------------------- | ------- | +| [terraform](#requirement_terraform) | >= 1.0 | +| [postgresql](#requirement_postgresql) | >= 1 | +| [random](#requirement_random) | >= 3 | ## Providers -| Name | Version | -|------|---------| -| [postgresql](#provider\_postgresql) | >= 1 | -| [random](#provider\_random) | >= 3 | +| Name | Version | +| --------------------------------------------------------------------- | ------- | +| [postgresql](#provider_postgresql) | >= 1 | +| [random](#provider_random) | >= 3 | ## Modules -| Name | Source | Version | -|------|--------|---------| -| [this](#module\_this) | cloudposse/label/null | 0.25.0 | +| Name | Source | Version | +| ----------------------------------------------- | --------------------- | ------- | +| [this](#module_this) | cloudposse/label/null | 0.25.0 | ## Resources -| Name | Type | -|------|------| -| [postgresql_database.logical_dbs](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/database) | resource | +| Name | Type | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| [postgresql_database.logical_dbs](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/database) | resource | | [postgresql_default_privileges.privileges](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/default_privileges) | resource | -| [postgresql_grant.database_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | -| [postgresql_grant.schema_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | -| [postgresql_grant.sequence_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | -| [postgresql_grant.table_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | -| [postgresql_role.base_role](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | -| [postgresql_role.dependent_role](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | -| [random_password.user_password](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | +| [postgresql_grant.database_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | +| [postgresql_grant.schema_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | +| [postgresql_grant.sequence_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | +| [postgresql_grant.table_access](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/grant) | resource | +| [postgresql_role.base_role](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | +| [postgresql_role.dependent_role](https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/role) | resource | +| [random_password.user_password](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | ## Inputs -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | -| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | -| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | -| [databases](#input\_databases) | The logical database to create and configure |
list(object({
name = string
connection_limit = optional(number)
}))
| `[]` | no | -| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | -| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | -| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | -| [environment](#input\_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | -| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | -| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | -| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | -| [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | -| [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | -| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | -| [namespace](#input\_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `string` | `null` | no | -| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | -| [roles](#input\_roles) | List of static postgres roles to create and related permissions. These are for applications that use static credentials and don't use IAM DB Auth. See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role |
list(object({
role = object({
# See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role
name = string
superuser = optional(bool)
create_database = optional(bool)
create_role = optional(bool)
inherit = optional(bool)
login = optional(bool)
replication = optional(bool)
bypass_row_level_security = optional(bool)
connection_limit = optional(number)
encrypted_password = optional(bool)
password = optional(string)
roles = optional(list(string))
search_path = optional(list(string))
valid_until = optional(string)
skip_drop_role = optional(bool)
skip_reassign_owned = optional(bool)
statement_timeout = optional(number)
assume_role = optional(string)
})
default_privileges = optional(list(object({
role = string
database = string
schema = string
owner = string
object_type = string
privileges = list(string)
})))
database_grants = optional(object({
role = string
database = string
object_type = string
privileges = list(string)
}))
schema_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
privileges = list(string)
})))
sequence_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
objects = optional(list(string))
privileges = list(string)
})))
table_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
objects = optional(list(string))
privileges = list(string)
})))
}))
| `[]` | no | -| [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | -| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [tenant](#input\_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | +| Name | Description | Type | Default | Required | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | +| [additional_tag_map](#input_additional_tag_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [attributes](#input_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [context](#input_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional_tag_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | +| [databases](#input_databases) | The logical database to create and configure |
list(object({
name = string
connection_limit = optional(number)
}))
| `[]` | no | +| [delimiter](#input_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [descriptor_formats](#input_descriptor_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | +| [enabled](#input_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [environment](#input_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | +| [id_length_limit](#input_id_length_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [label_key_case](#input_label_key_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label_order](#input_label_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | +| [label_value_case](#input_label_value_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | +| [labels_as_tags](#input_labels_as_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | +| [name](#input_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | +| [namespace](#input_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [regex_replace_chars](#input_regex_replace_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [roles](#input_roles) | List of static postgres roles to create and related permissions. These are for applications that use static credentials and don't use IAM DB Auth. See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role |
list(object({
role = object({
# See defaults: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs/resources/postgresql_role
name = string
superuser = optional(bool)
create_database = optional(bool)
create_role = optional(bool)
inherit = optional(bool)
login = optional(bool)
replication = optional(bool)
bypass_row_level_security = optional(bool)
connection_limit = optional(number)
encrypted_password = optional(bool)
password = optional(string)
roles = optional(list(string))
search_path = optional(list(string))
valid_until = optional(string)
skip_drop_role = optional(bool)
skip_reassign_owned = optional(bool)
statement_timeout = optional(number)
assume_role = optional(string)
})
default_privileges = optional(list(object({
role = string
database = string
schema = string
owner = string
object_type = string
privileges = list(string)
})))
database_grants = optional(object({
role = string
database = string
object_type = string
privileges = list(string)
}))
schema_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
privileges = list(string)
})))
sequence_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
objects = optional(list(string))
privileges = list(string)
})))
table_grants = optional(list(object({
role = string
database = string
schema = string
object_type = string
objects = optional(list(string))
privileges = list(string)
})))
}))
| `[]` | no | +| [stage](#input_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | +| [tags](#input_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [tenant](#input_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | ## Outputs -| Name | Description | -|------|-------------| -| [base\_roles](#output\_base\_roles) | Base roles (group roles, no dependencies on other custom roles) | -| [database\_access](#output\_database\_access) | n/a | -| [databases](#output\_databases) | n/a | -| [default\_privileges](#output\_default\_privileges) | n/a | -| [dependent\_roles](#output\_dependent\_roles) | Dependent roles (login roles that inherit from other custom roles) | -| [roles](#output\_roles) | All created roles (both base and dependent) | -| [schema\_access](#output\_schema\_access) | n/a | -| [sequence\_access](#output\_sequence\_access) | n/a | -| [table\_access](#output\_table\_access) | n/a | +| Name | Description | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| [base_roles](#output_base_roles) | Base roles (group roles, no dependencies on other custom roles) | +| [database_access](#output_database_access) | n/a | +| [databases](#output_databases) | n/a | +| [default_privileges](#output_default_privileges) | n/a | +| [dependent_roles](#output_dependent_roles) | Dependent roles (login roles that inherit from other custom roles) | +| [roles](#output_roles) | All created roles (both base and dependent) | +| [schema_access](#output_schema_access) | n/a | +| [sequence_access](#output_sequence_access) | n/a | +| [table_access](#output_table_access) | n/a | + ## Built By From 3d0f92c847457474c177c963c9dacf7c46270a72 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Tue, 31 Mar 2026 17:41:41 -0600 Subject: [PATCH 12/14] linting From 6ee103011075e08bb1aa0dceae586429f25d0793 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Fri, 3 Apr 2026 10:05:11 -0600 Subject: [PATCH 13/14] examples: change db name and add pg_ prefixed login roles --- .../llm_chat_app/2_create_test_objects.sh | 2 +- .../llm_chat_app/3_run_verification_tests.sh | 2 +- examples/llm_chat_app/4_cleanup.sh | 4 +- examples/llm_chat_app/README.md | 187 ++++++------------ examples/llm_chat_app/TEST_INSTRUCTIONS.md | 4 +- examples/llm_chat_app/fixtures.auto.tfvars | 124 +++++++----- examples/llm_chat_app/main.tf | 10 +- examples/llm_chat_app/run_tests.sh | 16 +- 8 files changed, 147 insertions(+), 202 deletions(-) diff --git a/examples/llm_chat_app/2_create_test_objects.sh b/examples/llm_chat_app/2_create_test_objects.sh index 5d20203..2b05547 100755 --- a/examples/llm_chat_app/2_create_test_objects.sh +++ b/examples/llm_chat_app/2_create_test_objects.sh @@ -5,7 +5,7 @@ set -e export PGHOST=localhost export PGPORT=5432 -export PGDATABASE=llm_service +export PGDATABASE=llm_chat_app echo "==============================================" echo "Creating Test Objects" diff --git a/examples/llm_chat_app/3_run_verification_tests.sh b/examples/llm_chat_app/3_run_verification_tests.sh index 0970c8d..17ad115 100755 --- a/examples/llm_chat_app/3_run_verification_tests.sh +++ b/examples/llm_chat_app/3_run_verification_tests.sh @@ -5,7 +5,7 @@ set -e export PGHOST=localhost export PGPORT=5432 -export PGDATABASE=llm_service +export PGDATABASE=llm_chat_app echo "==============================================" echo "Running Verification Tests" diff --git a/examples/llm_chat_app/4_cleanup.sh b/examples/llm_chat_app/4_cleanup.sh index c601dc9..890a143 100755 --- a/examples/llm_chat_app/4_cleanup.sh +++ b/examples/llm_chat_app/4_cleanup.sh @@ -16,11 +16,11 @@ echo "" export PGUSER=admin_user export PGPASSWORD=insecure-pass-for-demo-admin-user -echo "Step 1: Terminating connections to llm_service database..." +echo "Step 1: Terminating connections to llm_chat_app database..." psql -c " SELECT pg_terminate_backend(pid) FROM pg_stat_activity -WHERE datname = 'llm_service' AND pid <> pg_backend_pid(); +WHERE datname = 'llm_chat_app' AND pid <> pg_backend_pid(); " 2>/dev/null || true echo "" diff --git a/examples/llm_chat_app/README.md b/examples/llm_chat_app/README.md index e9fcccf..0245863 100644 --- a/examples/llm_chat_app/README.md +++ b/examples/llm_chat_app/README.md @@ -1,6 +1,6 @@ # Example: LLM Chat App Setup -This example shows how to create a comprehensive role-based access control (RBAC) setup for an LLM pattern reviewer service with multiple schemas and permission boundaries. +This example shows how to create a comprehensive role-based access control (RBAC) setup for an LLM chat application with multiple schemas and permission boundaries. ## Prerequisites @@ -9,6 +9,7 @@ This example shows how to create a comprehensive role-based access control (RBAC 2. **Create an admin user** with CREATEROLE privilege: ```bash psql postgres -c "CREATE ROLE admin_user LOGIN CREATEROLE PASSWORD 'insecure-pass-for-demo-admin-user';" + psql postgres -c "GRANT pg_monitor TO admin_user WITH ADMIN OPTION;" ``` ## Usage @@ -17,30 +18,26 @@ This example shows how to create a comprehensive role-based access control (RBAC cd examples/llm_chat_app # Initialize Terraform -terraform init +tofu init # Preview the changes -terraform plan +tofu plan # Apply the configuration -terraform apply +tofu apply ``` ## Verify the Setup -After `terraform apply` succeeds, run these tests to verify the RBAC configuration. +After `tofu apply` succeeds, run these tests to verify the RBAC configuration. -### Set up environment +### Quick smoke test ```bash export PGHOST=localhost export PGPORT=5432 -export PGDATABASE=llm_service -``` - -### Quick smoke test +export PGDATABASE=llm_chat_app -```bash # Migration role can connect and create tables PGPASSWORD=demo-password-migrator psql -U service_migrator -c " CREATE TABLE app.smoke_test (id serial); @@ -50,116 +47,59 @@ PGPASSWORD=demo-password-migrator psql -U service_migrator -c " # FastAPI RW can connect PGPASSWORD=demo-password-fastapi-rw psql -U service_fastapi_rw -c "SELECT 1 AS connected;" - -# Verify connection limits -PGPASSWORD=demo-password-migrator psql -U service_migrator -c " - SELECT rolname, rolconnlimit - FROM pg_roles - WHERE rolname LIKE 'role_service_%' OR rolname LIKE 'service_%' - ORDER BY rolname; -" ``` ### Full test suite -Run the included test scripts to verify all permissions: - ```bash # Run all tests at once ./RUN_ALL_TESTS.sh # Or run steps individually: -./1_apply_terraform.sh # Apply the Terraform configuration -./2_create_test_objects.sh # Create test tables/views/functions -./3_run_verification_tests.sh # Verify RBAC permissions -./4_cleanup.sh # Clean up test objects and destroy infrastructure +./1_apply_terraform.sh # Apply the Terraform configuration +./2_create_test_objects.sh # Create test tables/views/functions +./3_run_verification_tests.sh # Verify RBAC permissions +./4_cleanup.sh # Clean up test objects and destroy infrastructure ``` ## Roles and Permissions -Group roles (no login) use the `role_` prefix. Login roles do not have the prefix. - -| Role | Purpose | Login | Connection Limit | -| ------------------------ | -------------------------------------------------- | ----- | ---------------- | -| `role_pg_cluster_admin` | Creates users/roles across the cluster (Terraform) | Yes | - | -| `role_pg_monitoring` | System stats access (Datadog, Grafana) | Yes | - | -| `role_service_migration` | Group role that owns database, schemas, all DDL | No | - | -| `service_migrator` | Login role for CI/CD migrations | Yes | 5 | -| `role_service_rw` | Group role for read/write on `app` schema | No | - | -| `role_service_ro` | Group role for read-only on `app` schema | No | - | -| `service_fastapi_rw` | FastAPI backend with write access | Yes | 30 | -| `service_fastapi_ro` | FastAPI backend with read-only access | Yes | 30 | -| `service_pipeline_rw` | Data pipeline with write access to all schemas | Yes | 10 | -| `service_pipeline_ro` | Data pipeline with read-only access to all schemas | Yes | 10 | +Group roles (no login) use the `role_` prefix. Login roles do not. + +| Role | Type | Purpose | Login | Connection Limit | +| ------------------------ | ----- | ------------------------------------------------------- | ----- | ---------------- | +| `role_pg_cluster_admin` | Group | Holds CREATEROLE — manages users/roles cluster-wide | No | - | +| `pg_cluster_admin` | Login | Terraform / DBA login; inherits cluster admin | Yes | - | +| `role_pg_monitoring` | Group | Holds pg_monitor membership | No | - | +| `pg_monitoring` | Login | Datadog / Grafana login; inherits monitoring | Yes | - | +| `role_service_migration` | Group | Owns schemas and all DDL | No | - | +| `service_migrator` | Login | CI/CD migrations login | Yes | 5 | +| `role_service_rw` | Group | Read/write on `app` schema | No | - | +| `role_service_ro` | Group | Read-only on `app` schema | No | - | +| `service_fastapi_rw` | Login | FastAPI backend with write access | Yes | 30 | +| `service_fastapi_ro` | Login | FastAPI backend with read-only access | Yes | 30 | +| `service_pipeline_rw` | Login | Data pipeline with write access to ref_data schemas | Yes | 10 | +| `service_pipeline_ro` | Login | Data pipeline with read-only access to ref_data schemas | Yes | 10 | ## Schemas -| Schema | Purpose | -| -------------- | ------------------------------------------------------------------ | -| `app` | LLM Chat Application tables (users, conversations, messages, etc.) | -| `ref_data_abc` | Data pipeline for document corpus ingestion (supports RAG) | -| `ref_data_xyz` | Data pipeline for document corpus processing (supports RAG) | +| Schema | Purpose | +| ----------------------- | ------------------------------------------------------------------ | +| `app` | LLM Chat Application tables (users, conversations, messages, etc.) | +| `ref_data_pipeline_abc` | Document corpus ingestion pipeline (supports RAG) | +| `ref_data_pipeline_xyz` | Document corpus processing pipeline (supports RAG) | The `ref_data_*` schemas are managed by separate data pipelines that populate the document corpus used by the Chat Application's RAG (Retrieval-Augmented Generation) system. -## Grant Configuration - -This example demonstrates two approaches for configuring database grants: - -### 1. Inline Grants (in `roles` variable) - -Use for grants that don't depend on schemas created by Terraform: - -```hcl -roles = [ - { - role = { - name = "role_service_migration" - login = false - } - database_grants = { - role = "role_service_migration" - database = "llm_service" - object_type = "database" - privileges = ["CREATE", "CONNECT", "TEMPORARY"] - } - } -] -``` - -### 2. Separate Grant Variables (in `fixtures.auto.tfvars`) - -Use for grants on schemas created by Terraform. This data-driven approach replaces hundreds of lines of hardcoded resources: - -```hcl -schema_grants = [ - { role = "role_service_rw", database = "llm_service", schema = "app", privileges = ["USAGE"] }, - { role = "role_service_ro", database = "llm_service", schema = "app", privileges = ["USAGE"] }, -] - -table_grants = [ - { role = "role_service_rw", database = "llm_service", schema = "app", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, - { role = "role_service_ro", database = "llm_service", schema = "app", privileges = ["SELECT"] }, -] - -default_privileges = [ - { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, -] -``` - -**Supported grant types:** `schema_grants`, `table_grants`, `sequence_grants`, `default_privileges` - -The `objects` field in table/sequence grants is optional - omit it to grant on all objects in the schema. - ## Schema Access Matrix -| | app | ref_data_abc | ref_data_xyz | -| ------------------- | --- | ------------ | ------------ | -| service_migrator | DDL | DDL | DDL | -| service_fastapi_rw | RW | - | - | -| service_fastapi_ro | RO | - | - | -| service_pipeline_rw | RW | RW | RW | -| service_pipeline_ro | RO | RO | RO | +| | app | ref_data_pipeline_abc | ref_data_pipeline_xyz | +| --------------------- | --- | --------------------- | --------------------- | +| `service_migrator` | DDL | DDL | DDL | +| `service_fastapi_rw` | RW | - | - | +| `service_fastapi_ro` | RO | - | - | +| `service_pipeline_rw` | RW | RW | RW | +| `service_pipeline_ro` | RO | RO | RO | **Legend:** DDL = CREATE/ALTER/DROP, RW = CRUD, RO = SELECT only @@ -168,11 +108,16 @@ The `objects` field in table/sequence grants is optional - omit it to grant on a ```mermaid flowchart TB subgraph cluster["Cluster-Wide Roles"] - admin["role_pg_cluster_admin
login • creates roles"] - monitoring["role_pg_monitoring
login • pg_monitor member"] + role_admin["role_pg_cluster_admin
no login • CREATEROLE"] + pg_admin["pg_cluster_admin
login • inherits cluster admin"] + role_monitoring["role_pg_monitoring
no login • pg_monitor member"] + pg_monitoring["pg_monitoring
login • inherits monitoring"] + + pg_admin -->|inherits| role_admin + pg_monitoring -->|inherits| role_monitoring end - subgraph service["Service-Scoped Roles (llm_service)"] + subgraph service["Service-Scoped Roles (llm_chat_app)"] subgraph groups["Group Roles (no login)"] migration["role_service_migration
DDL on all schemas"] rw["role_service_rw
DML on app schema"] @@ -186,36 +131,24 @@ flowchart TB pipeline_rw["service_pipeline_rw
conn_limit=10"] pipeline_ro["service_pipeline_ro
conn_limit=10"] end - - subgraph devs["Developer Accounts"] - senior["Senior Developers"] - junior["Junior/Mid Developers"] - end end - %% Inheritance arrows migrator -->|inherits| migration - fastapi_rw -->|inherits| rw - pipeline_rw -->|inherits| rw - senior -->|inherits| rw - fastapi_ro -->|inherits| ro + pipeline_rw -->|inherits| rw pipeline_ro -->|inherits| ro - junior -->|inherits| ro - %% Styling classDef clusterRole fill:#e1f5fe,stroke:#01579b classDef groupRole fill:#fff3e0,stroke:#e65100 classDef loginRole fill:#e8f5e9,stroke:#2e7d32 - classDef devRole fill:#f3e5f5,stroke:#7b1fa2 classDef migrationGroup fill:#fce4ec,stroke:#c2185b - class admin,monitoring clusterRole + class role_admin,role_monitoring clusterRole + class pg_admin,pg_monitoring clusterRole class rw,ro groupRole class migration migrationGroup class migrator,fastapi_rw,fastapi_ro,pipeline_rw,pipeline_ro loginRole - class senior,junior devRole ``` ## Schema Access Diagram @@ -223,17 +156,17 @@ flowchart TB ```mermaid flowchart TB subgraph ddl_access["DDL Access (Schema Owner)"] - migration["migration"] + migration["service_migrator"] end subgraph app_only["App Schema Only"] - fastapi_rw["fastapi_rw"] - fastapi_ro["fastapi_ro"] + fastapi_rw["service_fastapi_rw"] + fastapi_ro["service_fastapi_ro"] end subgraph all_schemas["All Schemas Access"] - pipeline_rw["pipeline_rw"] - pipeline_ro["pipeline_ro"] + pipeline_rw["service_pipeline_rw"] + pipeline_ro["service_pipeline_ro"] end subgraph schemas["Schemas"] @@ -243,16 +176,13 @@ flowchart TB ref_xyz["ref_data_pipeline_xyz"] end - %% DDL access - straight down to all schemas migration -->|DDL| app migration -->|DDL| ref_abc migration -->|DDL| ref_xyz - %% App-only roles - single path to app schema fastapi_rw -->|RW| app fastapi_ro -->|RO| app - %% Pipeline roles - access all schemas pipeline_rw -->|RW| app pipeline_rw -->|RW| ref_abc pipeline_rw -->|RW| ref_xyz @@ -261,7 +191,6 @@ flowchart TB pipeline_ro -->|RO| ref_abc pipeline_ro -->|RO| ref_xyz - %% Styling classDef schemaNode fill:#e3f2fd,stroke:#1565c0 classDef ddlRole fill:#fce4ec,stroke:#c2185b classDef rwRole fill:#e8f5e9,stroke:#2e7d32 @@ -277,13 +206,11 @@ flowchart TB ## Cleanup -To tear down the example infrastructure: - ```bash ./4_cleanup.sh ``` -This drops test objects and runs `terraform destroy` to remove all created roles, grants, and the database. +This drops test objects and runs `tofu destroy` to remove all created roles, grants, and the database. ## Note about authentication on Mac diff --git a/examples/llm_chat_app/TEST_INSTRUCTIONS.md b/examples/llm_chat_app/TEST_INSTRUCTIONS.md index 74708b4..ef8ec77 100644 --- a/examples/llm_chat_app/TEST_INSTRUCTIONS.md +++ b/examples/llm_chat_app/TEST_INSTRUCTIONS.md @@ -41,7 +41,7 @@ chmod +x 1_apply_terraform.sh This runs `tofu apply -auto-approve` to create: -- Database: `llm_service` +- Database: `llm_chat_app` - Roles: migration, group roles (rw/ro), login roles (fastapi_rw, fastapi_ro, pipeline_rw, pipeline_ro) - Schemas: `app`, `ref_data_pipeline_abc`, `ref_data_pipeline_xyz` - Grants and default privileges @@ -101,7 +101,7 @@ Example: ```bash export PGHOST=localhost export PGPORT=5432 -export PGDATABASE=llm_service +export PGDATABASE=llm_chat_app PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "SELECT * FROM app.test_users;" ``` diff --git a/examples/llm_chat_app/fixtures.auto.tfvars b/examples/llm_chat_app/fixtures.auto.tfvars index 307bbfa..4ceec4d 100644 --- a/examples/llm_chat_app/fixtures.auto.tfvars +++ b/examples/llm_chat_app/fixtures.auto.tfvars @@ -15,7 +15,7 @@ db_sslmode = "disable" # Database configuration databases = [ { - name = "llm_service" + name = "llm_chat_app" connection_limit = 100 } ] @@ -30,20 +30,38 @@ roles = [ { role = { name = "role_pg_cluster_admin" - login = true + login = false inherit = true create_role = true create_database = false - password = "demo-password-cluster-admin" } }, { role = { - name = "role_pg_monitoring" + name = "pg_cluster_admin" login = true inherit = true - roles = ["pg_monitor"] + roles = ["role_pg_cluster_admin"] + password = "demo-password-cluster-admin" + } + }, + + { + role = { + name = "role_pg_monitoring" + login = false + inherit = true + roles = ["pg_monitor"] + } + }, + + { + role = { + name = "pg_monitoring" + login = true + inherit = true + roles = ["role_pg_monitoring"] password = "demo-password-monitoring" } }, @@ -60,24 +78,24 @@ roles = [ } database_grants = { role = "role_service_migration" - database = "llm_service" + database = "llm_chat_app" object_type = "database" privileges = ["CREATE", "CONNECT", "TEMPORARY"] } schema_grants = [ - { role = "role_service_migration", database = "llm_service", schema = "app", object_type = "schema", privileges = ["USAGE", "CREATE"] }, - { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE", "CREATE"] }, - { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE", "CREATE"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "app", object_type = "schema", privileges = ["USAGE", "CREATE"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE", "CREATE"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE", "CREATE"] }, ] table_grants = [ - { role = "role_service_migration", database = "llm_service", schema = "app", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, - { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, - { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "app", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES"] }, ] sequence_grants = [ - { role = "role_service_migration", database = "llm_service", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, - { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, - { role = "role_service_migration", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_migration", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, ] }, @@ -105,23 +123,23 @@ roles = [ } database_grants = { role = "role_service_rw" - database = "llm_service" + database = "llm_chat_app" object_type = "database" privileges = ["CONNECT"] } schema_grants = [ - { role = "role_service_rw", database = "llm_service", schema = "app", object_type = "schema", privileges = ["USAGE"] }, + { role = "role_service_rw", database = "llm_chat_app", schema = "app", object_type = "schema", privileges = ["USAGE"] }, ] table_grants = [ - { role = "role_service_rw", database = "llm_service", schema = "app", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, + { role = "role_service_rw", database = "llm_chat_app", schema = "app", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, ] sequence_grants = [ - { role = "role_service_rw", database = "llm_service", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_rw", database = "llm_chat_app", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, ] default_privileges = [ - { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, - { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, - { role = "role_service_rw", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "role_service_rw", database = "llm_chat_app", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"] }, + { role = "role_service_rw", database = "llm_chat_app", schema = "app", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "role_service_rw", database = "llm_chat_app", schema = "app", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, ] }, @@ -137,23 +155,23 @@ roles = [ } database_grants = { role = "role_service_ro" - database = "llm_service" + database = "llm_chat_app" object_type = "database" privileges = ["CONNECT"] } schema_grants = [ - { role = "role_service_ro", database = "llm_service", schema = "app", object_type = "schema", privileges = ["USAGE"] }, + { role = "role_service_ro", database = "llm_chat_app", schema = "app", object_type = "schema", privileges = ["USAGE"] }, ] table_grants = [ - { role = "role_service_ro", database = "llm_service", schema = "app", object_type = "table", privileges = ["SELECT"] }, + { role = "role_service_ro", database = "llm_chat_app", schema = "app", object_type = "table", privileges = ["SELECT"] }, ] sequence_grants = [ - { role = "role_service_ro", database = "llm_service", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "role_service_ro", database = "llm_chat_app", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, ] default_privileges = [ - { role = "role_service_ro", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, - { role = "role_service_ro", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, - { role = "role_service_ro", database = "llm_service", schema = "app", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "role_service_ro", database = "llm_chat_app", schema = "app", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, + { role = "role_service_ro", database = "llm_chat_app", schema = "app", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "role_service_ro", database = "llm_chat_app", schema = "app", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, ] }, @@ -197,24 +215,24 @@ roles = [ password = "demo-password-pipeline-rw" } schema_grants = [ - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE"] }, ] table_grants = [ - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, ] sequence_grants = [ - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, ] default_privileges = [ - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, - { role = "service_pipeline_rw", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "table", privileges = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] }, + { role = "service_pipeline_rw", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, ] }, @@ -228,24 +246,24 @@ roles = [ password = "demo-password-pipeline-ro" } schema_grants = [ - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "schema", privileges = ["USAGE"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "schema", privileges = ["USAGE"] }, ] table_grants = [ - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "table", privileges = ["SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "table", privileges = ["SELECT"] }, ] sequence_grants = [ - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_abc", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, ] default_privileges = [ - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, - { role = "service_pipeline_ro", database = "llm_service", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_abc", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "table", privileges = ["SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "sequence", privileges = ["USAGE", "SELECT"] }, + { role = "service_pipeline_ro", database = "llm_chat_app", schema = "ref_data_pipeline_xyz", owner = "role_service_migration", object_type = "function", privileges = ["EXECUTE"] }, ] }, ] diff --git a/examples/llm_chat_app/main.tf b/examples/llm_chat_app/main.tf index 8f86af2..5a93afb 100644 --- a/examples/llm_chat_app/main.tf +++ b/examples/llm_chat_app/main.tf @@ -16,21 +16,21 @@ resource "postgresql_database" "databases" { resource "postgresql_schema" "app" { name = "app" - database = "llm_service" + database = "llm_chat_app" depends_on = [postgresql_database.databases] } resource "postgresql_schema" "ref_data_pipeline_abc" { name = "ref_data_pipeline_abc" - database = "llm_service" + database = "llm_chat_app" depends_on = [postgresql_database.databases] } resource "postgresql_schema" "ref_data_pipeline_xyz" { name = "ref_data_pipeline_xyz" - database = "llm_service" + database = "llm_chat_app" depends_on = [postgresql_database.databases] } @@ -60,7 +60,7 @@ module "postgres_automation" { # ======================================== resource "postgresql_grant" "revoke_public_connect" { - database = "llm_service" + database = "llm_chat_app" role = "public" object_type = "database" privileges = [] @@ -69,7 +69,7 @@ resource "postgresql_grant" "revoke_public_connect" { } resource "postgresql_grant" "revoke_public_schema" { - database = "llm_service" + database = "llm_chat_app" role = "public" schema = "public" object_type = "schema" diff --git a/examples/llm_chat_app/run_tests.sh b/examples/llm_chat_app/run_tests.sh index 30f64f7..8663058 100644 --- a/examples/llm_chat_app/run_tests.sh +++ b/examples/llm_chat_app/run_tests.sh @@ -26,7 +26,7 @@ echo "" # Set connection variables export PGHOST=localhost export PGPORT=5432 -export PGDATABASE=llm_service +export PGDATABASE=llm_chat_app echo "--- Prerequisites: Creating Test Objects ---" echo "" @@ -75,7 +75,7 @@ echo "" # Test 2: Migration Role DDL Access echo "--- Test 2: Migration Role DDL Access ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_chat_app psql -c " CREATE TABLE app.migration_test (id int); ALTER TABLE app.migration_test ADD COLUMN name text; DROP TABLE app.migration_test; @@ -85,7 +85,7 @@ echo "" # Test 3: FastAPI RW Role echo "--- Test 3: FastAPI RW Role - DML on app schema ---" -PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_chat_app psql -c " SELECT * FROM app.test_users; INSERT INTO app.test_users (name) VALUES ('fastapi_test'); DELETE FROM app.test_users WHERE name = 'fastapi_test'; @@ -95,7 +95,7 @@ echo "" # Test 4: FastAPI RO Role echo "--- Test 4: FastAPI RO Role - SELECT only ---" -PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_fastapi_ro PGPASSWORD=demo-password-fastapi-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_chat_app psql -c " SELECT * FROM app.test_users; SELECT 'TEST 4 PASSED: FastAPI RO has SELECT' AS result; " @@ -103,7 +103,7 @@ echo "" # Test 5: Pipeline RW Role echo "--- Test 5: Pipeline RW Role - All schemas access ---" -PGUSER=service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_pipeline_rw PGPASSWORD=demo-password-pipeline-rw PGHOST=localhost PGPORT=5432 PGDATABASE=llm_chat_app psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; @@ -113,7 +113,7 @@ echo "" # Test 6: Pipeline RO Role echo "--- Test 6: Pipeline RO Role - Read access to all schemas ---" -PGUSER=service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_pipeline_ro PGPASSWORD=demo-password-pipeline-ro PGHOST=localhost PGPORT=5432 PGDATABASE=llm_chat_app psql -c " SELECT * FROM app.test_users; SELECT * FROM ref_data_pipeline_abc.test_ref; SELECT * FROM ref_data_pipeline_xyz.test_ref; @@ -123,7 +123,7 @@ echo "" # Test 7: Connection Limits echo "--- Test 7: Connection Limits ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_chat_app psql -c " SELECT rolname, rolconnlimit FROM pg_roles WHERE rolname LIKE 'role_service_%' @@ -133,7 +133,7 @@ echo "" # Test 8: Role Inheritance echo "--- Test 8: Role Inheritance ---" -PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_service psql -c " +PGUSER=service_migrator PGPASSWORD=demo-password-migration PGHOST=localhost PGPORT=5432 PGDATABASE=llm_chat_app psql -c " SELECT r.rolname AS role, ARRAY_AGG(m.rolname) AS member_of From 035acf06a74fb4d3a16cc75fb14539b57e2d5dd9 Mon Sep 17 00:00:00 2001 From: Weston Platter Date: Fri, 3 Apr 2026 10:18:34 -0600 Subject: [PATCH 14/14] examples: remove the pg admin role, we'll use the admin_user that we create in aws --- examples/llm_chat_app/README.md | 9 ++------- examples/llm_chat_app/fixtures.auto.tfvars | 22 ++-------------------- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/examples/llm_chat_app/README.md b/examples/llm_chat_app/README.md index 0245863..2df23b3 100644 --- a/examples/llm_chat_app/README.md +++ b/examples/llm_chat_app/README.md @@ -68,8 +68,6 @@ Group roles (no login) use the `role_` prefix. Login roles do not. | Role | Type | Purpose | Login | Connection Limit | | ------------------------ | ----- | ------------------------------------------------------- | ----- | ---------------- | -| `role_pg_cluster_admin` | Group | Holds CREATEROLE — manages users/roles cluster-wide | No | - | -| `pg_cluster_admin` | Login | Terraform / DBA login; inherits cluster admin | Yes | - | | `role_pg_monitoring` | Group | Holds pg_monitor membership | No | - | | `pg_monitoring` | Login | Datadog / Grafana login; inherits monitoring | Yes | - | | `role_service_migration` | Group | Owns schemas and all DDL | No | - | @@ -108,12 +106,9 @@ The `ref_data_*` schemas are managed by separate data pipelines that populate th ```mermaid flowchart TB subgraph cluster["Cluster-Wide Roles"] - role_admin["role_pg_cluster_admin
no login • CREATEROLE"] - pg_admin["pg_cluster_admin
login • inherits cluster admin"] role_monitoring["role_pg_monitoring
no login • pg_monitor member"] pg_monitoring["pg_monitoring
login • inherits monitoring"] - pg_admin -->|inherits| role_admin pg_monitoring -->|inherits| role_monitoring end @@ -144,8 +139,8 @@ flowchart TB classDef loginRole fill:#e8f5e9,stroke:#2e7d32 classDef migrationGroup fill:#fce4ec,stroke:#c2185b - class role_admin,role_monitoring clusterRole - class pg_admin,pg_monitoring clusterRole + class role_monitoring clusterRole + class pg_monitoring clusterRole class rw,ro groupRole class migration migrationGroup class migrator,fastapi_rw,fastapi_ro,pipeline_rw,pipeline_ro loginRole diff --git a/examples/llm_chat_app/fixtures.auto.tfvars b/examples/llm_chat_app/fixtures.auto.tfvars index 4ceec4d..b802a89 100644 --- a/examples/llm_chat_app/fixtures.auto.tfvars +++ b/examples/llm_chat_app/fixtures.auto.tfvars @@ -26,26 +26,8 @@ roles = [ # ======================================== # Cluster-wide roles # ======================================== - - { - role = { - name = "role_pg_cluster_admin" - login = false - inherit = true - create_role = true - create_database = false - } - }, - - { - role = { - name = "pg_cluster_admin" - login = true - inherit = true - roles = ["role_pg_cluster_admin"] - password = "demo-password-cluster-admin" - } - }, + # Note: admin_user (the RDS master user stored in SSM) serves as the cluster + # admin and is used by Terraform to connect. It does not need to be created here. { role = {